├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── python-publish.yml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── AUTHORS.rst ├── CHANGES ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── Makefile ├── README.rst ├── README_BUILD.rst ├── TODO ├── aiohttp_admin2 ├── __init__.py ├── connection_injectors.py ├── controllers │ ├── __init__.py │ ├── controller.py │ ├── exceptions.py │ ├── mongo_controller.py │ ├── mysql_controller.py │ ├── postgres_controller.py │ ├── relations.py │ └── types.py ├── exceptions.py ├── mappers │ ├── __init__.py │ ├── base.py │ ├── exceptions.py │ ├── fields │ │ ├── __init__.py │ │ ├── abc.py │ │ ├── array_field.py │ │ ├── bolean_field.py │ │ ├── choice_field.py │ │ ├── date_field.py │ │ ├── float_field.py │ │ ├── int_field.py │ │ ├── json_field.py │ │ ├── mongo_fields.py │ │ ├── string_field.py │ │ └── url_field.py │ ├── generics │ │ ├── __init__.py │ │ └── mongo.py │ └── validators │ │ ├── __init__.py │ │ ├── length.py │ │ └── required.py ├── resources │ ├── __init__.py │ ├── abc.py │ ├── dict_resource │ │ ├── __init__.py │ │ ├── dict_resource.py │ │ └── filters.py │ ├── exceptions.py │ ├── mongo_resource │ │ ├── __init__.py │ │ ├── filters.py │ │ └── mongo_resource.py │ ├── mysql_resource │ │ ├── __init__.py │ │ └── mysql_resource.py │ ├── postgres_resource │ │ ├── __init__.py │ │ ├── filters.py │ │ ├── postgres_resource.py │ │ └── utils.py │ └── types.py └── views │ ├── __init__.py │ ├── aiohttp │ ├── __init__.py │ ├── admin.py │ ├── exceptions.py │ ├── setup.py │ ├── static │ │ ├── css │ │ │ ├── base.css │ │ │ └── theme.css │ │ └── js │ │ │ └── main.js │ ├── templates │ │ └── aiohttp_admin │ │ │ ├── blocks │ │ │ ├── cursor_pagination.html │ │ │ ├── filters │ │ │ │ ├── boolean_filter.html │ │ │ │ ├── choice_filter.html │ │ │ │ ├── datetime_filter.html │ │ │ │ ├── search_filter.html │ │ │ │ └── single_value_filter.html │ │ │ ├── form │ │ │ │ ├── field_errors.html │ │ │ │ ├── field_title.html │ │ │ │ ├── fields │ │ │ │ │ ├── array_field.html │ │ │ │ │ ├── autocomplete_field.html │ │ │ │ │ ├── boolean_field.html │ │ │ │ │ ├── choice_field.html │ │ │ │ │ ├── ck_editor_field.html │ │ │ │ │ ├── date_field.html │ │ │ │ │ ├── datetime_field.html │ │ │ │ │ ├── field.html │ │ │ │ │ ├── file_field.html │ │ │ │ │ ├── image_field.html │ │ │ │ │ ├── json_field.html │ │ │ │ │ ├── long_string_field.html │ │ │ │ │ ├── readonly_field.html │ │ │ │ │ └── string_field.html │ │ │ │ └── form.html │ │ │ ├── header.html │ │ │ ├── list_action_buttons.html │ │ │ ├── list_cell.html │ │ │ ├── list_objects_block.html │ │ │ ├── list_objects_header_block.html │ │ │ ├── messages.html │ │ │ ├── nav_aside.html │ │ │ ├── pagination.html │ │ │ └── tabs_bar.html │ │ │ └── layouts │ │ │ ├── base.html │ │ │ ├── create_page.html │ │ │ ├── custom_page.html │ │ │ ├── custom_tab_page.html │ │ │ ├── delete_page.html │ │ │ ├── detail_edit_page.html │ │ │ ├── detail_view_page.html │ │ │ ├── list_cursor_page.html │ │ │ └── list_page.html │ ├── utils.py │ └── views │ │ ├── __init__.py │ │ ├── base.py │ │ ├── controller_view.py │ │ ├── dashboard.py │ │ ├── many_to_many_tab_view.py │ │ ├── tab_base_view.py │ │ ├── tab_template_view.py │ │ ├── template_view.py │ │ └── utils.py │ ├── filters.py │ └── widgets.py ├── docs ├── Makefile ├── _static │ └── style.css ├── authors.rst ├── conf.py ├── contributing.rst ├── history.rst ├── images │ ├── access_settings_result.png │ ├── body_field_html.png │ ├── custom_context.png │ ├── custom_fields_example.png │ ├── custom_template_name_dashboard.png │ ├── filters_example.png │ ├── full_controller_example.png │ ├── get_relation_example.png │ ├── groups_example.png │ ├── index.png │ ├── infinity_example.png │ ├── inline_fields_example.png │ ├── one_to_one_example.png │ ├── one_to_one_relation_autocomplete.png │ ├── one_to_one_relation_list.png │ ├── overview.excalidraw │ ├── overview.png │ ├── overview_header.png │ ├── post_controller_first.png │ ├── search_fields_example.png │ ├── simple_create.png │ ├── simple_delete.png │ ├── simple_example.png │ ├── simple_list.png │ ├── simple_update.png │ ├── template_page.png │ └── validation_error_example.png ├── index.rst ├── installation.rst ├── make.bat └── usage_aiohttp_admin.rst ├── example_projects ├── __init__.py ├── docker-compose.yaml ├── main │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ ├── actors │ │ │ ├── __init__.py │ │ │ └── controllers.py │ │ ├── genres │ │ │ ├── __init__.py │ │ │ ├── controllers.py │ │ │ ├── mappers.py │ │ │ ├── pages.py │ │ │ └── validators.py │ │ ├── images │ │ │ ├── __init__.py │ │ │ └── controller.py │ │ ├── injectors.py │ │ ├── mongo_admin.py │ │ ├── movies │ │ │ ├── __init__.py │ │ │ ├── controllers.py │ │ │ └── pages.py │ │ ├── shows │ │ │ ├── __init__.py │ │ │ └── controllers.py │ │ ├── template_view.py │ │ └── users │ │ │ ├── __init__.py │ │ │ └── controllers.py │ ├── auth │ │ ├── __init__.py │ │ ├── authorization.py │ │ ├── middlewares.py │ │ ├── tables.py │ │ ├── users.py │ │ └── views.py │ ├── catalog │ │ ├── __init__.py │ │ └── tables.py │ ├── db.py │ ├── load_data.py │ ├── routes.py │ ├── static │ │ └── .keep │ └── templates │ │ └── login.html └── quick_start │ ├── README.md │ ├── __init__.py │ ├── admin.py │ ├── app.py │ ├── auth.py │ ├── tables.py │ └── templates │ ├── login.html │ └── my_custom_dashboard.html ├── poetry.lock ├── poetry.toml ├── pyproject.toml ├── requirements └── documentation.txt └── tests ├── __init__.py ├── conftest.py ├── controllers ├── __init__.py ├── test_controller.py └── test_relations.py ├── docker-compose.yml ├── docker_fixtures.py ├── manager_fixtures.py ├── mappers ├── __init__.py ├── fields │ ├── __init__.py │ ├── test_array_field.py │ ├── test_boolean_field.py │ ├── test_choice_fields.py │ ├── test_date_field.py │ ├── test_float_field.py │ ├── test_int_field.py │ ├── test_string_field.py │ └── test_url_field.py ├── generics │ ├── __init__.py │ ├── test_sqlalchemy_mapper_generator.py │ └── test_umongo_mapper_generator.py ├── test_mapper_inherit.py ├── test_mapper_validation.py └── validators │ ├── __init__.py │ ├── test_length.py │ └── test_required.py ├── resources ├── __init__.py ├── common_resource │ ├── __init__.py │ ├── test_create.py │ ├── test_delete.py │ ├── test_get_many.py │ ├── test_get_one.py │ ├── test_list.py │ ├── test_name.py │ ├── test_update.py │ └── utils.py └── postgres_resource │ ├── __init__.py │ └── utils │ ├── __init__.py │ └── test_to_column.py ├── test_connection_injectors.py └── view ├── __init__.py ├── test_middleware_list.py ├── test_setup.py └── utils.py /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Long story short 2 | 3 | 4 | 5 | ## Expected behaviour 6 | 7 | 8 | 9 | ## Actual behaviour 10 | 11 | 12 | 13 | ## Steps to reproduce 14 | 15 | 18 | 19 | ## Your environment 20 | 21 | 22 | 23 | * aiohttp_admin2 version: 24 | * aiohttp version: 25 | * Python version: 26 | * Operating System: 27 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## What do these changes do? 4 | 5 | 6 | 7 | ## Are there changes in behavior for the user? 8 | 9 | 10 | 11 | ## Related issue number 12 | 13 | 14 | 15 | ## Checklist 16 | 17 | - [ ] I think the code is well written 18 | - [ ] Unit tests for the changes exist 19 | - [ ] Documentation reflects the changes 20 | - [ ] If you provide code modification, please add yourself to `CONTRIBUTORS.txt` 21 | * The format is <Name> <Surname>. 22 | * Please keep alphabetical order, the file is sorted by names. 23 | - [ ] Add a new news fragment into the `CHANGES` folder 24 | * name it `.` for example (588.bugfix) 25 | * if you don't have an `issue_id` change it to the pr id after creating the pr 26 | * ensure type is one of the following: 27 | * `.feature`: Signifying a new feature. 28 | * `.bugfix`: Signifying a bug fix. 29 | * `.doc`: Signifying a documentation improvement. 30 | * `.removal`: Signifying a deprecation or removal of public API. 31 | * `.misc`: A ticket has been closed, but it is not of interest to users. 32 | * Make sure to use full sentences with correct case and punctuation, for example: "Fix issue with non-ascii contents in doctest text files." 33 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | jobs: 16 | deploy: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: '3.x' 24 | - name: Install Python dependencies 25 | run: | 26 | # install poetry 27 | curl -sSL https://install.python-poetry.org | python3 - 28 | poetry config virtualenvs.create false 29 | 30 | # install deps 31 | poetry install --with "dev, test" --all-extras 32 | poetry self add "poetry-dynamic-versioning[plugin]" 33 | - name: Create README.rst 34 | run: | 35 | cat README.rst > README_BUILD.rst && echo >> README_BUILD.rst && cat HISTORY.rst >> README_BUILD.rst 36 | - name: Publish 37 | run: | 38 | poetry build 39 | poetry run python -m twine check --strict dist/* 40 | poetry publish --username ${PYPI_USERNAME} --password ${PYPI_PASSWORD} 41 | env: 42 | PYPI_USERNAME: __token__ 43 | PYPI_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 44 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | # Trigger the workflow on push or pull request, 5 | # but only for the main branch 6 | push: 7 | branches: 8 | - master 9 | pull_request: 10 | branches: 11 | - master 12 | 13 | jobs: 14 | run-linters: 15 | name: Run linters 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python 3.12 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.12" 24 | - name: Install pre-commit 25 | run: pip install pre-commit 26 | - name: Lint using pre-commit 27 | run: pre-commit run --all-files 28 | 29 | - name: Install Poetry 30 | run: | 31 | python -m pip install --upgrade pip 32 | 33 | # install poetry 34 | curl -sSL https://install.python-poetry.org | python3 - 35 | poetry config virtualenvs.create false 36 | 37 | # install deps 38 | poetry install --with "dev, test" --all-extras 39 | 40 | - name: Lint build 41 | run: | 42 | cat README.rst > README_BUILD.rst && echo >> README_BUILD.rst && cat HISTORY.rst >> README_BUILD.rst 43 | poetry build 44 | poetry run python -m twine check --strict dist/* 45 | 46 | run-tests: 47 | name: Run pytest 48 | runs-on: ubuntu-latest 49 | 50 | steps: 51 | - name: Check out Git repository 52 | uses: actions/checkout@v2 53 | 54 | - name: Set up Python 55 | uses: actions/setup-python@v1 56 | with: 57 | python-version: '3.x' 58 | 59 | - name: Install Python dependencies 60 | run: | 61 | cat README.rst > README_BUILD.rst && echo >> README_BUILD.rst && cat HISTORY.rst >> README_BUILD.rst 62 | python -m pip install --upgrade pip 63 | 64 | # install poetry 65 | curl -sSL https://install.python-poetry.org | python3 - 66 | 67 | # install deps 68 | poetry install --with "dev, test" --all-extras 69 | - name: Pytest 70 | run: | 71 | poetry run pytest --slow -v -s -p no:warnings 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | .idea/ 105 | 106 | # test project 107 | project_new/ 108 | 109 | .vscode/ 110 | docker-compose.override.yml 111 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_stages: 2 | - pre-commit 3 | 4 | repos: 5 | # - repo: https://github.com/pre-commit/mirrors-mypy 6 | # rev: v1.15.0 7 | # hooks: 8 | # - id: mypy 9 | # exclude: ^tests/ 10 | # additional_dependencies: [ 11 | # 'sqlalchemy-stubs', 12 | # ] 13 | - repo: https://github.com/pre-commit/pre-commit-hooks 14 | rev: v4.4.0 15 | hooks: 16 | - id: trailing-whitespace 17 | - id: end-of-file-fixer 18 | - id: debug-statements 19 | - id: detect-private-key 20 | - id: mixed-line-ending 21 | - id: check-merge-conflict 22 | - repo: https://github.com/akaihola/darker 23 | rev: 1.7.2 24 | hooks: 25 | - id: darker 26 | additional_dependencies: 27 | - black~=24.2.0 28 | - isort~=5.13.0 29 | args: ["--isort"] 30 | - repo: https://github.com/pycqa/flake8 31 | rev: 7.1.1 32 | hooks: 33 | - id: flake8 34 | additional_dependencies: [ 35 | 'flake8-debugger==3.2.1', 36 | 'flake8-deprecated==1.3', 37 | 'flake8-pep3101==1.2.1', 38 | 'flake8-polyfill==1.0.2', 39 | 'flake8-print==3.1.4', 40 | 'flake8-string-format==0.2.3', 41 | 'flake8-pyproject==1.2.3', 42 | ] 43 | - repo: https://github.com/PyCQA/bandit 44 | rev: '1.8.3' 45 | hooks: 46 | - id: bandit 47 | args: ["-c", "pyproject.toml", "-r", "."] 48 | additional_dependencies: [".[toml]"] 49 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | formats: 7 | - pdf 8 | 9 | build: 10 | os: "ubuntu-22.04" 11 | tools: 12 | python: "3.12" 13 | 14 | python: 15 | install: 16 | - requirements: requirements/documentation.txt 17 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Mykhailo Havelia 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/CHANGES -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/arfey/aiohttp_admin2/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | aiohttp admin 2 could always use more documentation, whether as part of the 42 | official aiohttp admin 2 docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | .. code-block:: bash 46 | 47 | $ pip install -r requirements/documentation.txt 48 | $ sphinx-build -b html docs docs/build 49 | $ open docs/build/index.html 50 | 51 | Submit Feedback 52 | ~~~~~~~~~~~~~~~ 53 | 54 | The best way to send feedback is to file an issue at https://github.com/arfey/aiohttp_admin2/issues. 55 | 56 | If you are proposing a feature: 57 | 58 | * Explain in detail how it would work. 59 | * Keep the scope as narrow as possible, to make it easier to implement. 60 | * Remember that this is a volunteer-driven project, and that contributions 61 | are welcome :) 62 | 63 | Get Started! 64 | ------------ 65 | 66 | Ready to contribute? Here's how to set up `aiohttp_admin2` for local development. 67 | 68 | 1. Fork the `aiohttp_admin2` repo on GitHub. 69 | 2. Clone your fork locally:: 70 | 71 | $ git clone git@github.com:your_name_here/aiohttp_admin2.git 72 | 73 | 3. You need to have preinstalled poetry and docker. 74 | 75 | $ poetry config virtualenvs.create true --local # create virtualenv in project directory 76 | $ cd aiohttp_admin2/ 77 | $ poetry install --with "dev, test" --all-extras 78 | 79 | 4. Create a branch for local development:: 80 | 81 | $ git checkout -b name-of-your-bugfix-or-feature 82 | 83 | Now you can make your changes locally. 84 | 85 | 5. Install dependencies:: 86 | 87 | $ pip install pre-commit 88 | $ poetry install --with "dev, test" --all-extras 89 | 90 | 6. When you're done making changes, check that your changes pass linters and 91 | tests:: 92 | 93 | $ make lint 94 | $ make test 95 | 96 | 7. Commit your changes and push your branch to GitHub:: 97 | 98 | $ git add . 99 | $ git commit -m "Your detailed description of your changes." 100 | $ git push origin name-of-your-bugfix-or-feature 101 | 102 | 8. Submit a pull request through the GitHub website. 103 | 104 | Tips 105 | ---- 106 | 107 | To run a subset of tests:: 108 | 109 | 110 | $ pytest --slow -v -s -p no:warnings tests/resources/common_resource 111 | 112 | 113 | Run some particular test:: 114 | 115 | 116 | $ pytest --slow -v -s -p no:warnings tests/resources/common_resource/test_create.py::test_create_with_error 117 | 118 | 119 | All features you can to test in demo application. 120 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | `Changelog `_ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020, Mykhailo Havelia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ###### Demos 2 | 3 | demo_quick: ## Run quick start demo 4 | @cd example_projects && docker-compose up -d 5 | DATABASE_URL=postgresql://postgres:postgres@0.0.0.0:5432/postgres poetry run adev runserver example_projects/quick_start/app.py 6 | 7 | 8 | demo_main: ## Run main demo 9 | @cd example_projects && docker-compose up -d 10 | @WITHOUT_UPDATE_DB=1 DATABASE_URL=postgresql://postgres:postgres@0.0.0.0:5432/postgres poetry run adev runserver example_projects/main/__init__.py 11 | 12 | test: ## run tests 13 | @docker stop $(docker ps | grep pytest | awk '{ print $1 }') | true 14 | poetry run pytest --slow -v -s -p no:warnings 15 | 16 | lint: ## check style 17 | @pre-commit run --all-files 18 | 19 | build_lint: 20 | cat README.rst > README_BUILD.rst && echo >> README_BUILD.rst && cat HISTORY.rst >> README_BUILD.rst 21 | poetry build 22 | poetry run python -m twine check --strict dist/* 23 | rm README_BUILD.rst 24 | touch README_BUILD.rst 25 | rm -rf dist 26 | 27 | .DEFAULT_GOAL := help 28 | 29 | define PRINT_HELP_PYSCRIPT 30 | import re, sys 31 | 32 | for line in sys.stdin: 33 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 34 | if match: 35 | target, help = match.groups() 36 | print("%-20s %s" % (target, help)) 37 | endef 38 | export PRINT_HELP_PYSCRIPT 39 | 40 | help: 41 | @poetry run python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 42 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/aiohttp_admin2.svg 2 | :target: https://pypi.python.org/pypi/aiohttp_admin2 3 | 4 | .. image:: https://github.com/Arfey/aiohttp_admin2/actions/workflows/tests.yaml/badge.svg?branch=master 5 | :target: https://github.com/Arfey/aiohttp_admin2/actions/workflows/tests.yaml 6 | 7 | .. image:: https://readthedocs.org/projects/aiohttp-admin2/badge/?version=latest 8 | :target: https://aiohttp-admin2.readthedocs.io 9 | :alt: Documentation Status 10 | 11 | .. image:: https://pyup.io/repos/github/arfey/aiohttp_admin2/shield.svg 12 | :target: https://pyup.io/repos/github/arfey/aiohttp_admin2/ 13 | :alt: Updates 14 | 15 | .. image:: https://img.shields.io/badge/PRs-welcome-green.svg 16 | :target: https://github.com/Arfey/aiohttp_admin2/issues 17 | :alt: PRs Welcome 18 | 19 | ============= 20 | Aiohttp admin 21 | ============= 22 | 23 | `Demo site 24 | `_ | `Demo source code 25 | `_. 26 | 27 | The aiohttp admin is a library for build admin interface for applications based 28 | on the aiohttp. With this library you can ease to generate CRUD views for your 29 | data (for data storages which support by aiohttp admin) and flexibly customize 30 | representation and access to these. 31 | 32 | * Free software: MIT license 33 | * Documentation: https://aiohttp-admin2.readthedocs.io. 34 | 35 | Installation 36 | ------------ 37 | 38 | The first step which you need to do it’s installing library 39 | 40 | .. code-block:: bash 41 | 42 | pip install aiohttp_admin2 43 | 44 | .. image:: https://github.com/Arfey/aiohttp_admin2/blob/master/docs/images/index.png?raw=true 45 | -------------------------------------------------------------------------------- /README_BUILD.rst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/README_BUILD.rst -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | - pip-utils 2 | - added helpers for makefile 3 | - added flake plugin 4 | - add method to get pk from instance 5 | - add integrate test for resource 6 | - move methods for async 7 | - think about move instance to dict 8 | - get name of user in nav bar 9 | - controller name with whitespace 10 | - date time field mongodb 11 | - remove hidden field for update??? 12 | - add required to generic fields 13 | - add min,max length input 14 | - test view create/update/delete 15 | - validator not work + docs validation 16 | - csrf form 17 | - DETAIL: Key (id)=(0) already exists. 18 | - update list if not int 19 | - move validation to controller 20 | - add validation for inner type 21 | - scroll to error after update and create 22 | - delete for file 23 | - image view 24 | - time widget 25 | - multi files widget 26 | - download bl for file in field DRY 27 | - move data_field_sort to UserPostgresResource??? 28 | - filter validation for some value 29 | - refactor filters https://www.dothedev.com/blog/django-admin-list_filter/ 30 | - link to object (one to one) 31 | - add transactions for create/update/delete 32 | - list of field for show on detail page 33 | 34 | 35 | - tabs 36 | - permission for tabs 37 | - todo: pagination in tabs 38 | - TabTemplateView 39 | 40 | 41 | ----- 42 | 43 | - example video 44 | - optional packages 45 | - change docs related to example projects 46 | -------------------------------------------------------------------------------- /aiohttp_admin2/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = """Mykhailo Havelia""" 2 | __email__ = 'misha.gavela@gmail.com' 3 | __version__ = '0.1.0' 4 | 5 | 6 | from aiohttp_admin2.views.aiohttp.setup import setup_admin 7 | 8 | __all__ = ["setup_admin", ] 9 | -------------------------------------------------------------------------------- /aiohttp_admin2/connection_injectors.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | 4 | __all__ = ['ConnectionInjector', ] 5 | 6 | 7 | class ConnectionInjector: 8 | """ 9 | This class need for share engines connection for admin's controllers. In 10 | aiohttp you initialize connection after start application and can't 11 | explicitly add it to controller class. 12 | 13 | create injector 14 | 15 | >>> postgres_connection = ConnectionInjector() 16 | 17 | provide connection in place where you initialize it 18 | 19 | >>> postgres_connection.init(db) 20 | 21 | inject connection to controller 22 | 23 | >>> @postgres_connection.inject 24 | >>> class Controller(PostgresController): pass 25 | """ 26 | 27 | connection: t.Any 28 | 29 | def init(self, connection: t.Any) -> None: 30 | """ 31 | This method need to specify connection which need to share. 32 | """ 33 | self.connection = connection 34 | 35 | def inject(self, cls: object) -> object: 36 | """ 37 | This method need to use as decorator for the controller class. It 38 | inject itself to decorated class as `connection_injector`. 39 | """ 40 | cls.connection_injector = self 41 | return cls 42 | -------------------------------------------------------------------------------- /aiohttp_admin2/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/aiohttp_admin2/controllers/__init__.py -------------------------------------------------------------------------------- /aiohttp_admin2/controllers/exceptions.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.exceptions import AdminException 2 | 3 | 4 | __all__ = ["PermissionDenied", ] 5 | 6 | 7 | class PermissionDenied(AdminException): 8 | """If has not access for some operation.""" 9 | -------------------------------------------------------------------------------- /aiohttp_admin2/controllers/mongo_controller.py: -------------------------------------------------------------------------------- 1 | from umongo.document import MetaDocumentImplementation 2 | from aiohttp_admin2.controllers.controller import Controller 3 | from aiohttp_admin2.mappers.generics.mongo import MongoMapperGeneric 4 | from aiohttp_admin2.resources.mongo_resource.mongo_resource import MongoResource # noqa 5 | 6 | 7 | __all__ = ["MongoController", ] 8 | 9 | 10 | class MongoController(Controller): 11 | resource = MongoResource 12 | table: MetaDocumentImplementation 13 | 14 | def __init_subclass__( 15 | cls, 16 | table: MetaDocumentImplementation = None, 17 | ) -> None: 18 | # it only requires that the initialization of generic mappers and 19 | # controllers looks the same 20 | if table is not None and not getattr(cls, 'table', None): 21 | cls.table = table 22 | 23 | if not getattr(cls, 'mapper', None): 24 | class Mapper(MongoMapperGeneric, table=table): 25 | """ 26 | If user don't specify mapper for controller we need to create 27 | it automatically 28 | """ 29 | 30 | cls.mapper = Mapper 31 | 32 | def get_resource(self) -> MongoResource: 33 | return self.resource(self.table) 34 | -------------------------------------------------------------------------------- /aiohttp_admin2/controllers/mysql_controller.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.resources.mysql_resource.mysql_resource import MySqlResource # noqa 2 | from aiohttp_admin2.controllers.postgres_controller import PostgresController 3 | 4 | __all__ = ["MySQLController", ] 5 | 6 | 7 | class MySQLController(PostgresController): 8 | resource = MySqlResource 9 | 10 | def get_resource(self) -> MySqlResource: 11 | return self.resource( 12 | self.connection_injector.connection, 13 | self.table, 14 | custom_sort_list={ 15 | key.replace('_field_sort', ''): getattr(self, key) 16 | for key in dir(self) 17 | if key.endswith('_field_sort') 18 | }, 19 | ) 20 | -------------------------------------------------------------------------------- /aiohttp_admin2/controllers/postgres_controller.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sa 2 | 3 | from aiohttp_admin2.controllers.controller import Controller 4 | from aiohttp_admin2.resources.postgres_resource.postgres_resource import PostgresResource # noqa 5 | from aiohttp_admin2.connection_injectors import ConnectionInjector 6 | from aiohttp_admin2.mappers.generics import PostgresMapperGeneric 7 | 8 | 9 | __all__ = ["PostgresController", ] 10 | 11 | 12 | class PostgresController(Controller): 13 | table: sa.Table 14 | resource = PostgresResource 15 | connection_injector: ConnectionInjector 16 | 17 | def __init_subclass__(cls, table: sa.Table = None) -> None: 18 | # it only requires that the initialization of generic mappers and 19 | # controllers looks the same 20 | if table is not None and not getattr(cls, 'table', None): 21 | cls.table = table 22 | 23 | if not getattr(cls, 'mapper', None): 24 | class Mapper(PostgresMapperGeneric, table=table): 25 | """ 26 | If user don't specify mapper for controller we need to create 27 | it automatically 28 | """ 29 | 30 | cls.mapper = Mapper 31 | 32 | def get_resource(self) -> PostgresResource: 33 | return self.resource( 34 | self.connection_injector.connection, 35 | self.table, 36 | custom_sort_list={ 37 | key.replace('_field_sort', ''): getattr(self, key) 38 | for key in dir(self) 39 | if key.endswith('_field_sort') 40 | }, 41 | ) 42 | -------------------------------------------------------------------------------- /aiohttp_admin2/controllers/relations.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from collections.abc import Callable 3 | from dataclasses import dataclass 4 | 5 | if t.TYPE_CHECKING: 6 | from aiohttp_admin2.controllers.controller import Controller # noqa 7 | from aiohttp_admin2.views import ControllerView # noqa 8 | 9 | 10 | __all__ = ['ToManyRelation', 'ToOneRelation', ] 11 | 12 | 13 | @dataclass 14 | class ToManyRelation: 15 | """ 16 | The current class need to describe one to many or many to many relation 17 | between two tables. 18 | """ 19 | name: str 20 | left_table_pk: str 21 | relation_controller: "Callable[..., Controller] | Controller" 22 | view_settings: dict[str, t.Any] | None = None 23 | 24 | def accept(self, obj: t.Type['ControllerView']) -> None: 25 | if callable(self.relation_controller): 26 | self.relation_controller = self.relation_controller() 27 | 28 | obj.visit_to_many_relations(self) 29 | 30 | 31 | @dataclass 32 | class ToOneRelation: 33 | """ 34 | The current class need to describe one to one or many to one relation 35 | between two tables. 36 | """ 37 | name: str 38 | field_name: str 39 | controller: t.Any 40 | hidden: bool = False 41 | target_field_name: str = None 42 | -------------------------------------------------------------------------------- /aiohttp_admin2/controllers/types.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | __all__ = ["Cell", "ListObject", ] 4 | 5 | 6 | class Cell(t.NamedTuple): 7 | """Field data representation for html template""" 8 | value: t.Any 9 | url: t.Tuple[str, t.Dict[str, t.Union[str, int]]] 10 | is_safe: bool = False 11 | 12 | 13 | class ListObject(t.NamedTuple): 14 | rows: t.List[t.List[Cell]] 15 | has_next: bool 16 | has_prev: bool 17 | count: t.Optional[int] 18 | active_page: t.Optional[int] 19 | per_page: int 20 | next_id: t.Optional[int] 21 | -------------------------------------------------------------------------------- /aiohttp_admin2/exceptions.py: -------------------------------------------------------------------------------- 1 | class AdminException(Exception): 2 | """The main exception for all exceptions in this library.""" 3 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from aiohttp_admin2.mappers.base import Mapper 3 | from aiohttp_admin2.mappers.fields import * 4 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/exceptions.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.exceptions import AdminException 2 | 3 | 4 | __all__ = ['ValidationError', 'MapperError', ] 5 | 6 | 7 | class ValidationError(AdminException): 8 | """This exception connected with wrong mapper form or some field.""" 9 | 10 | 11 | class MapperError(AdminException): 12 | """This exception connected with errors inside mapper class.""" 13 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/fields/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from aiohttp_admin2.mappers.fields.string_field import StringField 3 | from aiohttp_admin2.mappers.fields.string_field import LongStringField 4 | from aiohttp_admin2.mappers.fields.bolean_field import BooleanField 5 | from aiohttp_admin2.mappers.fields.array_field import ArrayField 6 | from aiohttp_admin2.mappers.fields.json_field import JsonField 7 | from aiohttp_admin2.mappers.fields.float_field import FloatField 8 | from aiohttp_admin2.mappers.fields.int_field import IntField 9 | from aiohttp_admin2.mappers.fields.int_field import SmallIntField 10 | from aiohttp_admin2.mappers.fields.choice_field import ChoicesField 11 | from aiohttp_admin2.mappers.fields.url_field import UrlImageField 12 | from aiohttp_admin2.mappers.fields.url_field import UrlFileField 13 | from aiohttp_admin2.mappers.fields.url_field import UrlField 14 | from aiohttp_admin2.mappers.fields.date_field import DateField 15 | from aiohttp_admin2.mappers.fields.date_field import DateTimeField 16 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/fields/array_field.py: -------------------------------------------------------------------------------- 1 | import json 2 | import typing as t 3 | 4 | from aiohttp_admin2.mappers.exceptions import ValidationError 5 | from aiohttp_admin2.mappers.fields.abc import AbstractField 6 | from aiohttp_admin2.mappers import validators 7 | 8 | __all__ = ["ArrayField", ] 9 | 10 | 11 | class ArrayField(AbstractField): 12 | """ 13 | A field for represent an array type: 14 | 15 | >>> from aiohttp_admin2.mappers import fields 16 | >>> 17 | >>> class Mapper(Mapper): 18 | >>> field = fields.ArrayField(field_cls=fields.IntField) 19 | 20 | you need to specify `field_cls` which describe type of elements inside an 21 | array. 22 | """ 23 | type_name: str = 'array' 24 | 25 | def __init__( 26 | self, 27 | *, 28 | field_cls: AbstractField, 29 | max_length: int = None, 30 | min_length: int = None, 31 | **kwargs: t.Any, 32 | ): 33 | super().__init__(**kwargs) 34 | self.field_cls = field_cls 35 | self.field = field_cls(value=None) 36 | 37 | if max_length or min_length: 38 | self.validators.append( 39 | validators.length(max_value=max_length, min_value=min_length) 40 | ) 41 | 42 | def to_python(self) -> t.Optional[t.List[t.Any]]: 43 | if self._value: 44 | if isinstance(self._value, list): 45 | return [ 46 | self.field(i).to_python() 47 | for i in self._value 48 | ] 49 | 50 | if not isinstance(self._value, str): 51 | raise ValidationError("Incorrect format for array field.") 52 | 53 | if self._value.startswith('[') and self._value.endswith(']'): 54 | try: 55 | return [ 56 | self.field(i).to_python() 57 | for i in json.loads(self._value) 58 | ] 59 | except json.decoder.JSONDecodeError: 60 | raise ValidationError("Incorrect format for array field.") 61 | else: 62 | return [ 63 | self.field(i).to_python() for i in self._value.split(',') 64 | ] 65 | 66 | return self._value 67 | 68 | @property 69 | def failure_safe_value(self): 70 | if self._value: 71 | if isinstance(self._value, list): 72 | return self._value 73 | else: 74 | return self._value.split(',') 75 | return self._value 76 | 77 | def __call__(self, value: t.Any) -> "AbstractField": 78 | return self.__class__( 79 | field_cls=self.field_cls, 80 | required=self.required, 81 | validators=self.validators, 82 | default=self.default, 83 | primary_key=self.primary_key, 84 | value=value 85 | ) 86 | 87 | def __repr__(self): 88 | return \ 89 | f"{self.__class__.__name__}(name={self.type_name}," \ 90 | f" value={self._value}), required={self.required}" \ 91 | f" primary_key={self.primary_key}"\ 92 | f" type={self.field_cls.__name__})" 93 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/fields/bolean_field.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from aiohttp_admin2.mappers.fields.abc import AbstractField 4 | 5 | __all__ = ["BooleanField", ] 6 | 7 | 8 | class BooleanField(AbstractField): 9 | """ 10 | A field for represent an boolean type: 11 | 12 | >>> from aiohttp_admin2.mappers import fields 13 | >>> 14 | >>> class Mapper(Mapper): 15 | >>> field = fields.BooleanField() 16 | 17 | this fields convert any value which is not contains in `false_values` list 18 | to `True` and to `False` in other case. 19 | """ 20 | type_name: str = 'boolean' 21 | false_values = ['0', 'false', 'f', '', 'none'] 22 | 23 | def to_python(self) -> t.Optional[bool]: 24 | if self._value is None: 25 | return None 26 | 27 | if str(self._value).lower() in self.false_values: 28 | return False 29 | 30 | return True 31 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/fields/date_field.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, date 2 | 3 | from aiohttp_admin2.mappers.exceptions import ValidationError 4 | from aiohttp_admin2.mappers.fields.abc import AbstractField 5 | from dateutil import parser 6 | 7 | __all__ = [ 8 | "DateTimeField", 9 | "DateField", 10 | ] 11 | 12 | 13 | class DateTimeField(AbstractField): 14 | """ 15 | A field for represent an standard datetime type: 16 | 17 | >>> from aiohttp_admin2.mappers import fields 18 | >>> 19 | >>> class Mapper(Mapper): 20 | >>> field = fields.DateTimeField() 21 | """ 22 | type_name: str = 'datetime' 23 | 24 | def to_python(self) -> datetime: 25 | try: 26 | if isinstance(self._value, datetime): 27 | return self._value 28 | return parser.parse(self._value) if self._value else None 29 | except parser.ParserError: 30 | raise ValidationError( 31 | f"Incorrect format for time field. {self._value}" 32 | ) 33 | 34 | 35 | class DateField(AbstractField): 36 | """ 37 | A field for represent an standard date type: 38 | 39 | >>> from aiohttp_admin2.mappers import fields 40 | >>> 41 | >>> class Mapper(Mapper): 42 | >>> field = fields.DateField() 43 | """ 44 | type_name: str = 'date' 45 | 46 | def to_python(self) -> date: 47 | try: 48 | if isinstance(self._value, date): 49 | return self._value 50 | return parser.parse(self._value).date() if self._value else None 51 | except parser.ParserError: 52 | raise ValidationError( 53 | f"Incorrect format for time field. {self._value}" 54 | ) 55 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/fields/float_field.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from aiohttp_admin2.mappers.fields.abc import AbstractField 4 | from aiohttp_admin2.mappers.exceptions import ValidationError 5 | 6 | __all__ = ["FloatField", ] 7 | 8 | 9 | class FloatField(AbstractField): 10 | """ 11 | Simple representation of float type. 12 | """ 13 | type_name: str = 'float' 14 | 15 | def to_python(self) -> t.Optional[float]: 16 | try: 17 | return ( 18 | float(self._value) if self._value is not None else self._value 19 | ) 20 | except ValueError: 21 | raise ValidationError( 22 | f"Incorrect value for Float field. {self._value}" 23 | ) 24 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/fields/int_field.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from aiohttp_admin2.mappers.exceptions import ValidationError 4 | from aiohttp_admin2.mappers.fields.abc import AbstractField 5 | 6 | __all__ = ["IntField", "SmallIntField", ] 7 | 8 | 9 | class IntField(AbstractField): 10 | """ 11 | Simple representation of float type. 12 | """ 13 | type_name: str = 'int' 14 | 15 | def to_python(self) -> t.Optional[int]: 16 | if self._value == '': 17 | return None 18 | try: 19 | return int(self._value) if self._value is not None else self._value 20 | except ValueError: 21 | raise ValidationError( 22 | f"Incorrect value for Int field. {self._value}" 23 | ) 24 | 25 | 26 | class SmallIntField(IntField): 27 | """ 28 | Simple representation of float type but with additional validation related 29 | with long of integer (only for int from MIN_INT to MAX_INT). 30 | """ 31 | type_name: str = 'small_int' 32 | MAX_INT = 32_767 33 | MIN_INT = -32_768 34 | 35 | def to_python(self) -> t.Optional[int]: 36 | value = super().to_python() 37 | 38 | if value and (self.MIN_INT > value or value > self.MAX_INT): 39 | raise ValidationError( 40 | f"Value of SmallInt field have to between {self.MIN_INT} and " 41 | f"{self.MAX_INT} but received {value}" 42 | ) 43 | 44 | return value 45 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/fields/json_field.py: -------------------------------------------------------------------------------- 1 | import json 2 | import typing as t 3 | 4 | from aiohttp_admin2.mappers.exceptions import ValidationError 5 | from aiohttp_admin2.mappers.fields.abc import AbstractField 6 | 7 | __all__ = ["JsonField", ] 8 | 9 | 10 | class JsonField(AbstractField): 11 | type_name: str = 'json' 12 | 13 | def to_python(self) -> t.Optional[t.Dict[str, t.Any]]: 14 | if self._value and self._value.strip(): 15 | try: 16 | return json.loads(self._value) 17 | except json.decoder.JSONDecodeError: 18 | raise ValidationError( 19 | "Incorrect format for json field.") 20 | 21 | return self._value 22 | 23 | def to_storage(self) -> str: 24 | """ 25 | Convert value to correct storage type. 26 | """ 27 | if self._value is not None: 28 | try: 29 | if isinstance(self._value, dict): 30 | return json.dumps(self._value, sort_keys=True, indent=4) 31 | else: 32 | json.dumps( 33 | json.loads(self._value), sort_keys=True, indent=4) 34 | except Exception: 35 | return str(self._value).strip() 36 | 37 | return "" 38 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/fields/mongo_fields.py: -------------------------------------------------------------------------------- 1 | from bson.objectid import ObjectId 2 | 3 | from aiohttp_admin2.mappers.fields.abc import AbstractField 4 | 5 | 6 | __all__ = ["ObjectIdField", ] 7 | 8 | 9 | class ObjectIdField(AbstractField): 10 | """ 11 | Represent type of id in mongo db. 12 | """ 13 | type_name: str = 'string' 14 | 15 | def to_python(self) -> str: 16 | return str(self._value) 17 | 18 | def to_storage(self) -> ObjectId: 19 | if isinstance(self._value, str): 20 | return ObjectId(self._value) 21 | 22 | return self._value 23 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/fields/string_field.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from aiohttp_admin2.mappers.fields.abc import AbstractField 4 | 5 | __all__ = ["StringField", "LongStringField", ] 6 | 7 | 8 | class StringField(AbstractField): 9 | """ 10 | This class represent simple string type. 11 | """ 12 | type_name: str = 'string' 13 | 14 | def to_python(self) -> t.Optional[str]: 15 | return str(self._value) if self._value is not None else self._value 16 | 17 | 18 | class LongStringField(StringField): 19 | """ 20 | This class represent simple string type but have different representation 21 | in the admin interface (more space for text). 22 | """ 23 | type_name: str = 'string_long' 24 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/fields/url_field.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | import re 3 | 4 | from aiohttp_admin2.mappers.exceptions import ValidationError 5 | from aiohttp_admin2.mappers.fields.string_field import StringField 6 | 7 | 8 | __all__ = [ 9 | "UrlField", 10 | "UrlFileField", 11 | "UrlImageField", 12 | ] 13 | 14 | 15 | class UrlField(StringField): 16 | """ 17 | This class is wrapper on `StringField` that can validate correct url 18 | address. 19 | """ 20 | type_name: str = 'url' 21 | 22 | URL_REGEXP = re.compile( 23 | r'^(?:http|ftp)s?://' # http:// or https:// 24 | r'(?:\S+(?::\S*)?@)?' # user and password 25 | r'(?:(?:[A-Z0-9](?:[A-Z0-9-_]{0,61}[A-Z0-9])?\.)' 26 | r'+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain... 27 | r'localhost|' # localhost... 28 | r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip 29 | r'(?::\d+)?' # optional port 30 | r'(?:/?|[/?]\S+)$', 31 | re.IGNORECASE, 32 | ) 33 | 34 | def is_valid(self) -> bool: 35 | is_valid = super().is_valid() 36 | 37 | if is_valid and self._value: 38 | if not self.URL_REGEXP.match(self.value): 39 | raise ValidationError(f"{self.value} is not valid url.") 40 | 41 | return is_valid 42 | 43 | 44 | class UrlFileField(UrlField): 45 | """ 46 | This class just need to change visual representation in admin interface of 47 | field which contains url to the file. 48 | """ 49 | type_name: str = 'url_file' 50 | 51 | def to_python(self) -> t.Optional[str]: 52 | if self._value and not hasattr(self._value, 'file'): 53 | return str(self._value) 54 | 55 | return self._value 56 | 57 | 58 | class UrlImageField(UrlField): 59 | """ 60 | This class just need to change visual representation in admin interface of 61 | field which contains url to the image. 62 | """ 63 | type_name: str = 'url_file_image' 64 | 65 | def to_python(self) -> t.Optional[str]: 66 | if self._value and not hasattr(self._value, 'file'): 67 | if self._value in ('None', 'on'): 68 | return None 69 | 70 | return str(self._value) 71 | 72 | return self._value 73 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/generics/__init__.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sa 2 | from sqlalchemy.dialects import postgresql 3 | 4 | from aiohttp_admin2.mappers.base import Mapper 5 | from aiohttp_admin2.mappers import fields 6 | 7 | 8 | __all__ = [ 9 | "PostgresMapperGeneric", 10 | ] 11 | 12 | 13 | class PostgresMapperGeneric(Mapper): 14 | """ 15 | This class need for generate Mapper from sqlAlchemy's model. 16 | """ 17 | 18 | FIELDS_MAPPER = { 19 | sa.Integer: fields.IntField, 20 | sa.BigInteger: fields.IntField, 21 | sa.SmallInteger: fields.SmallIntField, 22 | sa.Float: fields.FloatField, 23 | sa.String: fields.StringField, 24 | sa.Text: fields.LongStringField, 25 | sa.Enum: fields.ChoicesField, 26 | postgresql.ENUM: fields.ChoicesField, 27 | sa.Boolean: fields.BooleanField, 28 | sa.ARRAY: fields.ArrayField, 29 | sa.DateTime: fields.DateTimeField, 30 | sa.Date: fields.DateField, 31 | sa.JSON: fields.JsonField, 32 | } 33 | DEFAULT_FIELD = fields.StringField 34 | 35 | def __init_subclass__(cls, table: sa.Table) -> None: 36 | super().__init_subclass__() 37 | cls._fields = {} 38 | 39 | existing_fields = [field.name for field in cls._fields_cls] 40 | 41 | for name, column in table.columns.items(): 42 | field_cls = \ 43 | cls.FIELDS_MAPPER.get(type(column.type), cls.DEFAULT_FIELD) 44 | 45 | max_length = hasattr(column.type, 'length') and column.type.length 46 | field_kwargs = { 47 | "max_length": max_length, 48 | "required": not column.nullable, 49 | "primary_key": column.primary_key, 50 | } 51 | 52 | if field_cls is fields.ChoicesField: 53 | field = fields.ChoicesField( 54 | choices=[(n, n) for n in column.type.enums], 55 | **field_kwargs 56 | ) 57 | elif field_cls is fields.ArrayField: 58 | field = field_cls( 59 | field_cls=( 60 | cls.FIELDS_MAPPER 61 | .get(type(column.type.item_type), cls.DEFAULT_FIELD) 62 | ), 63 | **field_kwargs 64 | ) 65 | else: 66 | field = field_cls(**field_kwargs) 67 | 68 | field.name = name 69 | if name not in existing_fields: 70 | cls._fields[name] = field 71 | cls._fields_cls.append(field) 72 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/generics/mongo.py: -------------------------------------------------------------------------------- 1 | import umongo 2 | 3 | from marshmallow import EXCLUDE 4 | from marshmallow.exceptions import ValidationError as MarshmallowValidationErr 5 | from aiohttp_admin2.mappers.base import Mapper 6 | from aiohttp_admin2.mappers import fields 7 | from aiohttp_admin2.mappers.fields import mongo_fields 8 | from aiohttp_admin2.mappers.exceptions import ValidationError 9 | 10 | 11 | __all__ = [ 12 | "MongoMapperGeneric", 13 | ] 14 | 15 | 16 | class MongoMapperGeneric(Mapper): 17 | """ 18 | This class need for generate Mapper from Mongo model. 19 | """ 20 | table: umongo.Document 21 | 22 | FIELDS_MAPPER = { 23 | umongo.fields.ObjectIdField: mongo_fields.ObjectIdField, 24 | umongo.fields.IntegerField: fields.IntField, 25 | umongo.fields.StringField: fields.StringField, 26 | umongo.fields.DateTimeField: fields.DateTimeField, 27 | umongo.fields.List: fields.ArrayField, 28 | } 29 | 30 | DEFAULT_FIELD = fields.StringField 31 | 32 | def __init_subclass__(cls, table: umongo.Document) -> None: 33 | cls._fields = {} 34 | cls.table = table 35 | 36 | existing_fields = [field.name for field in cls._fields_cls] 37 | 38 | for name, column in table.schema.fields.items(): 39 | field = \ 40 | cls.FIELDS_MAPPER.get(type(column), cls.DEFAULT_FIELD)() 41 | field.name = name 42 | if name not in existing_fields: 43 | cls._fields_cls.append(field) 44 | cls._fields[name] = field 45 | 46 | def validation(self): 47 | """ 48 | In the current method we cover marshmallow validation. We create/update 49 | instances via umongo which use marshmallow validation for it. We can't 50 | to copy all validation from marshmallow to our mapper because user can 51 | to set custom validation. So we just check twice that data is valid for 52 | the our mapper and for the marshmallow schema. 53 | """ 54 | is_valid = True 55 | errors = {} 56 | 57 | try: 58 | # mapper may have additional fields which are not specify in the 59 | # schema so we need to skip validation of fields which are not 60 | # exist in the schema 61 | self.table.schema.as_marshmallow_schema()().load( 62 | self.raw_data, 63 | unknown=EXCLUDE, 64 | ) 65 | except MarshmallowValidationErr as e: 66 | errors = e.messages 67 | 68 | # validation for each field 69 | for f in self.fields.values(): 70 | if errors.get(f.name): 71 | f.errors.append(errors.get(f.name)[0]) 72 | is_valid = False 73 | 74 | if not is_valid: 75 | raise ValidationError 76 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/validators/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from aiohttp_admin2.mappers.validators.length import length 3 | from aiohttp_admin2.mappers.validators.required import required 4 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/validators/length.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from aiohttp_admin2.mappers.exceptions import ValidationError 4 | 5 | __all__ = ["length", ] 6 | 7 | 8 | LengthCallable = t.Callable[[t.Sized], None] 9 | 10 | 11 | def length(*, max_value: int = None, min_value: int = None) -> LengthCallable: 12 | """ 13 | This validator need to restrict length of value in field. 14 | 15 | >>> from aiohttp_admin2.mappers import Mapper, StringField 16 | >>> 17 | >>> class Foo(Mapper): 18 | >>> field_name = StringField(validators=[length(max_value=5)]) 19 | """ 20 | def length_validator(value: t.Sized) -> None: 21 | if max_value and len(value) > max_value: 22 | raise ValidationError( 23 | f"'{value}' has length larger than {max_value}" 24 | ) 25 | 26 | if min_value and len(value) < min_value: 27 | raise ValidationError( 28 | f"'{value}' has length less than {min_value}" 29 | ) 30 | 31 | return length_validator 32 | -------------------------------------------------------------------------------- /aiohttp_admin2/mappers/validators/required.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from aiohttp_admin2.mappers.exceptions import ValidationError 4 | 5 | __all__ = ["required", ] 6 | 7 | 8 | def required(value: t.Any): 9 | """ 10 | This validator need for check that the received value is not empty. 11 | 12 | >>> from aiohttp_admin2.mappers import Mapper, StringField 13 | >>> 14 | >>> class Foo(Mapper): 15 | >>> field_name = StringField(validators=[required]) 16 | >>> # or just 17 | >>> second_field_name = StringField(required=True) 18 | """ 19 | if value is None or value == '': 20 | raise ValidationError("field is required") 21 | -------------------------------------------------------------------------------- /aiohttp_admin2/resources/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from aiohttp_admin2.resources.postgres_resource.postgres_resource import \ 3 | PostgresResource 4 | from aiohttp_admin2.resources.dict_resource.dict_resource import DictResource 5 | from aiohttp_admin2.resources.abc import Instance 6 | -------------------------------------------------------------------------------- /aiohttp_admin2/resources/dict_resource/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/aiohttp_admin2/resources/dict_resource/__init__.py -------------------------------------------------------------------------------- /aiohttp_admin2/resources/dict_resource/filters.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from aiohttp_admin2.resources.abc import ABCFilter 4 | 5 | 6 | __all__ = [ 7 | "GT", 8 | "GTE", 9 | "LT", 10 | "LTE", 11 | "EQ", 12 | "NE", 13 | "IN", 14 | "NIN", 15 | "Like", 16 | "DictBaseFilter", 17 | "DictQuery", 18 | "default_filter_mapper" 19 | ] 20 | 21 | 22 | DictQuery = t.Dict[int, t.Any] 23 | 24 | 25 | class DictBaseFilter(ABCFilter): 26 | def __init__( 27 | self, 28 | *, 29 | column: str, 30 | value: t.Any, 31 | query: DictQuery, 32 | ) -> None: 33 | self.value = value 34 | self.column = column 35 | self._query = query 36 | 37 | def _update_query(self, predict): 38 | return { 39 | key: value 40 | for key, value in self._query.items() 41 | if predict(value[self.column], self.value) 42 | } 43 | 44 | 45 | class GT(DictBaseFilter): 46 | """Greater filter.""" 47 | 48 | def apply(self) -> DictQuery: 49 | return self._update_query(lambda a, b: a > b) 50 | 51 | 52 | class GTE(DictBaseFilter): 53 | """Greater or equal filter.""" 54 | 55 | def apply(self) -> DictQuery: 56 | return self._update_query(lambda a, b: a >= b) 57 | 58 | 59 | class LT(DictBaseFilter): 60 | """Less filter.""" 61 | 62 | def apply(self) -> DictQuery: 63 | return self._update_query(lambda a, b: a < b) 64 | 65 | 66 | class LTE(DictBaseFilter): 67 | """Less or equal filter.""" 68 | 69 | def apply(self) -> DictQuery: 70 | return self._update_query(lambda a, b: a <= b) 71 | 72 | 73 | class EQ(DictBaseFilter): 74 | """Equal filter.""" 75 | 76 | def apply(self) -> DictQuery: 77 | return self._update_query(lambda a, b: a == b) 78 | 79 | 80 | class NE(DictBaseFilter): 81 | """No equal filter.""" 82 | 83 | def apply(self) -> DictQuery: 84 | return self._update_query(lambda a, b: a != b) 85 | 86 | 87 | class IN(DictBaseFilter): 88 | """In array filter.""" 89 | 90 | def apply(self) -> DictQuery: 91 | return self._update_query(lambda a, b: a in b) 92 | 93 | 94 | class NIN(DictBaseFilter): 95 | """Not in array filter.""" 96 | 97 | def apply(self) -> DictQuery: 98 | return self._update_query(lambda a, b: a not in b) 99 | 100 | 101 | class Like(DictBaseFilter): 102 | """Like filter.""" 103 | 104 | def apply(self) -> DictQuery: 105 | return self._update_query(lambda a, b: b in a) 106 | 107 | 108 | default_filter_mapper = { 109 | 'eq': EQ, 110 | 'ne': NE, 111 | 'lt': LT, 112 | 'lte': LTE, 113 | 'gt': GT, 114 | 'gte': GTE, 115 | 'in': IN, 116 | 'nin': NIN, 117 | 'like': Like, 118 | } 119 | -------------------------------------------------------------------------------- /aiohttp_admin2/resources/exceptions.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.exceptions import AdminException 2 | 3 | 4 | __all__ = [ 5 | 'ClientException', 6 | 'InstanceDoesNotExist', 7 | 'FilterException', 8 | 'BadParameters', 9 | 'CURSOR_PAGINATION_ERROR_MESSAGE', 10 | ] 11 | 12 | 13 | CURSOR_PAGINATION_ERROR_MESSAGE = \ 14 | "Pagination by cursor available only together with sorting by primary key" 15 | 16 | 17 | class ClientException(AdminException): 18 | """The main exception for client exceptions.""" 19 | 20 | 21 | class InstanceDoesNotExist(ClientException): 22 | """Manager can't return instance because it does not exists.""" 23 | 24 | 25 | class FilterException(ClientException): 26 | """Manager can't apply filter to query.""" 27 | 28 | 29 | class BadParameters(AdminException): 30 | """Bad arguments for method.""" 31 | -------------------------------------------------------------------------------- /aiohttp_admin2/resources/mongo_resource/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/aiohttp_admin2/resources/mongo_resource/__init__.py -------------------------------------------------------------------------------- /aiohttp_admin2/resources/mongo_resource/filters.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from bson.objectid import ObjectId 4 | 5 | from aiohttp_admin2.resources.abc import ABCFilter 6 | 7 | 8 | __all__ = [ 9 | "GT", 10 | "GTE", 11 | "LT", 12 | "LTE", 13 | "EQ", 14 | "NE", 15 | "IN", 16 | "NIN", 17 | "Like", 18 | "MongoBaseFilter", 19 | "MongoQuery", 20 | "default_filter_mapper" 21 | ] 22 | 23 | 24 | MongoQuery = t.Dict[str, t.Any] 25 | 26 | 27 | class MongoBaseFilter(ABCFilter): 28 | def __init__( 29 | self, 30 | *, 31 | column: str, 32 | value: t.Any, 33 | query: MongoQuery, 34 | ) -> None: 35 | self.value = value 36 | self.column = column 37 | self._query = query 38 | 39 | def _update_query(self, value: MongoQuery) -> MongoQuery: 40 | if self.column in self._query: 41 | self._query[self.column].update(value) 42 | 43 | else: 44 | self._query.update({self.column: value}) 45 | 46 | return self._query 47 | 48 | @property 49 | def query(self) -> MongoQuery: 50 | if self.column == 'id': 51 | if isinstance(self.value, str): 52 | self.value = ObjectId(self.value) 53 | elif isinstance(self.value, list) or isinstance(self.value, tuple): 54 | self.value = [ObjectId(i) for i in self.value] 55 | 56 | return super().query 57 | 58 | 59 | class GT(MongoBaseFilter): 60 | """Greater filter.""" 61 | 62 | def apply(self) -> MongoQuery: 63 | return self._update_query({"$gt": self.value}) 64 | 65 | 66 | class GTE(MongoBaseFilter): 67 | """Greater or equal filter.""" 68 | 69 | def apply(self) -> MongoQuery: 70 | return self._update_query({"$gte": self.value}) 71 | 72 | 73 | class LT(MongoBaseFilter): 74 | """Less filter.""" 75 | 76 | def apply(self) -> MongoQuery: 77 | return self._update_query({"$lt": self.value}) 78 | 79 | 80 | class LTE(MongoBaseFilter): 81 | """Less or equal filter.""" 82 | 83 | def apply(self) -> MongoQuery: 84 | return self._update_query({"$lte": self.value}) 85 | 86 | 87 | class EQ(MongoBaseFilter): 88 | """Equal filter.""" 89 | 90 | def apply(self) -> MongoQuery: 91 | return self._update_query({"$eq": self.value}) 92 | 93 | 94 | class NE(MongoBaseFilter): 95 | """No equal filter.""" 96 | 97 | def apply(self) -> MongoQuery: 98 | return self._update_query({"$ne": self.value}) 99 | 100 | 101 | class IN(MongoBaseFilter): 102 | """In array filter.""" 103 | 104 | def apply(self) -> MongoQuery: 105 | return self._update_query({"$in": self.value}) 106 | 107 | 108 | class NIN(MongoBaseFilter): 109 | """Not in array filter.""" 110 | 111 | def apply(self) -> MongoQuery: 112 | return self._update_query({"$nin": self.value}) 113 | 114 | 115 | class Like(MongoBaseFilter): 116 | """Like filter.""" 117 | 118 | def apply(self) -> MongoQuery: 119 | return self._update_query({"$regex": f"{self.value}"}) 120 | 121 | 122 | default_filter_mapper = { 123 | 'eq': EQ, 124 | 'ne': NE, 125 | 'lt': LT, 126 | 'lte': LTE, 127 | 'gt': GT, 128 | 'gte': GTE, 129 | 'in': IN, 130 | 'nin': NIN, 131 | 'like': Like, 132 | } 133 | -------------------------------------------------------------------------------- /aiohttp_admin2/resources/mysql_resource/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/aiohttp_admin2/resources/mysql_resource/__init__.py -------------------------------------------------------------------------------- /aiohttp_admin2/resources/mysql_resource/mysql_resource.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.dialects import mysql 2 | 3 | from aiohttp_admin2.resources.postgres_resource.postgres_resource import \ 4 | PostgresResource 5 | from aiohttp_admin2.resources.abc import Instance 6 | from aiohttp_admin2.resources.types import PK 7 | 8 | try: 9 | from sqlalchemy.dialects.mysql.pymysql import MySQLDialect_pymysql 10 | # waiting for fix https://github.com/aio-libs/aiomysql/discussions/908 11 | MySQLDialect_pymysql.case_sensitive = True 12 | except ImportError: 13 | raise ImportError('aiomysql.sa requires sqlalchemy') 14 | 15 | 16 | __all__ = ['MySqlResource', ] 17 | 18 | 19 | class MySqlResource(PostgresResource): 20 | 21 | _dialect = mysql.dialect() 22 | 23 | async def _execute(self, conn, query): 24 | if not isinstance(query, str): 25 | # fixed problem with post compile in aio-mysql 26 | query = str( 27 | query.compile( 28 | compile_kwargs={"literal_binds": True}, 29 | dialect=self._dialect, 30 | ) 31 | ) 32 | 33 | return await conn.execute(query) 34 | 35 | async def create(self, instance: Instance) -> Instance: 36 | data = instance.data.to_dict() 37 | async with self.engine.acquire() as conn: 38 | query = self.table\ 39 | .insert()\ 40 | .values([data]) 41 | 42 | result = await conn.execute(query) 43 | 44 | query = self.table\ 45 | .select()\ 46 | .where(self._primary_key == result.lastrowid) 47 | 48 | cursor = await conn.execute(query) 49 | data = await cursor.fetchone() 50 | 51 | await conn.execute('commit;') 52 | 53 | return self._row_to_instance(data) 54 | 55 | async def update(self, pk: PK, instance: Instance) -> Instance: 56 | data = instance.data.to_dict() 57 | async with self.engine.acquire() as conn: 58 | query = self.table\ 59 | .update()\ 60 | .where(self._primary_key == pk)\ 61 | .values(**data) 62 | 63 | await conn.execute(query) 64 | 65 | query = self.table\ 66 | .select()\ 67 | .where(self._primary_key == pk) 68 | 69 | cursor = await conn.execute(query) 70 | data = await cursor.fetchone() 71 | 72 | await conn.execute('commit;') 73 | 74 | return self._row_to_instance(data) 75 | -------------------------------------------------------------------------------- /aiohttp_admin2/resources/postgres_resource/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/aiohttp_admin2/resources/postgres_resource/__init__.py -------------------------------------------------------------------------------- /aiohttp_admin2/resources/postgres_resource/utils.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sa 2 | 3 | from aiohttp_admin2.resources.exceptions import ClientException 4 | 5 | 6 | __all__ = ["to_column", ] 7 | 8 | 9 | def to_column(column_name: str, table: sa.Table) -> sa.Column: 10 | """ 11 | Return sa.Column type from table by received column's name. 12 | 13 | Raises: 14 | ClientException: if received column doesn't exist in current table. 15 | """ 16 | res = table.c.get(column_name) 17 | 18 | if res is None: 19 | raise ClientException( 20 | f"The {column_name} column does not exist in {table.name} table." 21 | ) 22 | 23 | return res 24 | -------------------------------------------------------------------------------- /aiohttp_admin2/resources/types.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.resources.abc import ( 2 | PK, 3 | FilterTuple, 4 | FiltersType, 5 | FilterMultiTuple, 6 | Instance, 7 | ) 8 | 9 | 10 | __all__ = [ 11 | "PK", 12 | "FilterTuple", 13 | "FiltersType", 14 | "Instance", 15 | "FilterMultiTuple", 16 | ] 17 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/__init__.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.views.aiohttp.views.template_view import TemplateView 2 | from aiohttp_admin2.views.aiohttp.views.base import BaseAdminView 3 | from aiohttp_admin2.views.aiohttp.views.dashboard import DashboardView 4 | from aiohttp_admin2.views.aiohttp.views.controller_view import ControllerView 5 | from aiohttp_admin2.views.aiohttp.views.tab_template_view import TabTemplateView # noqa 6 | from aiohttp_admin2.views.aiohttp.views.many_to_many_tab_view import ManyToManyTabView # noqa 7 | from aiohttp_admin2.views.aiohttp.admin import Admin 8 | 9 | 10 | __all__ = [ 11 | 'TemplateView', 12 | 'DashboardView', 13 | 'Admin', 14 | 'BaseAdminView', 15 | 'ControllerView', 16 | 'TabTemplateView', 17 | 'ManyToManyTabView', 18 | ] 19 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/aiohttp_admin2/views/aiohttp/__init__.py -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/exceptions.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.exceptions import AdminException 2 | 3 | __all__ = [ 4 | 'CanNotModifiedFrozenView', 5 | 'CanNotCreateUnfrozenView', 6 | 'NoUniqueController', 7 | 'NotRegisterView', 8 | 'NoUniqueControllerName', 9 | 'UseHandlerWithoutAccess', 10 | ] 11 | 12 | 13 | class CanNotModifiedFrozenView(AdminException): 14 | """We can't modified static properties in the frozen views.""" 15 | 16 | 17 | class CanNotCreateUnfrozenView(AdminException): 18 | """ 19 | We can't instantiate the unfrozen views. U need setup the views before 20 | create. 21 | """ 22 | 23 | 24 | class NoUniqueController(AdminException): 25 | """Register views with common controller is forbidden.""" 26 | 27 | 28 | class NoUniqueControllerName(AdminException): 29 | """Register controller with common name is forbidden.""" 30 | 31 | 32 | class NotRegisterView(AdminException): 33 | """View is not register for admin interface""" 34 | 35 | 36 | class UseHandlerWithoutAccess(AdminException): 37 | """You call handler without check access""" 38 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/setup.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from aiohttp import web 4 | from aiohttp_jinja2 import APP_KEY 5 | 6 | from aiohttp_admin2.views import Admin 7 | from aiohttp_admin2.views import BaseAdminView 8 | 9 | 10 | __all__ = ['setup_admin', ] 11 | 12 | 13 | def setup_admin( 14 | app: web.Application, 15 | *, 16 | admin_class=Admin, 17 | jinja_app_key: str = APP_KEY, 18 | views: t.Optional[t.List[t.Type[BaseAdminView]]] = None, 19 | middleware_list: t.Optional[t.List[t.Callable]] = None, 20 | logout_path: t.Optional[str] = None, 21 | ) -> None: 22 | """ 23 | This is a main function for initialize an admin interface for the given 24 | aiohttp application. 25 | """ 26 | admin_class( 27 | app, 28 | views, 29 | middleware_list, 30 | logout_path, 31 | ).setup_admin_application(jinja_app_key=jinja_app_key) 32 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/static/js/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This function provide logic for infinity scroll at list page. 3 | */ 4 | function loadMore(element) { 5 | if (element.classList.contains('disabled')) { 6 | return; 7 | } 8 | 9 | element.classList.add('disabled'); 10 | 11 | const nextIdAttr = 'data-next-id'; 12 | const cursorParam = location.href.includes('?') ? '&cursor=' : '?cursor='; 13 | const lastId = element.getAttribute(nextIdAttr); 14 | const url = location.href + cursorParam + lastId; 15 | 16 | 17 | fetch(url).then(data => { 18 | data.text().then(text => { 19 | if (data.status === 200) { 20 | 21 | // add received html to out list of items 22 | document.querySelector('#table-list').innerHTML += text; 23 | 24 | const newLastId = document 25 | .querySelector('#scroll_id') 26 | .getAttribute(nextIdAttr); 27 | 28 | if (newLastId) { 29 | // set to our button a new next-id attr 30 | element.setAttribute(nextIdAttr, newLastId); 31 | // remove unnecessary element 32 | document.querySelector('#scroll_id').remove(); 33 | 34 | if (newLastId === 'None') { 35 | element.classList.add('disabled'); 36 | } else { 37 | element.classList.remove('disabled'); 38 | } 39 | } 40 | } else { 41 | console.error(text); 42 | } 43 | }) 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/cursor_pagination.html: -------------------------------------------------------------------------------- 1 | 2 | {% macro cursor_pagination(has_next, next_id) -%} 3 | 16 | {%- endmacro %} 17 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/filters/boolean_filter.html: -------------------------------------------------------------------------------- 1 | {% macro filter(filter) %} 2 |
3 | 15 | {% if filter.get_param() %} 16 | clear 17 | {% endif %} 18 |
19 | {% endmacro %} 20 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/filters/choice_filter.html: -------------------------------------------------------------------------------- 1 | {% macro filter(filter) %} 2 |
3 | 15 | {% if filter.get_param() %} 16 | clear 17 | {% endif %} 18 |
19 | {% endmacro %} 20 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/filters/datetime_filter.html: -------------------------------------------------------------------------------- 1 | {% macro filter(filter) %} 2 |
3 |
4 |
5 | 6 |
11 | 19 |
24 |
25 | 26 |
27 |
28 |
29 |
30 | 31 |
32 | 33 |
38 | 46 |
51 |
52 | 53 |
54 |
55 |
56 |
57 | 58 | 59 | 60 | {% for i, k in filter.query.items() %} 61 | {% if i not in [filter.param_key_to, filter.param_key_from, "page"] %} 62 | 63 | {% endif %} 64 | {% endfor %} 65 | 66 |
67 | 68 | 73 | 74 | {% if filter.get_params()[1] or filter.get_params()[0] %} 75 | 76 | clear 77 | 78 | {% endif %} 79 |
80 | {% endmacro %} 81 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/filters/search_filter.html: -------------------------------------------------------------------------------- 1 | {% macro search_filter(filter) %} 2 |
3 |
4 | 11 |
12 | 13 | 14 | 15 | {% for i, k in filter.query.items() %} 16 | {% if i not in [filter.param_key, 'page'] %} 17 | 18 | {% endif %} 19 | {% endfor %} 20 | 21 | 22 | 23 | {% if filter.get_param() %} 24 | 25 | clear 26 | 27 | {% endif %} 28 |
29 | {% endmacro %} 30 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/filters/single_value_filter.html: -------------------------------------------------------------------------------- 1 | {% macro filter(filter) %} 2 |
3 |
4 |
5 | 11 |
12 | 13 | 14 | 15 | {% for i, k in filter.query.items() %} 16 | {% if i not in [filter.param_key, "page"] %} 17 | 18 | {% endif %} 19 | {% endfor %} 20 | 21 |
22 | {% if filter.get_param() %} 23 | clear 24 | {% endif %} 25 |
26 | {% endmacro %} 27 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/field_errors.html: -------------------------------------------------------------------------------- 1 | {% macro errors(field) %} 2 | {% if field.errors %} 3 |
4 | {% for error in field.errors %} 5 | {{ error }} 6 |
7 | {% endfor %} 8 | {% endif %} 9 | {% endmacro %} 10 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/field_title.html: -------------------------------------------------------------------------------- 1 | {% macro title(name, field) %} 2 | 3 | {% endmacro %} 4 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/fields/array_field.html: -------------------------------------------------------------------------------- 1 | {% from "aiohttp_admin/blocks/form/field_title.html" import title %} 2 | {% from "aiohttp_admin/blocks/form/field_errors.html" import errors %} 3 | 4 | {% macro field( 5 | name, 6 | field, 7 | get_field_value, 8 | with_defaults=False 9 | ) 10 | %} 11 |
12 | {{ title(name, field) }} 13 | {% set field_value = get_field_value(field, with_defaults) -%} 14 | 21 | {{ errors(field) }} 22 | 25 |
26 | {% endmacro %} 27 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/fields/autocomplete_field.html: -------------------------------------------------------------------------------- 1 | {% from "aiohttp_admin/blocks/form/field_title.html" import title %} 2 | {% from "aiohttp_admin/blocks/form/field_errors.html" import errors %} 3 | 4 | {% macro field( 5 | name, 6 | field, 7 | get_field_value, 8 | with_defaults=False 9 | ) 10 | %} 11 |
12 | {{ title(name, field) }} 13 | {% set field_value = get_field_value(field, with_defaults) -%} 14 | 19 | {{ errors(field) }} 20 | 29 |
30 | {% endmacro %} 31 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/fields/boolean_field.html: -------------------------------------------------------------------------------- 1 | {% from "aiohttp_admin/blocks/form/field_title.html" import title %} 2 | {% from "aiohttp_admin/blocks/form/field_errors.html" import errors %} 3 | 4 | {% macro field( 5 | name, 6 | field, 7 | get_field_value, 8 | with_defaults=False 9 | ) 10 | %} 11 |
12 | {{ title(name, field) }} 13 | {% set field_value = get_field_value(field, with_defaults) -%} 14 | 20 |
21 | {{ errors(field) }} 22 |
23 | {% endmacro %} 24 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/fields/choice_field.html: -------------------------------------------------------------------------------- 1 | {% from "aiohttp_admin/blocks/form/field_title.html" import title %} 2 | {% from "aiohttp_admin/blocks/form/field_errors.html" import errors %} 3 | 4 | {% macro field( 5 | name, 6 | field, 7 | get_field_value, 8 | with_defaults=False 9 | ) 10 | %} 11 |
12 | {{ title(name, field) }} 13 | {% set field_value = get_field_value(field, with_defaults) -%} 14 | 31 | {{ errors(field) }} 32 |
33 | {% endmacro %} 34 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/fields/ck_editor_field.html: -------------------------------------------------------------------------------- 1 | {% from "aiohttp_admin/blocks/form/field_title.html" import title %} 2 | {% from "aiohttp_admin/blocks/form/field_errors.html" import errors %} 3 | 4 | 5 | {% macro field( 6 | name, 7 | field, 8 | get_field_value, 9 | with_defaults=False 10 | ) 11 | %} 12 |
13 | 18 | {{ title(name, field) }} 19 | {% set field_value = get_field_value(field, with_defaults) -%} 20 | 23 | {{ errors(field) }} 24 |
25 | {% endmacro %} 26 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/fields/date_field.html: -------------------------------------------------------------------------------- 1 | {% from 'aiohttp_admin/blocks/form/fields/datetime_field.html' import field as field_macro with context %} 2 | 3 | {% macro field( 4 | name, 5 | field, 6 | get_field_value, 7 | with_defaults=False 8 | ) 9 | %} 10 | {{ field_macro(name, field, get_field_value, with_defaults, format="YYYY-MM-DD") }} 11 | {% endmacro %} 12 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/fields/datetime_field.html: -------------------------------------------------------------------------------- 1 | {% from "aiohttp_admin/blocks/form/field_title.html" import title %} 2 | {% from "aiohttp_admin/blocks/form/field_errors.html" import errors %} 3 | 4 | {% macro field( 5 | name, 6 | field, 7 | get_field_value, 8 | with_defaults=False, 9 | format="YYYY-MM-DD HH:mm:ss" 10 | ) 11 | %} 12 |
13 | {{ title(name, field) }} 14 | {% set field_value = get_field_value(field, with_defaults) -%} 15 |
16 | 24 |
25 |
26 | 27 |
28 |
29 |
30 | {{ errors(field) }} 31 | 36 |
37 | {% endmacro %} 38 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/fields/field.html: -------------------------------------------------------------------------------- 1 | {% macro field_generator( 2 | name, 3 | field, 4 | controller_view, 5 | get_field_value, 6 | with_defaults=False 7 | ) 8 | %} 9 | {% with autocomplete_url_name=controller_view.get_autocomplete_url_name(name) %} 10 | {% from controller_view.get_widget_template_for_field(name, field.type_name) import field as field_macro with context %} 11 | {{ field_macro(name, field, get_field_value, with_defaults) }} 12 | {% endwith %} 13 | {% endmacro %} 14 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/fields/file_field.html: -------------------------------------------------------------------------------- 1 | {% from "aiohttp_admin/blocks/form/field_title.html" import title %} 2 | {% from "aiohttp_admin/blocks/form/field_errors.html" import errors %} 3 | 4 | {% macro field( 5 | name, 6 | field, 7 | get_field_value, 8 | with_defaults=False 9 | ) 10 | %} 11 |
12 | {{ title(name, field) }} 13 | {% if field.value %} 14 | {{ field.value }} 15 | {% endif %} 16 | 23 | {{ errors(field) }} 24 |
25 | {% endmacro %} 26 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/fields/image_field.html: -------------------------------------------------------------------------------- 1 | {% from "aiohttp_admin/blocks/form/field_title.html" import title %} 2 | {% from "aiohttp_admin/blocks/form/field_errors.html" import errors %} 3 | 4 | {% macro field( 5 | name, 6 | field, 7 | get_field_value, 8 | with_defaults=False 9 | ) 10 | %} 11 |
12 | {{ title(name, field) }} 13 | {% if field.value %} 14 | {{ field.value }} 15 | 16 | clean image 17 | {% endif %} 18 | 24 | 29 | 35 | {{ errors(field) }} 36 | 68 |
69 | {% endmacro %} 70 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/fields/json_field.html: -------------------------------------------------------------------------------- 1 | {% from "aiohttp_admin/blocks/form/field_title.html" import title %} 2 | {% from "aiohttp_admin/blocks/form/field_errors.html" import errors %} 3 | 4 | {% macro field( 5 | name, 6 | field, 7 | get_field_value, 8 | with_defaults=False 9 | ) 10 | %} 11 |
12 | {{ title(name, field) }} 13 | {% set field_value = get_field_value(field, with_defaults) -%} 14 |
15 |
16 | {{ field_value }} 17 |
18 | 21 |
22 | {{ errors(field) }} 23 | 36 | 49 |

50 | {% endmacro %} 51 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/fields/long_string_field.html: -------------------------------------------------------------------------------- 1 | {% from "aiohttp_admin/blocks/form/field_title.html" import title %} 2 | {% from "aiohttp_admin/blocks/form/field_errors.html" import errors %} 3 | 4 | {% macro field( 5 | name, 6 | field, 7 | get_field_value, 8 | with_defaults=False 9 | ) 10 | %} 11 |
12 | {{ title(name, field) }} 13 | {% set field_value = get_field_value(field, with_defaults) -%} 14 | 21 | {{ errors(field) }} 22 |
23 | {% endmacro %} 24 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/fields/readonly_field.html: -------------------------------------------------------------------------------- 1 | {% from "aiohttp_admin/blocks/form/field_title.html" import title %} 2 | {% from "aiohttp_admin/blocks/form/field_errors.html" import errors %} 3 | 4 | {% macro readonly_field( 5 | name, 6 | field, 7 | get_field_value, 8 | with_defaults=False 9 | ) 10 | %} 11 |
12 | {{ title(name, field) }} 13 | {% set field_value = get_field_value(field, with_defaults) -%} 14 | 20 | {{ errors(field) }} 21 |
22 | {% endmacro %} 23 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/fields/string_field.html: -------------------------------------------------------------------------------- 1 | {% from "aiohttp_admin/blocks/form/field_title.html" import title %} 2 | {% from "aiohttp_admin/blocks/form/field_errors.html" import errors %} 3 | 4 | {% macro field( 5 | name, 6 | field, 7 | get_field_value, 8 | with_defaults=False 9 | ) 10 | %} 11 |
12 | {{ title(name, field) }} 13 | {% set field_value = get_field_value(field, with_defaults) -%} 14 | 22 | {{ errors(field) }} 23 |
24 | {% endmacro %} 25 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/form/form.html: -------------------------------------------------------------------------------- 1 | {% from 'aiohttp_admin/blocks/form/fields/field.html' import field_generator %} 2 | {% from 'aiohttp_admin/blocks/messages.html' import messages %} 3 | {% from 'aiohttp_admin/blocks/form/fields/readonly_field.html' import readonly_field %} 4 | 5 | {% macro form( 6 | action, 7 | controller_view, 8 | get_field_value, 9 | mapper=None, 10 | fields=None, 11 | method='POST', 12 | submit_text='submit', 13 | submit_cls='', 14 | exclude=None, 15 | readonly=None, 16 | with_defaults=False 17 | ) %} 18 | {% set exclude = exclude or [] %} 19 | {% set readonly = readonly or [] %} 20 |
21 |
22 | {# Show a general error message if the form has errors #} 23 | {% if mapper and mapper.with_errors %} 24 | {{ messages("Invalid form", type="danger") }} 25 | {% endif %} 26 | 27 | {# Render visible fields #} 28 | {% if mapper %} 29 | {% for name, field in mapper.fields.items() %} 30 | {% if (fields == '__all__' or name in fields) and name not in exclude %} 31 | {% if name in readonly %} 32 | {{ readonly_field(name, field, get_field_value, with_defaults) }} 33 | {% else %} 34 | {{ field_generator(name, field, controller_view, get_field_value, with_defaults) }} 35 | {% endif %} 36 | {% endif %} 37 | {% endfor %} 38 | {% endif %} 39 | 40 | {# Show a general form error if present #} 41 | {% if mapper and mapper.error %} 42 |
43 | {{ mapper.error }} 44 |
45 | {% endif %} 46 | 47 | {# Show errors for hidden/excluded fields (not rendered above) #} 48 | {% if mapper %} 49 | {% for name, field in mapper.fields.items() %} 50 | {% if fields != '__all__' and (name not in fields or name in exclude) and field.errors %} 51 |
52 | .... err 53 | 54 | {{ name }} - {{ field.errors|join(', ') }} 55 | {# This field is not visible in the form, but has errors #} 56 | 57 |
58 | {% endif %} 59 | {% endfor %} 60 | {% endif %} 61 | 62 | 63 |
64 |
65 | {% endmacro %} 66 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/header.html: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/list_action_buttons.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

{{ title }}

5 |
6 |
7 | {%- if controller.can_create %} 8 | Create 9 | {%- endif %} 10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/list_cell.html: -------------------------------------------------------------------------------- 1 | {% macro list_cell(cell) %} 2 | {% if cell.url %} 3 | 4 | {{ cell.value }} 5 | 6 | {% else %} 7 | {{ cell.value }} 8 | {% endif %} 9 | {% endmacro %} 10 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/list_objects_block.html: -------------------------------------------------------------------------------- 1 | {% from 'aiohttp_admin/blocks/list_cell.html' import list_cell %} 2 | 3 |
4 |
5 | 6 | {% include 'aiohttp_admin/blocks/list_objects_header_block.html' %} 7 | 8 | {%- for row in list.rows %} 9 | 10 | {% for cell in row %} 11 | 21 | {%- endfor %} 22 | 23 |
12 | {% if loop.first %} 13 | 16 | {% endif %} 17 | {{ list_cell(cell) }} 18 | 19 | {% endfor %} 20 |
24 |
25 | {% if view_filters %} 26 | 37 | {% endif %} 38 |
39 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/list_objects_header_block.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% with isDesc=(url_query.get('sortDir') == 'desc') %} 4 | {% for field in controller.inline_fields %} 5 | 6 | {% if controller.is_field_sortable(field, controller_view.infinite_scroll) %} 7 | 8 | 13 | {{ field }} 14 | {% if url_query.get('sort') == field %} 15 | {% if isDesc %} 16 | arrow_upward 17 | {% else %} 18 | arrow_downward 19 | {% endif %} 20 | {% endif %} 21 | 22 | {% else %} 23 | {{ field }} 24 | {% endif %} 25 | 26 | {% endfor %} 27 | {% endwith %} 28 | 29 | 30 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/messages.html: -------------------------------------------------------------------------------- 1 | {% macro messages(message, type="dark") %} 2 | {% if message %} 3 |

{{ message }}

4 | {% endif %} 5 | {% endmacro %} 6 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/nav_aside.html: -------------------------------------------------------------------------------- 1 | 30 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/pagination.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% macro item(index, active, query_args={}) %} 4 |
  • 5 | 6 | {{ index }} 7 | 8 |
  • 9 | {% endmacro %} 10 | 11 | 12 | {% macro empty_item() %} 13 |
  • 14 | 15 | ... 16 | 17 |
  • 18 | {% endmacro %} 19 | 20 | 21 | {% macro pagination(page, count, has_next, has_prev, per_page, query_args={}, size=5) -%} 22 | 71 | {%- endmacro %} 72 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/blocks/tabs_bar.html: -------------------------------------------------------------------------------- 1 | {% if tabs %} 2 | 22 |
    23 | {% endif %} 24 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/layouts/base.html: -------------------------------------------------------------------------------- 1 | {% from 'aiohttp_admin/blocks/messages.html' import messages %} 2 | 3 | 4 | 5 | 6 | 7 | 11 | {% if media %} 12 | 13 | {% for css_url in media['css'] %} 14 | 15 | {% endfor %} 16 | 17 | {% endif %} 18 | 19 | 20 | 21 | 22 | 23 | {% if media %} 24 | 25 | {% for js_url in media['js'] %} 26 | 27 | {% endfor %} 28 | 29 | {% endif %} 30 | {% block extra_header %}{% endblock extra_header %} 31 | 32 | {% block title %}{{ project_name }}{% endblock title %} 33 | 34 | 35 | {% include 'aiohttp_admin/blocks/header.html' %} 36 |
    37 | {% include 'aiohttp_admin/blocks/nav_aside.html' %} 38 |
    39 | {{ messages(message) }} 40 | {% block main %}{% endblock main %} 41 |
    42 |
    43 | 44 | 45 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/layouts/create_page.html: -------------------------------------------------------------------------------- 1 | {% extends 'aiohttp_admin/layouts/base.html' %} 2 | {% from 'aiohttp_admin/blocks/form/form.html' import form %} 3 | 4 | {% block main %} 5 |
    6 |

    {{ title }}

    7 | 8 | {% if controller.can_create %} 9 | {{ 10 | form( 11 | create_post_url, 12 | get_field_value=get_field_value, 13 | submit_text='Create', 14 | mapper=mapper, 15 | exclude=exclude_fields, 16 | readonly=read_only_fields, 17 | fields=fields, 18 | controller_view=controller_view, 19 | with_defaults=True, 20 | ) 21 | }} 22 | {% else %} 23 | 26 | {% endif %} 27 | 28 |
    29 | {% endblock main %} 30 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/layouts/custom_page.html: -------------------------------------------------------------------------------- 1 | {% extends 'aiohttp_admin/layouts/base.html' %} 2 | 3 | {% block main %} 4 | {%- if content %} 5 | {{ content }} 6 | {%- else %} 7 |

    {{ title }}

    8 | {%- endif %} 9 | {% endblock main %} 10 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/layouts/custom_tab_page.html: -------------------------------------------------------------------------------- 1 | {% extends 'aiohttp_admin/layouts/list_page.html' %} 2 | 3 | {% block list_content %} 4 |
    5 | {{ content|safe }} 6 |
    7 | {% endblock list_content %} 8 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/layouts/delete_page.html: -------------------------------------------------------------------------------- 1 | {% extends 'aiohttp_admin/layouts/base.html' %} 2 | {% from 'aiohttp_admin/blocks/form/form.html' import form %} 3 | 4 | {% block main %} 5 |
    6 |

    {{ title }}

    7 | 10 | 11 | {% if controller.can_delete %} 12 | {{ 13 | form( 14 | delete_url, 15 | get_field_value=get_field_value, 16 | submit_text='Delete', 17 | method='POST', 18 | submit_cls='btn-danger', 19 | controller_view=controller_view, 20 | ) 21 | }} 22 | {% else %} 23 | 26 | {% endif %} 27 |
    28 | {% endblock main %} 29 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/layouts/detail_edit_page.html: -------------------------------------------------------------------------------- 1 | {% extends 'aiohttp_admin/layouts/base.html' %} 2 | {% from 'aiohttp_admin/blocks/form/form.html' import form %} 3 | 4 | {% block main %} 5 |
    6 |
    7 |
    8 |
    9 |

    {{ title }}

    10 |
    11 |
    12 |
    13 | 14 | {% include 'aiohttp_admin/blocks/tabs_bar.html' %} 15 | 16 | {% block instance %} 17 | 18 | {% if controller.can_update %} 19 | {{ 20 | form( 21 | save_url, 22 | get_field_value=get_field_value, 23 | submit_text='Update', 24 | mapper=mapper, 25 | exclude=exclude_fields, 26 | readonly=read_only_fields, 27 | fields=fields, 28 | controller_view=controller_view, 29 | ) 30 | }} 31 | {% else %} 32 | 35 | {% endif %} 36 | {% endblock instance %} 37 | 38 | 39 | {% if controller.can_delete %} 40 | {{ 41 | form( 42 | delete_url, 43 | get_field_value=get_field_value, 44 | submit_text='Delete', 45 | method='GET', 46 | submit_cls='btn-danger', 47 | controller_view=controller_view, 48 | ) 49 | }} 50 | {% endif %} 51 |
    52 | {% endblock main %} 53 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/layouts/detail_view_page.html: -------------------------------------------------------------------------------- 1 | {% extends 'aiohttp_admin/layouts/detail_edit_page.html' %} 2 | 3 | {% block instance %} 4 | {% if controller.can_view %} 5 |
    6 | {% for name, value in object.data.to_dict().items() %} 7 |

    {{ name }} {{ value }}

    8 | {% endfor %} 9 |
    10 |
    11 | {% endif %} 12 | {% endblock instance %} 13 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/layouts/list_cursor_page.html: -------------------------------------------------------------------------------- 1 | {% from 'aiohttp_admin/blocks/list_cell.html' import list_cell %} 2 | {% from "aiohttp_admin/blocks/cursor_pagination.html" import cursor_pagination %} 3 | 4 | {%- for row in list.rows %} 5 | 6 | {% for cell in row %} 7 | 8 | {% if loop.first %} 9 | 12 | {% endif %} 13 | {{ list_cell(cell) }} 14 | 15 | {% endfor %} 16 | 17 | {%- endfor %} 18 | 19 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/templates/aiohttp_admin/layouts/list_page.html: -------------------------------------------------------------------------------- 1 | {% extends 'aiohttp_admin/layouts/base.html' %} 2 | {% from "aiohttp_admin/blocks/pagination.html" import pagination %} 3 | {% from "aiohttp_admin/blocks/cursor_pagination.html" import cursor_pagination %} 4 | {% from "aiohttp_admin/blocks/filters/search_filter.html" import search_filter with context %} 5 | 6 | {% block main %} 7 |
    8 | {% include 'aiohttp_admin/blocks/list_action_buttons.html' %} 9 | {% include 'aiohttp_admin/blocks/tabs_bar.html' %} 10 | 11 | {% block list_content %} 12 |
    13 |
    14 | {% if controller.search_fields %} 15 | {{ search_filter(controller_view.search_filter(controller.search_fields, url_query)) }} 16 | {% endif %} 17 |
    18 | {% include 'aiohttp_admin/blocks/list_objects_block.html' %} 19 |
    20 | 21 | 22 | {% if controller_view.infinite_scroll %} 23 | {{ 24 | cursor_pagination( 25 | list.has_next, 26 | list.next_id, 27 | ) 28 | }} 29 | {% else %} 30 | {{ 31 | pagination( 32 | list.active_page, 33 | list.count, 34 | list.has_next, 35 | list.has_prev, 36 | list.per_page, 37 | url_query, 38 | ) 39 | }} 40 | {% endif %} 41 | {% endblock list_content %} 42 |
    43 | {% endblock main %} 44 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/utils.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from aiohttp import web 4 | 5 | 6 | __all__ = ['get_params_from_request', 'QueryParams', 'get_field_value', ] 7 | 8 | 9 | class QueryParams(t.NamedTuple): 10 | """ 11 | # todo: description 12 | """ 13 | page: t.Optional[int] 14 | cursor: t.Optional[int] 15 | order_by: t.Optional[str] 16 | 17 | 18 | # todo: add test 19 | def get_params_from_request(req: web.Request) -> QueryParams: 20 | """ 21 | This function need for convert query string to filter parameters. 22 | """ 23 | page = int(req.rel_url.query.get('page', '1')) 24 | cursor = req.rel_url.query.get('cursor') 25 | sort = req.rel_url.query.get('sort') 26 | sort_dir = req.rel_url.query.get('sortDir') 27 | 28 | if sort and sort_dir == 'desc': 29 | sort = f'-{sort}' 30 | 31 | return QueryParams( 32 | page=page, 33 | cursor=int(cursor) if cursor else None, 34 | order_by=sort, 35 | ) 36 | 37 | 38 | def get_field_value(field, with_defaults) -> str: 39 | """ 40 | This helper need to extract value from field. 41 | """ 42 | if field.is_not_none: 43 | return field.failure_safe_value 44 | elif field.default and with_defaults: 45 | return field.default 46 | 47 | return '' 48 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/aiohttp_admin2/views/aiohttp/views/__init__.py -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/views/dashboard.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.views import TemplateView 2 | 3 | 4 | __all__ = ['DashboardView', ] 5 | 6 | 7 | class DashboardView(TemplateView): 8 | """ 9 | This class need for represent a main page for admin interface. 10 | """ 11 | index_url = '/' 12 | name = 'index' 13 | icon = 'dashboard' 14 | 15 | @classmethod 16 | def get_index_url(cls): 17 | return cls.index_url 18 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/views/tab_base_view.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from aiohttp import web 4 | 5 | from aiohttp_admin2.views.aiohttp.views.base import BaseAdminView 6 | 7 | __all__ = ['TabBaseView', ] 8 | 9 | 10 | class TabBaseView(BaseAdminView): 11 | _parent = None 12 | is_hide_view = True 13 | 14 | @classmethod 15 | def get_parent(cls): 16 | return cls._parent 17 | 18 | @classmethod 19 | def set_parent(cls, parent): 20 | cls._parent = parent 21 | 22 | def get_pk(self, req: web.Request) -> str: 23 | return req.match_info['pk'] 24 | 25 | async def get_context(self, req: web.Request) -> t.Dict[str, t.Any]: 26 | return { 27 | ** await super().get_context(req), 28 | 'controller_view': self, 29 | 'pk': self.get_pk(req), 30 | } 31 | 32 | @classmethod 33 | def get_index_url(cls) -> str: 34 | return ( 35 | f'{cls.get_parent().get_index_url()}' 36 | + r'{pk:\w+}' + f'/{cls.get_name()}' 37 | ) 38 | 39 | @classmethod 40 | def get_index_url_name(cls): 41 | """This method return the name of the index url route.""" 42 | 43 | name = super().get_index_url_name() 44 | 45 | return cls.get_parent().get_index_url_name() + "_" + name 46 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/views/tab_template_view.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | import aiohttp_jinja2 3 | 4 | from aiohttp_admin2.views.aiohttp.views.tab_base_view import TabBaseView 5 | from aiohttp_admin2.views.aiohttp.views.base import BaseAdminView 6 | from aiohttp_admin2.views.aiohttp.views.utils import route 7 | 8 | __all__ = ['TabTemplateView', ] 9 | 10 | 11 | class TabTemplateView(TabBaseView, BaseAdminView): 12 | template_name: str = 'aiohttp_admin/layouts/custom_tab_page.html' 13 | 14 | async def get_content(self, req: web.Request) -> str: 15 | return '' 16 | 17 | @route(r'/') 18 | async def get(self, req: web.Request) -> web.Response: 19 | return aiohttp_jinja2.render_template( 20 | self.template_name, 21 | req, 22 | { 23 | **await self.get_context(req), 24 | 'title': f"{self.get_parent().name}#{self.get_pk(req)}", 25 | 'content': await self.get_content(req) 26 | }, 27 | ) 28 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/views/template_view.py: -------------------------------------------------------------------------------- 1 | import aiohttp_jinja2 2 | from aiohttp import web 3 | 4 | from aiohttp_admin2.views.aiohttp.views.base import BaseAdminView 5 | from aiohttp_admin2.views.aiohttp.views.utils import route 6 | 7 | 8 | __all__ = ['TemplateView', ] 9 | 10 | 11 | class TemplateView(BaseAdminView): 12 | """ 13 | This class need for represented custom pages like dashboard or some like 14 | that. 15 | """ 16 | template_name: str = 'aiohttp_admin/layouts/custom_page.html' 17 | 18 | @route('/') 19 | async def get(self, req: web.Request) -> web.Response: 20 | return aiohttp_jinja2.render_template( 21 | self.template_name, 22 | req, 23 | await self.get_context(req), 24 | ) 25 | -------------------------------------------------------------------------------- /aiohttp_admin2/views/aiohttp/views/utils.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from aiohttp import web 4 | from aiohttp_admin2.controllers.controller import Controller 5 | from aiohttp_admin2.exceptions import AdminException 6 | from aiohttp_admin2.views.filters import FilerBase 7 | from aiohttp_admin2.views.filters import SearchFilter 8 | 9 | 10 | __all__ = [ 11 | 'route', 12 | 'get_route', 13 | 'IsNotRouteAdminException', 14 | 'UrlInfo', 15 | 'get_list_filters', 16 | ] 17 | 18 | 19 | # todo: tests 20 | def get_list_filters( 21 | req: web.Request, 22 | controller: Controller, 23 | filter_mapper: t.Dict[str, t.Any], 24 | ) -> t.List[FilerBase]: 25 | """ 26 | In this function we extract filter from request params and return 27 | represented as list of internal classes. 28 | """ 29 | filters = [] 30 | 31 | for f in controller.list_filter: 32 | field = controller.mapper({})._fields[f] 33 | filter_cls = filter_mapper.get(field.type_name) 34 | if filter_cls: 35 | filters_list = filter_cls(field, req.rel_url.query) \ 36 | .get_filter_list() 37 | 38 | if filters_list: 39 | filters.extend(filters_list) 40 | 41 | if controller.search_fields: 42 | filters.extend( 43 | SearchFilter(controller.search_fields, req.rel_url.query) 44 | .get_filter_list() 45 | ) 46 | 47 | return filters 48 | 49 | 50 | class RouteValidationAdminException(AdminException): 51 | pass 52 | 53 | 54 | class IsNotRouteAdminException(AdminException): 55 | pass 56 | 57 | 58 | class RouteInfo(t.NamedTuple): 59 | url: str 60 | method: str 61 | 62 | 63 | class UrlInfo(t.NamedTuple): 64 | name: str 65 | url: str 66 | 67 | 68 | def route(url: str, method='GET'): 69 | """ 70 | :param url: 71 | :param method: 72 | :return: 73 | """ 74 | method = method.upper() 75 | valid_methods = ['POST', 'GET', 'PUT', 'DELETE', 'HEAD', ] 76 | if method not in valid_methods: 77 | raise RouteValidationAdminException( 78 | f"The http method {method} is not valid because not contains in" 79 | f"list of valid methods {valid_methods}" 80 | ) 81 | 82 | if not url.startswith('/') or not url.endswith('/'): 83 | raise RouteValidationAdminException( 84 | f"The url `f{url}` have to start and end by `/` symbol.") 85 | 86 | def inner(fn): 87 | fn.route = RouteInfo(url=url, method=method) 88 | return fn 89 | 90 | return inner 91 | 92 | 93 | def get_route(fn) -> t.Optional[RouteInfo]: 94 | return getattr(fn, 'route', None) 95 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = aiohttp_admin2 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/style.css: -------------------------------------------------------------------------------- 1 | .highlight { 2 | background: #f8f8f8; 3 | } 4 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from datetime import datetime 4 | 5 | sys.path.insert(0, os.path.abspath('..')) 6 | 7 | 8 | extensions = [ 9 | # https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#module-sphinx.ext.napoleon 10 | 'sphinx.ext.napoleon', 11 | # https://www.sphinx-doc.org/en/master/usage/extensions/viewcode.html 12 | "sphinx.ext.viewcode", 13 | "sphinx.ext.autosectionlabel", 14 | "sphinx_toggleprompt", 15 | ] 16 | 17 | # Add any paths that contain templates here, relative to this directory. 18 | templates_path = ['_templates'] 19 | 20 | # The suffix(es) of source filenames. 21 | # You can specify multiple suffix as a list of string: 22 | # 23 | source_suffix = ['.rst', '.md'] 24 | 25 | # The master toctree document. 26 | master_doc = 'index' 27 | 28 | # General information about the project. 29 | project = 'aiohttp admin 2' 30 | copyright = f'{datetime.now().year}, Mykhailo Havelia' 31 | author = "Mykhailo Havelia" 32 | 33 | # The version info for the project you're documenting, acts as replacement 34 | # for |version| and |release|, also used in various other places throughout 35 | # the built documents. 36 | # 37 | # The short X.Y version. 38 | version = "" 39 | # The full version, including alpha/beta/rc tags. 40 | release = "" 41 | 42 | # The language for content autogenerated by Sphinx. Refer to documentation 43 | # for a list of supported languages. 44 | # 45 | # This is also used if you do content translation via gettext catalogs. 46 | # Usually you set "language" from the command line for these cases. 47 | language = "en" 48 | 49 | # List of patterns, relative to source directory, that match files and 50 | # directories to ignore when looking for source files. 51 | # This patterns also effect to html_static_path and html_extra_path 52 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 53 | 54 | # The name of the Pygments (syntax highlighting) style to use. 55 | pygments_style = 'abap' 56 | 57 | 58 | # -- Options for HTML output ------------------------------------------- 59 | 60 | # The theme to use for HTML and HTML Help pages. See the documentation for 61 | # a list of builtin themes. 62 | # 63 | html_theme = "furo" 64 | 65 | # Theme options are theme-specific and customize the look and feel of a 66 | # theme further. For a list of options available for each theme, see the 67 | # documentation. 68 | # 69 | # html_theme_options = {} 70 | 71 | # Add any paths that contain custom static files (such as style sheets) here, 72 | # relative to this directory. They are copied after the builtin static files, 73 | # so a file named "default.css" will overwrite the builtin "default.css". 74 | html_static_path = ['_static'] 75 | 76 | # -- Options for LaTeX output ------------------------------------------ 77 | 78 | latex_elements = { 79 | # The paper size ('letterpaper' or 'a4paper'). 80 | # 81 | # 'papersize': 'letterpaper', 82 | 83 | # The font size ('10pt', '11pt' or '12pt'). 84 | # 85 | # 'pointsize': '10pt', 86 | 87 | # Additional stuff for the LaTeX preamble. 88 | # 89 | # 'preamble': '', 90 | 91 | # Latex figure (float) alignment 92 | # 93 | # 'figure_align': 'htbp', 94 | } 95 | 96 | 97 | def setup(app): 98 | app.add_css_file('style.css') 99 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/images/access_settings_result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/access_settings_result.png -------------------------------------------------------------------------------- /docs/images/body_field_html.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/body_field_html.png -------------------------------------------------------------------------------- /docs/images/custom_context.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/custom_context.png -------------------------------------------------------------------------------- /docs/images/custom_fields_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/custom_fields_example.png -------------------------------------------------------------------------------- /docs/images/custom_template_name_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/custom_template_name_dashboard.png -------------------------------------------------------------------------------- /docs/images/filters_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/filters_example.png -------------------------------------------------------------------------------- /docs/images/full_controller_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/full_controller_example.png -------------------------------------------------------------------------------- /docs/images/get_relation_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/get_relation_example.png -------------------------------------------------------------------------------- /docs/images/groups_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/groups_example.png -------------------------------------------------------------------------------- /docs/images/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/index.png -------------------------------------------------------------------------------- /docs/images/infinity_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/infinity_example.png -------------------------------------------------------------------------------- /docs/images/inline_fields_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/inline_fields_example.png -------------------------------------------------------------------------------- /docs/images/one_to_one_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/one_to_one_example.png -------------------------------------------------------------------------------- /docs/images/one_to_one_relation_autocomplete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/one_to_one_relation_autocomplete.png -------------------------------------------------------------------------------- /docs/images/one_to_one_relation_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/one_to_one_relation_list.png -------------------------------------------------------------------------------- /docs/images/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/overview.png -------------------------------------------------------------------------------- /docs/images/overview_header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/overview_header.png -------------------------------------------------------------------------------- /docs/images/post_controller_first.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/post_controller_first.png -------------------------------------------------------------------------------- /docs/images/search_fields_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/search_fields_example.png -------------------------------------------------------------------------------- /docs/images/simple_create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/simple_create.png -------------------------------------------------------------------------------- /docs/images/simple_delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/simple_delete.png -------------------------------------------------------------------------------- /docs/images/simple_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/simple_example.png -------------------------------------------------------------------------------- /docs/images/simple_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/simple_list.png -------------------------------------------------------------------------------- /docs/images/simple_update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/simple_update.png -------------------------------------------------------------------------------- /docs/images/template_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/template_page.png -------------------------------------------------------------------------------- /docs/images/validation_error_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/docs/images/validation_error_example.png -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install aiohttp admin 2, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install aiohttp_admin2 16 | 17 | This is the preferred method to install aiohttp admin 2, as it will always install the most recent stable release. 18 | 19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 20 | you through the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 24 | 25 | 26 | From sources 27 | ------------ 28 | 29 | The sources for aiohttp admin 2 can be downloaded from the `Github repo`_. 30 | 31 | You can either clone the public repository: 32 | 33 | .. code-block:: console 34 | 35 | $ git clone git://github.com/arfey/aiohttp_admin2 36 | 37 | Or download the `tarball`_: 38 | 39 | .. code-block:: console 40 | 41 | $ curl -OJL https://github.com/arfey/aiohttp_admin2/tarball/master 42 | 43 | Once you have a copy of the source, you can install it with: 44 | 45 | .. code-block:: console 46 | 47 | $ python setup.py install 48 | 49 | 50 | .. _Github repo: https://github.com/arfey/aiohttp_admin2 51 | .. _tarball: https://github.com/arfey/aiohttp_admin2/tarball/master 52 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=aiohttp_admin2 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /example_projects/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/example_projects/__init__.py -------------------------------------------------------------------------------- /example_projects/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | 5 | postgres: 6 | image: postgres 7 | environment: 8 | POSTGRES_DB: postgres 9 | POSTGRES_USER: postgres 10 | POSTGRES_PASSWORD: postgres 11 | ports: 12 | - 5432:5432 13 | 14 | mongodb: 15 | image: mongo:latest 16 | environment: 17 | - MONGO_DATA_DIR=/data/db 18 | - MONGO_LOG_DIR=/dev/null 19 | ports: 20 | - 27017:27017 21 | -------------------------------------------------------------------------------- /example_projects/main/admin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/example_projects/main/admin/__init__.py -------------------------------------------------------------------------------- /example_projects/main/admin/actors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/example_projects/main/admin/actors/__init__.py -------------------------------------------------------------------------------- /example_projects/main/admin/actors/controllers.py: -------------------------------------------------------------------------------- 1 | from markupsafe import Markup 2 | from aiohttp_admin2.views import ControllerView 3 | from aiohttp_admin2.controllers.postgres_controller import PostgresController 4 | from aiohttp_admin2.mappers.generics import PostgresMapperGeneric 5 | from aiohttp_admin2.controllers.relations import ToOneRelation 6 | from aiohttp_admin2.mappers import fields 7 | 8 | from ...catalog.tables import actors 9 | from ...catalog.tables import actors_hash 10 | from ..injectors import postgres_injector 11 | 12 | 13 | class ActorMapper(PostgresMapperGeneric, table=actors): 14 | GENDER_CHOICES = ( 15 | ('male', "male"), 16 | ('female', "female"), 17 | ) 18 | 19 | gender = fields.ChoicesField( 20 | field_cls=fields.StringField, 21 | choices=GENDER_CHOICES, 22 | default='male', 23 | required=True, 24 | ) 25 | 26 | 27 | @postgres_injector.inject 28 | class ActorHashController(PostgresController, table=actors_hash): 29 | pass 30 | 31 | 32 | @postgres_injector.inject 33 | class ActorController(PostgresController, table=actors): 34 | mapper = ActorMapper 35 | name = 'actor' 36 | per_page = 3 37 | 38 | # can_create = False 39 | # can_update = False 40 | # can_view = False 41 | 42 | relations_to_one = [ 43 | ToOneRelation( 44 | name='profile_hash', 45 | field_name='id', 46 | controller=ActorHashController, 47 | target_field_name='actor_id' 48 | ) 49 | ] 50 | 51 | list_filter = ['gender', ] 52 | search_fields = ['name', 'gender'] 53 | 54 | inline_fields = ['photo', 'name', 'hash'] 55 | 56 | async def photo_field(self, obj): 57 | return Markup( 58 | '' 62 | )\ 63 | .format(path=obj.data.url) 64 | 65 | async def hash_field(self, obj): 66 | profile = await obj.get_relation('profile_hash') 67 | return profile.data.hash if profile else None 68 | 69 | async def get_object_name(self, obj): 70 | return f"{obj.get_pk()} - {obj.data.name}" 71 | 72 | 73 | class ActorPage(ControllerView): 74 | controller = ActorController 75 | -------------------------------------------------------------------------------- /example_projects/main/admin/genres/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/example_projects/main/admin/genres/__init__.py -------------------------------------------------------------------------------- /example_projects/main/admin/genres/controllers.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.controllers.postgres_controller import PostgresController 2 | 3 | from ..injectors import postgres_injector 4 | from ...catalog.tables import genres 5 | from .mappers import GenresMapper 6 | 7 | 8 | @postgres_injector.inject 9 | class GenresController(PostgresController, table=genres): 10 | mapper = GenresMapper 11 | name = 'genres' 12 | per_page = 10 13 | 14 | inline_fields = ['id', 'name', 'type', ] 15 | autocomplete_search_fields = ['name', ] 16 | 17 | async def get_object_name(self, obj): 18 | return obj.data.name 19 | -------------------------------------------------------------------------------- /example_projects/main/admin/genres/mappers.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.mappers.generics import PostgresMapperGeneric 2 | from aiohttp_admin2.mappers import fields 3 | 4 | from .validators import validate_short_name 5 | from ...catalog.tables import genres 6 | 7 | __all__ = ["GenresMapper", ] 8 | 9 | 10 | class GenresMapper(PostgresMapperGeneric, table=genres): 11 | name = fields.StringField(required=True, validators=[validate_short_name]) 12 | -------------------------------------------------------------------------------- /example_projects/main/admin/genres/pages.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.views import ControllerView 2 | 3 | from .controllers import GenresController 4 | 5 | __all__ = ["GenresPage", ] 6 | 7 | 8 | class GenresPage(ControllerView): 9 | infinite_scroll = True 10 | controller = GenresController 11 | -------------------------------------------------------------------------------- /example_projects/main/admin/genres/validators.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.mappers.exceptions import ValidationError 2 | 3 | __all__ = ["validate_short_name", ] 4 | 5 | 6 | def validate_short_name(val): 7 | if len(val) < 3: 8 | raise ValidationError("length too small") 9 | -------------------------------------------------------------------------------- /example_projects/main/admin/images/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/example_projects/main/admin/images/__init__.py -------------------------------------------------------------------------------- /example_projects/main/admin/images/controller.py: -------------------------------------------------------------------------------- 1 | from markupsafe import Markup 2 | from aiohttp_admin2.controllers.postgres_controller import PostgresController 3 | 4 | from ...catalog.tables import images_links 5 | from ..injectors import postgres_injector 6 | 7 | 8 | @postgres_injector.inject 9 | class ImageController(PostgresController, table=images_links): 10 | name = 'images' 11 | per_page = 10 12 | 13 | inline_fields = ['photo', 'type', ] 14 | 15 | async def photo_field(self, obj): 16 | return Markup( 17 | '' 21 | )\ 22 | .format(path=obj.data.url) 23 | -------------------------------------------------------------------------------- /example_projects/main/admin/injectors.py: -------------------------------------------------------------------------------- 1 | # from umongo.frameworks import MotorAsyncIOInstance 2 | from aiohttp_admin2.connection_injectors import ConnectionInjector 3 | 4 | 5 | postgres_injector = ConnectionInjector() 6 | # instance = MotorAsyncIOInstance() 7 | -------------------------------------------------------------------------------- /example_projects/main/admin/mongo_admin.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.controllers.mongo_controller import MongoController 2 | from aiohttp_admin2.views import ControllerView 3 | from umongo import Document, fields 4 | 5 | from .injectors import instance 6 | 7 | 8 | @instance.register 9 | class User(Document): 10 | email = fields.EmailField(required=True, unique=True) 11 | 12 | class Meta: 13 | collection_name = "user" 14 | 15 | 16 | class MongoTestController(MongoController, table=User): 17 | name = 'user mongo' 18 | per_page = 10 19 | 20 | 21 | class MongoPage(ControllerView): 22 | controller = MongoTestController 23 | -------------------------------------------------------------------------------- /example_projects/main/admin/movies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/example_projects/main/admin/movies/__init__.py -------------------------------------------------------------------------------- /example_projects/main/admin/movies/pages.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.views import ControllerView 2 | 3 | from .controllers import MoviesController 4 | 5 | 6 | class MoviesPage(ControllerView): 7 | controller = MoviesController 8 | -------------------------------------------------------------------------------- /example_projects/main/admin/shows/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/example_projects/main/admin/shows/__init__.py -------------------------------------------------------------------------------- /example_projects/main/admin/template_view.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.views.aiohttp.views.template_view import TemplateView 2 | 3 | 4 | class TemplatePage(TemplateView): 5 | name = 'Template_view' 6 | has_access = True 7 | -------------------------------------------------------------------------------- /example_projects/main/admin/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/example_projects/main/admin/users/__init__.py -------------------------------------------------------------------------------- /example_projects/main/admin/users/controllers.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | import sqlalchemy as sa 3 | 4 | import aiofiles 5 | from aiohttp_admin2.views import ControllerView 6 | from aiohttp_admin2.controllers.postgres_controller import PostgresController 7 | from aiohttp_admin2.resources.postgres_resource.postgres_resource import PostgresResource # noqa 8 | from aiohttp_admin2.mappers.generics import PostgresMapperGeneric 9 | from aiohttp_admin2.views.widgets import CKEditorWidget 10 | from aiohttp_admin2.mappers import fields 11 | 12 | from ...auth.tables import users 13 | from ..injectors import postgres_injector 14 | 15 | 16 | class UsersMapper(PostgresMapperGeneric, table=users): 17 | avatar = fields.UrlImageField() 18 | 19 | 20 | class UserPostgresResource(PostgresResource): 21 | def get_list_select(self) -> sa.sql.Select: 22 | """ 23 | In this place you can redefine query. 24 | """ 25 | return sa.select( 26 | *self.table.c, 27 | sa.type_coerce(sa.text("payload -> 'data'"), sa.Text) 28 | .label('data') 29 | ) 30 | 31 | 32 | @postgres_injector.inject 33 | class UsersController(PostgresController, table=users): 34 | mapper = UsersMapper 35 | resource = UserPostgresResource 36 | name = 'users' 37 | per_page = 10 38 | upload_to = './example_projects/main/static' 39 | inline_fields = ['id', 'create_at', 'is_superuser', 'array_c', 'data', ] 40 | list_filter = ['create_at', 'is_superuser', 'id'] 41 | 42 | async def prepare_avatar_field(self, avatar: t.Any) -> str: 43 | if hasattr(avatar, 'file'): 44 | url = f'{self.upload_to}/{avatar.filename}' 45 | 46 | f = await aiofiles.open(url, mode='wb') 47 | await f.write(avatar.file.read()) 48 | await f.close() 49 | 50 | return f'/static/{avatar.filename}' 51 | 52 | return avatar 53 | 54 | async def pre_update(self, data: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: 55 | data['avatar'] = await self.prepare_avatar_field(data['avatar']) 56 | return data 57 | 58 | async def pre_create(self, data: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: 59 | data['avatar'] = await self.prepare_avatar_field(data['avatar']) 60 | return data 61 | 62 | # todo: rename to inline_data_field 63 | async def data_field(self, obj) -> str: 64 | if obj.data.payload and isinstance(obj.data.payload, dict): 65 | return obj.data.data 66 | 67 | return '' 68 | 69 | def data_field_sort(self, is_reverse): 70 | if is_reverse: 71 | return sa.text("payload ->> 'data' desc") 72 | return sa.text("payload ->> 'data'") 73 | 74 | 75 | class UsersPage(ControllerView): 76 | controller = UsersController 77 | fields_widgets = { 78 | "name": CKEditorWidget(), 79 | } 80 | -------------------------------------------------------------------------------- /example_projects/main/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/example_projects/main/auth/__init__.py -------------------------------------------------------------------------------- /example_projects/main/auth/authorization.py: -------------------------------------------------------------------------------- 1 | from aiohttp_security import AbstractAuthorizationPolicy 2 | 3 | from .users import user_map 4 | 5 | 6 | class AuthorizationPolicy(AbstractAuthorizationPolicy): 7 | """ 8 | This class implement access policy to admin interface. 9 | """ 10 | 11 | async def permits(self, identity, permission, context=None) -> bool: 12 | user = user_map.get(identity) 13 | 14 | if permission in user.permission: 15 | return True 16 | 17 | return False 18 | 19 | async def authorized_userid(self, identity) -> int: 20 | return identity 21 | -------------------------------------------------------------------------------- /example_projects/main/auth/middlewares.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from aiohttp import web 4 | from aiohttp_security import is_anonymous 5 | from aiohttp_security import permits 6 | 7 | 8 | @web.middleware 9 | async def admin_access_middleware( 10 | request: web.Request, 11 | handler: t.Any, 12 | ) -> web.Response: 13 | """ 14 | This middleware need for forbidden access to admin interface for users who 15 | don't have right permissions. 16 | """ 17 | if await is_anonymous(request): 18 | raise web.HTTPFound('/') 19 | 20 | if not await permits(request, 'admin'): 21 | raise web.HTTPFound('/') 22 | 23 | return await handler(request) 24 | -------------------------------------------------------------------------------- /example_projects/main/auth/tables.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sa 2 | 3 | from ..db import metadata 4 | 5 | 6 | users = sa.Table('users', metadata, 7 | sa.Column('id', sa.Integer, primary_key=True), 8 | sa.Column('name', sa.String(255)), 9 | sa.Column('is_superuser', sa.Boolean), 10 | sa.Column('array_c', sa.ARRAY(sa.Integer)), 11 | sa.Column('create_at', sa.DateTime()), 12 | sa.Column('create_at_date', sa.Date()), 13 | sa.Column('payload', sa.JSON()), 14 | sa.Column('avatar', sa.Text), 15 | ) 16 | -------------------------------------------------------------------------------- /example_projects/main/auth/users.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | 4 | User = namedtuple('User', ['username', 'id', 'password', 'permission']) 5 | 6 | 7 | users = [ 8 | User('admin', 1, 'admin', ['admin']), 9 | User('simple', 2, 'simple', []), 10 | ] 11 | 12 | user_map = { 13 | user.username: user for user in users 14 | } 15 | -------------------------------------------------------------------------------- /example_projects/main/auth/views.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | import aiohttp_jinja2 3 | from aiohttp_security import remember 4 | from aiohttp_security import forget 5 | from aiohttp_security import is_anonymous 6 | 7 | from ..routes import routes 8 | from .users import user_map 9 | 10 | 11 | __all___ = ['login_page', 'login_handler', ] 12 | 13 | 14 | @routes.get('/') 15 | async def index_page(request: web.Request) -> None: 16 | """ 17 | The handler redirect user from index page to login if user is not 18 | authorized and to admin in other case. 19 | """ 20 | if await is_anonymous(request): 21 | raise web.HTTPFound('/login') 22 | else: 23 | raise web.HTTPFound('/admin/') 24 | 25 | 26 | @routes.get('/login') 27 | @aiohttp_jinja2.template('login.html') 28 | async def login_page(request: web.Request) -> None: 29 | """ 30 | This handler represent a form for authorization user. If user is authorized 31 | we'll redirect him to admin page. 32 | """ 33 | if not await is_anonymous(request): 34 | raise web.HTTPFound('/admin/') 35 | 36 | 37 | @routes.post('/login', name='login_post') 38 | @aiohttp_jinja2.template('login.html') 39 | async def login_post(request: web.Request) -> web.Response: 40 | """ 41 | This handler check if user type a correct credential and do login for him 42 | in another case we'll redirect him to login page. 43 | """ 44 | data = await request.post() 45 | user = user_map.get(data['username']) 46 | 47 | if user and user.password == data['password']: 48 | admin_page = web.HTTPFound('/admin/') 49 | await remember(request, admin_page, user.username) 50 | 51 | # return instead raise an error to set cookies 52 | # https://github.com/aio-libs/aiohttp/issues/5181 53 | return admin_page 54 | 55 | raise web.HTTPFound('/login') 56 | 57 | 58 | @routes.get('/logout') 59 | async def logout_page(request: web.Request) -> web.Response: 60 | """ 61 | This handler need for logout user and redirect him to login page. 62 | """ 63 | redirect_response = web.HTTPFound('/login') 64 | await forget(request, redirect_response) 65 | # return instead raise an error to set cookies 66 | # https://github.com/aio-libs/aiohttp/issues/5181 67 | return redirect_response 68 | -------------------------------------------------------------------------------- /example_projects/main/catalog/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/example_projects/main/catalog/__init__.py -------------------------------------------------------------------------------- /example_projects/main/db.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sa 2 | 3 | 4 | metadata = sa.MetaData() 5 | -------------------------------------------------------------------------------- /example_projects/main/routes.py: -------------------------------------------------------------------------------- 1 | from aiohttp.web import RouteTableDef 2 | 3 | 4 | routes = RouteTableDef() 5 | -------------------------------------------------------------------------------- /example_projects/main/static/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/example_projects/main/static/.keep -------------------------------------------------------------------------------- /example_projects/main/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | Login page | aiohttp admin 10 | 16 | 17 | 18 |
    19 | 20 | 21 |
    26 | 27 | 28 |
    29 | 30 | 31 | 32 | For login use admin for username and password. 33 |
    34 | 35 | 36 |
    37 | 38 | 39 |
    40 | 41 | 42 | 43 | 44 |
    45 | 46 |
    47 | 48 | 49 | -------------------------------------------------------------------------------- /example_projects/quick_start/README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | docker-compose up 3 | 4 | python tables.py 5 | ``` 6 | -------------------------------------------------------------------------------- /example_projects/quick_start/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/example_projects/quick_start/__init__.py -------------------------------------------------------------------------------- /example_projects/quick_start/admin.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from aiohttp import web 4 | from aiohttp_admin2.views import DashboardView 5 | from aiohttp_admin2.views import widgets 6 | from aiohttp_admin2.views.aiohttp.views.template_view import TemplateView 7 | from aiohttp_admin2.views import ControllerView 8 | from aiohttp_admin2.controllers.postgres_controller import PostgresController 9 | from aiohttp_admin2.controllers.relations import ToOneRelation 10 | from aiohttp_admin2.controllers.relations import ToManyRelation 11 | from aiohttp_admin2.mappers.generics import PostgresMapperGeneric 12 | from aiohttp_admin2.mappers import fields 13 | from aiohttp_admin2.mappers.validators import length 14 | 15 | from .tables import users 16 | from .tables import post 17 | from .tables import postgres_injector 18 | 19 | 20 | class FirstCustomView(TemplateView): 21 | name = 'Template views' 22 | 23 | 24 | class CustomDashboard(DashboardView): 25 | template_name = 'my_custom_dashboard.html' 26 | 27 | async def get_context(self, req: web.Request) -> t.Dict[str, t.Any]: 28 | return { 29 | **await super().get_context(req=req), 30 | "content": "My custom content" 31 | } 32 | 33 | 34 | # create a mapper for table 35 | class UserMapper(PostgresMapperGeneric, table=users): 36 | pass 37 | 38 | 39 | # create controller for table with UserMapper 40 | @postgres_injector.inject 41 | class UserController(PostgresController, table=users): 42 | mapper = UserMapper 43 | name = 'user' 44 | 45 | inline_fields = ['id', 'full_name', 'is_superuser', 'joined_at'] 46 | search_fields = ['first_name', 'last_name'] 47 | list_filter = ['joined_at', 'is_superuser', ] 48 | 49 | async def full_name_field(self, obj): 50 | return f'{obj.data.first_name} {obj.data.last_name}' 51 | 52 | async def get_object_name(self, obj): 53 | return obj.data.first_name 54 | 55 | relations_to_many = [ 56 | ToManyRelation( 57 | name='user posts', 58 | left_table_pk='author_id', 59 | relation_controller=lambda: PostController, 60 | ) 61 | ] 62 | 63 | 64 | # create views for table 65 | class UserView(ControllerView): 66 | controller = UserController 67 | 68 | 69 | # create a mapper for table 70 | class PostMapper(PostgresMapperGeneric, table=post): 71 | title = fields.StringField( 72 | required=True, 73 | validators=[length(min_value=10)], 74 | ) 75 | 76 | 77 | # create controller for table with UserMapper 78 | @postgres_injector.inject 79 | class PostController(PostgresController, table=post): 80 | mapper = PostMapper 81 | name = 'post' 82 | 83 | inline_fields = ['id', 'title', 'published_at', 'author_id', ] 84 | search_fields = ['title', ] 85 | list_filter = ['status', 'author_id', ] 86 | 87 | async def title_field(self, obj): 88 | if len(obj.data.title) > 10: 89 | return f'{obj.data.title[:10]}...' 90 | return obj.data.title 91 | 92 | relations_to_one = [ 93 | ToOneRelation( 94 | name='author_id', 95 | field_name='author_id', 96 | controller=UserController 97 | ), 98 | ] 99 | 100 | 101 | # create views for table 102 | class PostView(ControllerView): 103 | controller = PostController 104 | 105 | fields_widgets = { 106 | 'body': widgets.CKEditorWidget 107 | } 108 | -------------------------------------------------------------------------------- /example_projects/quick_start/app.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from aiohttp import web 3 | from aiohttp_admin2 import setup_admin 4 | from aiohttp_admin2.views import Admin 5 | from aiohttp_security import SessionIdentityPolicy 6 | from aiohttp_security import setup as setup_security 7 | from aiohttp_session.cookie_storage import EncryptedCookieStorage 8 | from aiohttp_session import setup as session_setup 9 | from cryptography import fernet 10 | import base64 11 | import aiohttp_jinja2 12 | import jinja2 13 | from pathlib import Path 14 | import aiopg.sa 15 | 16 | from .admin import CustomDashboard 17 | from .admin import FirstCustomView 18 | from .admin import UserView 19 | from .admin import PostView 20 | from .tables import postgres_injector 21 | from .auth import admin_access_middleware 22 | from .auth import login_page 23 | from .auth import login_post 24 | from .auth import logout_page 25 | from .auth import AuthorizationPolicy 26 | from .tables import create_tables, DB_URL 27 | 28 | 29 | template_directory = Path(__file__).parent / 'templates' 30 | 31 | 32 | class CustomAdmin(Admin): 33 | dashboard_class = CustomDashboard 34 | 35 | 36 | async def init_db(app): 37 | engine = await aiopg.sa.create_engine( 38 | user='postgres', 39 | database='postgres', 40 | host='0.0.0.0', 41 | password='postgres', 42 | ) 43 | postgres_injector.init(engine) 44 | app['db'] = engine 45 | 46 | yield 47 | 48 | app['db'].close() 49 | await app['db'].wait_closed() 50 | 51 | 52 | async def security(application: web.Application) -> t.AsyncGenerator[None, None]: 53 | fernet_key = fernet.Fernet.generate_key() 54 | secret_key = base64.urlsafe_b64decode(fernet_key) 55 | 56 | session_setup( 57 | application, 58 | EncryptedCookieStorage(secret_key, cookie_name='API_SESSION'), 59 | ) 60 | 61 | policy = SessionIdentityPolicy() 62 | setup_security(application, policy, AuthorizationPolicy()) 63 | 64 | yield 65 | 66 | 67 | def app() -> web.Application: 68 | try: 69 | # try to create tables if no exists 70 | create_tables(DB_URL) 71 | except Exception: 72 | pass 73 | 74 | application = web.Application() 75 | application.cleanup_ctx.extend([ 76 | init_db, 77 | security, 78 | ]) 79 | 80 | # setup jinja2 81 | aiohttp_jinja2.setup( 82 | app=application, 83 | loader=jinja2.FileSystemLoader(str(template_directory)), 84 | ) 85 | 86 | # setup admin interface 87 | setup_admin( 88 | application, 89 | admin_class=CustomAdmin, 90 | views=[FirstCustomView, UserView, PostView], 91 | middleware_list=[admin_access_middleware, ], 92 | logout_path='/logout' 93 | ) 94 | 95 | application.add_routes([ 96 | web.get('/login', login_page, name='login'), 97 | web.post('/login', login_post, name='login_post'), 98 | web.get('/logout', logout_page, name='logout'), 99 | # redirect to login page if page not found 100 | web.get('/{tail:.*}', login_page, name='redirect_to_login'), 101 | ]) 102 | 103 | return application 104 | 105 | 106 | if __name__ == '__main__': 107 | web.run_app(app()) 108 | -------------------------------------------------------------------------------- /example_projects/quick_start/auth.py: -------------------------------------------------------------------------------- 1 | import aiohttp_jinja2 2 | from aiohttp import web 3 | from aiohttp_security import is_anonymous 4 | from aiohttp_security import permits 5 | from aiohttp_security import remember 6 | from aiohttp_security import forget 7 | from aiohttp_security import AbstractAuthorizationPolicy 8 | 9 | 10 | class AuthorizationPolicy(AbstractAuthorizationPolicy): 11 | async def permits(self, identity, permission, context=None) -> bool: 12 | if identity == 'admin' and permission == 'admin': 13 | return True 14 | 15 | return False 16 | 17 | async def authorized_userid(self, identity) -> int: 18 | return identity 19 | 20 | 21 | @web.middleware 22 | async def admin_access_middleware(request, handler): 23 | if await is_anonymous(request): 24 | raise web.HTTPFound('/') 25 | 26 | if not await permits(request, 'admin'): 27 | raise web.HTTPFound('/') 28 | 29 | return await handler(request) 30 | 31 | 32 | @aiohttp_jinja2.template('login.html') 33 | async def login_page(request: web.Request) -> None: 34 | if not await is_anonymous(request): 35 | raise web.HTTPFound('/admin/') 36 | 37 | 38 | @aiohttp_jinja2.template('login.html') 39 | async def login_post(request: web.Request) -> web.Response: 40 | data = await request.post() 41 | 42 | if data['username'] == 'admin' and 'admin' == data['password']: 43 | admin_page = web.HTTPFound('/admin/') 44 | await remember(request, admin_page, 'admin') 45 | # return instead raise an error to set cookies 46 | # https://github.com/aio-libs/aiohttp/issues/5181 47 | return admin_page 48 | 49 | raise web.HTTPFound('/login') 50 | 51 | 52 | async def logout_page(request: web.Request) -> web.Response: 53 | redirect_response = web.HTTPFound('/login') 54 | await forget(request, redirect_response) 55 | # return instead raise an error to set cookies 56 | # https://github.com/aio-libs/aiohttp/issues/5181 57 | return redirect_response 58 | -------------------------------------------------------------------------------- /example_projects/quick_start/tables.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from sqlalchemy import create_engine 4 | import sqlalchemy as sa 5 | from aiohttp_admin2.connection_injectors import ConnectionInjector 6 | 7 | 8 | metadata = sa.MetaData() 9 | postgres_injector = ConnectionInjector() 10 | 11 | 12 | class PostStatusEnum(Enum): 13 | published = 'published' 14 | not_published = 'not published' 15 | 16 | 17 | users = sa.Table('demo_quick_users', metadata, 18 | sa.Column('id', sa.Integer, primary_key=True), 19 | sa.Column('first_name', sa.String(255)), 20 | sa.Column('last_name', sa.String(255)), 21 | sa.Column('is_superuser', sa.Boolean), 22 | sa.Column('joined_at', sa.DateTime()), 23 | ) 24 | 25 | post = sa.Table('demo_quick_post', metadata, 26 | sa.Column('id', sa.Integer, primary_key=True), 27 | sa.Column('title', sa.String(255)), 28 | sa.Column('body', sa.Text), 29 | sa.Column('status', sa.Enum(PostStatusEnum)), 30 | sa.Column('published_at', sa.DateTime()), 31 | sa.Column('author_id', sa.ForeignKey('demo_quick_users.id', ondelete='CASCADE')), 32 | ) 33 | 34 | DB_URL = 'postgresql://postgres:postgres@0.0.0.0:5432/postgres' 35 | 36 | 37 | def create_tables(db_url): 38 | engine = create_engine( 39 | db_url, 40 | isolation_level='AUTOCOMMIT', 41 | ) 42 | 43 | metadata.create_all(engine) 44 | 45 | 46 | def recreate_tables(db_url): 47 | engine = create_engine( 48 | db_url, 49 | isolation_level='AUTOCOMMIT', 50 | ) 51 | 52 | metadata.drop_all(engine) 53 | metadata.create_all(engine) 54 | 55 | 56 | if __name__ == '__main__': 57 | recreate_tables(DB_URL) 58 | -------------------------------------------------------------------------------- /example_projects/quick_start/templates/login.html: -------------------------------------------------------------------------------- 1 |
    2 |
    7 | 8 | 9 |
    10 |
    11 | 12 | 13 |
    14 |
    15 | 16 |
    17 |
    18 | -------------------------------------------------------------------------------- /example_projects/quick_start/templates/my_custom_dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends 'aiohttp_admin/layouts/base.html' %} 2 | 3 | {% block main %} 4 |

    Dashboard

    5 | {{ content }}... 6 | {% endblock main %} 7 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | create = true 3 | in-project = true 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "aiohttp_admin2" 3 | version = "0.0.1" 4 | description = "Generator an admin interface based on aiohttp." 5 | authors = ["Mykhailo Havelia "] 6 | maintainers = ["Mykhailo Havelia "] 7 | license = "MIT" 8 | readme = 'README_BUILD.rst' 9 | homepage = "https://github.com/arfey/aiohttp_admin2" 10 | repository = "https://github.com/arfey/aiohttp_admin2" 11 | documentation = "https://aiohttp-admin2.readthedocs.io" 12 | keywords = ["aiohttp_admin2", "admin interface", "aiohttp"] 13 | packages = [ 14 | { include = "aiohttp_admin2" }, 15 | { include = "aiohttp_admin2/**/*" }, 16 | ] 17 | exclude = ["tests/**/*"] 18 | 19 | [tool.poetry.dependencies] 20 | python = "^3.12.0" 21 | aiohttp = "^3.6.3" 22 | aiohttp-jinja2 = "^1.4.2" 23 | aiomysql = {version = "0.2.0", optional = true} 24 | aiopg = {version = "1.5.0a1", extras = ["sa"]} 25 | python-dateutil = "2.9.0" 26 | sqlalchemy = "2.0.41" 27 | 28 | umongo = {version = "^3.1.0", extras = ["motor"], optional = true} 29 | 30 | [tool.poetry.group.dev.dependencies] 31 | aiofiles = "24.1.0" 32 | aiohttp-devtools = "1.1.2" 33 | aiohttp-jinja2 = "1.6" 34 | aiohttp-security = "0.5.0" 35 | aiohttp-session = "2.12.1" 36 | cryptography = "45.0.2" 37 | 38 | [tool.poetry.group.test.dependencies] 39 | pytest = "8.3.5" 40 | pytest-aiohttp = "1.1.0" 41 | pytest-asyncio = "0.26.0" 42 | pytest-docker = "3.2.1" 43 | twine = "6.1.0" 44 | 45 | [tool.poetry.extras] 46 | motor = ["umongo"] 47 | mysql = ["aiomysql"] 48 | 49 | 50 | [tool.poetry-dynamic-versioning] 51 | enable = true 52 | 53 | [tool.pytest.ini_options] 54 | asyncio_mode="auto" 55 | addopts = "-s" 56 | asyncio_default_fixture_loop_scope = "session" 57 | 58 | 59 | [build-system] 60 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"] 61 | build-backend = "poetry.core.masonry.api" 62 | 63 | [tool.black] 64 | line-length = 79 65 | 66 | [tool.isort] 67 | line_length = 80 68 | multi_line_output = 3 69 | include_trailing_comma = true 70 | use_parentheses = true 71 | ensure_newline_before_comments = true 72 | force_grid_wrap = 2 73 | extend_skip_glob = [".git", "env/*", ".venv/*", ".mypy_cache/*", "example_projects/*", "tests/*"] 74 | 75 | [tool.flake8] 76 | max-line-length = 80 77 | max-complexity = 12 78 | exclude = [".git", "env/*", ".venv/*", ".mypy_cache/*", "example_projects/*", "tests/*"] 79 | extend-ignore = ["P101"] 80 | 81 | [tool.bandit] 82 | exclude_dirs = ["env", ".venv", ".cache", "tests/", ".mypy_cache", "example_projects"] 83 | 84 | [tool.mypy] 85 | ignore_missing_imports = true 86 | check_untyped_defs = true 87 | disallow_any_generics = true 88 | disallow_untyped_defs = true 89 | follow_imports = "silent" 90 | strict_optional = true 91 | warn_redundant_casts = true 92 | warn_unused_ignores = true 93 | 94 | [[tool.mypy.overrides]] 95 | module = "tests/*" 96 | ignore_errors = true 97 | 98 | [[tool.mypy.overrides]] 99 | module = "example_projects/*" 100 | ignore_errors = true 101 | -------------------------------------------------------------------------------- /requirements/documentation.txt: -------------------------------------------------------------------------------- 1 | Sphinx 2 | sphinx_rtd_theme 3 | sphinxcontrib-napoleon 4 | sphinx-toggleprompt==0.6.0 5 | furo==2024.8.6 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit test package for aiohttp_admin2.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | # Added custom skip slow test feature 5 | 6 | def pytest_addoption(parser): 7 | parser.addoption( 8 | "--slow", action="store_true", default=False, help="run slow tests" 9 | ) 10 | 11 | 12 | def pytest_configure(config): 13 | config.addinivalue_line("markers", "slow: mark test as slow to run") 14 | 15 | 16 | def pytest_collection_modifyitems(config, items): 17 | 18 | if config.getoption("--slow"): 19 | return 20 | 21 | skip_slow = pytest.mark.skip(reason="need --slow option to run") 22 | 23 | for item in items: 24 | if "slow" in item.keywords: 25 | item.add_marker(skip_slow) 26 | 27 | 28 | pytest_plugins = [ 29 | 'tests.docker_fixtures', 30 | 'tests.manager_fixtures', 31 | ] 32 | -------------------------------------------------------------------------------- /tests/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/tests/controllers/__init__.py -------------------------------------------------------------------------------- /tests/controllers/test_controller.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.controllers.controller import Controller 2 | from aiohttp_admin2.mappers import ( 3 | Mapper, 4 | fields, 5 | ) 6 | from aiohttp_admin2.resources.dict_resource.dict_resource import DictResource 7 | 8 | 9 | class MockMapper(Mapper): 10 | id = fields.StringField(required=True) 11 | 12 | 13 | class MockController(Controller): 14 | mapper = MockMapper 15 | resource = DictResource 16 | 17 | def get_resource(self) -> DictResource: 18 | return DictResource({i: {"id": i} for i in range(20)}) 19 | 20 | 21 | async def test_get_list_without_count(): 22 | list_objects = await MockController().get_list( 23 | url_builder=lambda *args: "", with_count=True 24 | ) 25 | assert list_objects.count == 20 26 | 27 | list_objects = await MockController().get_list( 28 | url_builder=lambda *args: "", with_count=False 29 | ) 30 | assert list_objects.count is None 31 | -------------------------------------------------------------------------------- /tests/controllers/test_relations.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.controllers.controller import Controller 2 | from aiohttp_admin2.controllers.relations import ToManyRelation 3 | from aiohttp_admin2.views import ControllerView 4 | 5 | 6 | class MockController(Controller): 7 | pass 8 | 9 | 10 | class TestToManyRelation: 11 | def test_success_creation(self): 12 | relation = ToManyRelation( 13 | name="name", 14 | left_table_pk="id", 15 | relation_controller=MockController, 16 | ) 17 | 18 | assert relation.name == "name" 19 | assert relation.left_table_pk == "id" 20 | assert relation.view_settings is None 21 | 22 | relation = ToManyRelation( 23 | name="name", 24 | left_table_pk="id", 25 | view_settings={"key": "value"}, 26 | relation_controller=MockController, 27 | ) 28 | 29 | assert relation.name == "name" 30 | assert relation.left_table_pk == "id" 31 | assert relation.view_settings == {"key": "value"} 32 | 33 | def test_accept_with_view_settings(self): 34 | class MockControllerView(ControllerView): 35 | _tabs = [] 36 | 37 | relation1 = ToManyRelation( 38 | name="name1", 39 | left_table_pk="id", 40 | view_settings={"infinite_scroll": True}, 41 | relation_controller=MockController, 42 | ) 43 | relation2 = ToManyRelation( 44 | name="name2", 45 | left_table_pk="id", 46 | relation_controller=MockController, 47 | ) 48 | 49 | relation1.accept(MockControllerView) 50 | relation2.accept(MockControllerView) 51 | 52 | assert len(MockControllerView._tabs) == 2 53 | assert MockControllerView._tabs[0].infinite_scroll == True 54 | assert MockControllerView._tabs[1].infinite_scroll == False 55 | -------------------------------------------------------------------------------- /tests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | postgres_test: 5 | image: postgres 6 | environment: 7 | POSTGRES_DB: postgres 8 | POSTGRES_USER: postgres 9 | POSTGRES_PASSWORD: postgres 10 | ports: 11 | - 5432 12 | 13 | mongodb_test: 14 | image: mongo:latest 15 | environment: 16 | - MONGO_DATA_DIR=/data/db 17 | - MONGO_LOG_DIR=/dev/null 18 | ports: 19 | - 27017 20 | 21 | mysql_test: 22 | image: mysql 23 | environment: 24 | MYSQL_DATABASE: mysql 25 | MYSQL_USER: mysql 26 | MYSQL_PASSWORD: mysql 27 | MYSQL_ROOT_PASSWORD: mysql 28 | ports: 29 | - 3306 30 | -------------------------------------------------------------------------------- /tests/docker_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import psycopg2 3 | import pymongo 4 | import pymysql 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | def postgres(docker_ip, docker_services): 9 | """Ensure that Postgres service is up and responsive.""" 10 | 11 | port = docker_services.port_for("postgres_test", 5432) 12 | data = dict( 13 | user='postgres', 14 | password='postgres', 15 | host=docker_ip, 16 | port=port, 17 | database='postgres', 18 | ) 19 | 20 | def is_responsive(postgres_data): 21 | try: 22 | conn = psycopg2.connect(**postgres_data) 23 | cur = conn.cursor() 24 | cur.close() 25 | conn.close() 26 | return True 27 | except psycopg2.Error: 28 | return False 29 | 30 | docker_services.wait_until_responsive( 31 | timeout=30.0, pause=0.1, check=lambda: is_responsive(data) 32 | ) 33 | 34 | return data 35 | 36 | 37 | @pytest.fixture(scope="session") 38 | def mysql(docker_ip, docker_services): 39 | """Ensure that MySql service is up and responsive.""" 40 | 41 | port = docker_services.port_for("mysql_test", 3306) 42 | data = dict( 43 | user='mysql', 44 | password='mysql', 45 | host=docker_ip, 46 | port=port, 47 | db='mysql', 48 | ) 49 | 50 | def is_responsive(mysql_data): 51 | try: 52 | conn = pymysql.connect(**mysql_data) 53 | cur = conn.cursor() 54 | cur.execute("SELECT 1;") 55 | cur.close() 56 | conn.close() 57 | return True 58 | except Exception: 59 | return False 60 | 61 | docker_services.wait_until_responsive( 62 | timeout=30.0, pause=0.1, check=lambda: is_responsive(data) 63 | ) 64 | 65 | return data 66 | 67 | 68 | @pytest.fixture(scope="session") 69 | def mongo(docker_ip, docker_services): 70 | """Ensure that mongodb service is up and responsive.""" 71 | 72 | port = docker_services.port_for("mongodb_test", 27017) 73 | data = dict( 74 | host=docker_ip, 75 | port=port, 76 | ) 77 | 78 | def is_responsive(mongo_data): 79 | try: 80 | conn = pymongo.MongoClient(**mongo_data) 81 | conn.server_info() 82 | conn.close() 83 | return True 84 | except Exception: 85 | return False 86 | 87 | docker_services.wait_until_responsive( 88 | timeout=30.0, pause=0.1, check=lambda: is_responsive(data) 89 | ) 90 | 91 | return data 92 | -------------------------------------------------------------------------------- /tests/mappers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/tests/mappers/__init__.py -------------------------------------------------------------------------------- /tests/mappers/fields/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/tests/mappers/fields/__init__.py -------------------------------------------------------------------------------- /tests/mappers/fields/test_array_field.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from aiohttp_admin2.mappers import Mapper 6 | from aiohttp_admin2.mappers import fields 7 | 8 | 9 | class ArrayMapper(Mapper): 10 | field = fields.ArrayField(field_cls=fields.IntField) 11 | 12 | 13 | @pytest.mark.parametrize('item_cls, input_data, output_data', [ 14 | (fields.StringField, '[1, 2, 3]', ['1', '2', '3']), 15 | (fields.StringField, [1, 2, 3], ['1', '2', '3']), 16 | ( 17 | fields.DateTimeField, 18 | '["2021-09-05T00:14:49.466639"]', 19 | [datetime.datetime(2021, 9, 5, 0, 14, 49, 466639)], 20 | ), 21 | ( 22 | fields.DateTimeField, 23 | [datetime.datetime(2021, 9, 5, 0, 14, 49, 466639)], 24 | [datetime.datetime(2021, 9, 5, 0, 14, 49, 466639)], 25 | ), 26 | (fields.DateField, '["2021-09-05"]', [datetime.date(2021, 9, 5)]), 27 | ( 28 | fields.DateField, 29 | [datetime.date(2021, 9, 5)], 30 | [datetime.date(2021, 9, 5)] 31 | ), 32 | (fields.IntField, '[1, 2, 3]', [1, 2, 3]), 33 | (fields.IntField, [1, 2, 3], [1, 2, 3]), 34 | (fields.IntField, '[1, 2, 3]', [1, 2, 3]), 35 | (fields.FloatField, '[1.1, 2, 3]', [1.1, 2.0, 3.0]), 36 | (fields.FloatField, [1.1, 2, 3], [1.1, 2.0, 3.0]), 37 | (fields.BooleanField, '["t", "f", "False"]', [True, False, False]), 38 | (fields.BooleanField, [True, False, "False"], [True, False, False]), 39 | ]) 40 | def test_different_types_of_items(item_cls, input_data, output_data): 41 | """ 42 | In this test we check corrected converting items of array of different 43 | types. 44 | """ 45 | class InnerMapper(Mapper): 46 | field = fields.ArrayField(field_cls=item_cls) 47 | 48 | mapper = InnerMapper({'field': input_data}) 49 | 50 | mapper.is_valid() 51 | assert mapper.data['field'] == output_data 52 | 53 | 54 | def test_error_with_wrong_type(): 55 | """ 56 | In current test we check error if received wrong type 57 | """ 58 | assert not ArrayMapper({'field': 'string'}).is_valid() 59 | assert not ArrayMapper({'field': 1}).is_valid() 60 | assert not ArrayMapper({'field': '["str", "str"]'}).is_valid() 61 | assert not ArrayMapper({'field': ['str', 'str']}).is_valid() 62 | 63 | 64 | def test_length_validation(): 65 | """ 66 | Test corrected work of mix/max_length validation. If array less than 67 | min_length or greater than max_length then we must to receive an error. 68 | """ 69 | 70 | class InnerArrayMapperWithMax(Mapper): 71 | field = fields.ArrayField(field_cls=fields.IntField, max_length=1) 72 | 73 | assert InnerArrayMapperWithMax({"field": []}).is_valid() 74 | assert InnerArrayMapperWithMax({"field": [1]}).is_valid() 75 | assert not InnerArrayMapperWithMax({"field": [1, 2]}).is_valid() 76 | 77 | class InnerArrayMapperWithMin(Mapper): 78 | field = fields.ArrayField(field_cls=fields.IntField, min_length=1) 79 | 80 | assert InnerArrayMapperWithMin({"field": [1, 2]}).is_valid() 81 | assert InnerArrayMapperWithMin({"field": [1]}).is_valid() 82 | assert not InnerArrayMapperWithMin({"field": []}).is_valid() 83 | 84 | class InnerArrayMapperFull(Mapper): 85 | field = fields\ 86 | .ArrayField(field_cls=fields.IntField, min_length=1, max_length=2) 87 | 88 | assert InnerArrayMapperFull({"field": [1, 2]}).is_valid() 89 | assert InnerArrayMapperFull({"field": [1]}).is_valid() 90 | assert not InnerArrayMapperFull({"field": []}).is_valid() 91 | assert not InnerArrayMapperFull({"field": [1, 2, 3]}).is_valid() 92 | -------------------------------------------------------------------------------- /tests/mappers/fields/test_boolean_field.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.mappers import Mapper 2 | from aiohttp_admin2.mappers import fields 3 | 4 | 5 | class BooleanMapper(Mapper): 6 | field = fields.BooleanField() 7 | 8 | 9 | def test_validation_from_string(): 10 | """ 11 | In this test we check that if mapper receive string value than it try to 12 | convert it in right python's format. 13 | """ 14 | # '0' -> False 15 | mapper = BooleanMapper({"field": '0'}) 16 | assert mapper.is_valid() 17 | assert mapper.data['field'] is False 18 | 19 | # 'false' -> False 20 | mapper = BooleanMapper({"field": 'false'}) 21 | assert mapper.is_valid() 22 | assert mapper.data['field'] is False 23 | 24 | # 'f' -> False 25 | mapper = BooleanMapper({"field": 'f'}) 26 | assert mapper.is_valid() 27 | assert mapper.data['field'] is False 28 | 29 | # '' -> False 30 | mapper = BooleanMapper({"field": ''}) 31 | assert mapper.is_valid() 32 | assert mapper.data['field'] is False 33 | 34 | # 'none' -> False 35 | mapper = BooleanMapper({"field": 'none'}) 36 | assert mapper.is_valid() 37 | assert mapper.data['field'] is False 38 | 39 | # False -> False 40 | mapper = BooleanMapper({"field": False}) 41 | assert mapper.is_valid() 42 | assert mapper.data['field'] is False 43 | 44 | # all other is True 45 | mapper = BooleanMapper({"field": 'True'}) 46 | assert mapper.is_valid() 47 | assert mapper.data['field'] is True 48 | 49 | # True other is True 50 | mapper = BooleanMapper({"field": True}) 51 | assert mapper.is_valid() 52 | assert mapper.data['field'] is True 53 | -------------------------------------------------------------------------------- /tests/mappers/fields/test_choice_fields.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.mappers import Mapper 2 | from aiohttp_admin2.mappers import fields 3 | 4 | 5 | gender_choices = ( 6 | ('male', "male"), 7 | ('female', "female"), 8 | ) 9 | 10 | 11 | class ChoiceMapper(Mapper): 12 | type = fields.ChoicesField( 13 | field_cls=fields.StringField, 14 | choices=gender_choices, 15 | default='male' 16 | ) 17 | 18 | 19 | def test_choice_generation(): 20 | """ 21 | In this test we check correct generation a choices property for the 22 | ChoicesField field. 23 | """ 24 | assert ChoiceMapper({}).fields['type'].choices == gender_choices 25 | 26 | 27 | def test_correct_validation(): 28 | """ 29 | In this test we check that mapper will valid only if ChoicesField's value 30 | contain in choices list 31 | """ 32 | assert not ChoiceMapper({"type": "bad value"}).is_valid() 33 | assert ChoiceMapper({"type": "female"}).is_valid() 34 | 35 | 36 | def test_correct_for_default_field(): 37 | """ 38 | In this test we check that mapper will set default value to the 39 | ChoicesField if value is not specify. 40 | """ 41 | mapper = ChoiceMapper({}) 42 | assert mapper.is_valid() 43 | assert mapper.data['type'] == 'male' 44 | 45 | 46 | def test_bad_default_value(): 47 | """ 48 | If `default` value is not in choices that mapper must be is not valid. 49 | """ 50 | 51 | class InnerMapper(Mapper): 52 | type = fields.ChoicesField( 53 | field_cls=fields.StringField, 54 | choices=gender_choices, 55 | default='wrong' 56 | ) 57 | 58 | mapper = InnerMapper({}) 59 | assert not mapper.is_valid() 60 | -------------------------------------------------------------------------------- /tests/mappers/fields/test_date_field.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from aiohttp_admin2.mappers import Mapper 4 | from aiohttp_admin2.mappers import fields 5 | 6 | 7 | class DateMapper(Mapper): 8 | field = fields.DateField() 9 | 10 | 11 | class DateTimeMapper(Mapper): 12 | field = fields.DateTimeField() 13 | 14 | 15 | def test_correct_date_type(): 16 | """In this test we cover success convert string to date type""" 17 | mapper = DateMapper({"field": "2021-07-12"}) 18 | assert mapper.is_valid() 19 | assert mapper.data['field'] == datetime.date(2021, 7, 12) 20 | 21 | 22 | def test_wrong_date_type(): 23 | """In this test we cover case with wrong date type""" 24 | assert not DateMapper({"field": 1}).is_valid() 25 | assert not DateMapper({"field": 'ddd'}).is_valid() 26 | 27 | 28 | def test_default_date_str_value(): 29 | """In this test we check that we can specify default value as str""" 30 | 31 | class DateMapperStr(Mapper): 32 | field = fields.DateField(default="2021-07-12") 33 | 34 | mapper = DateMapperStr({}) 35 | assert mapper.is_valid() 36 | assert mapper.data['field'] == datetime.date(2021, 7, 12) 37 | 38 | 39 | def test_default_date_date_object_value(): 40 | """In this test we check that we can specify default value as date""" 41 | 42 | class DateMapperStr(Mapper): 43 | field = fields.DateField(default=datetime.date(2021, 7, 12)) 44 | 45 | mapper = DateMapperStr({}) 46 | assert mapper.is_valid() 47 | assert mapper.data['field'] == datetime.date(2021, 7, 12) 48 | 49 | 50 | def test_correct_datetime_type(): 51 | """In this test we cover success convert string to datetime type""" 52 | mapper = DateTimeMapper({"field": "2021-07-12 09:44:49"}) 53 | assert mapper.is_valid() 54 | assert mapper.data['field'] == datetime.datetime(2021, 7, 12, 9, 44, 49) 55 | 56 | 57 | def test_wrong_datetime_type(): 58 | """In this test we cover case with wrong datetime type""" 59 | assert not DateTimeMapper({"field": 1}).is_valid() 60 | assert not DateTimeMapper({"field": 'ddd'}).is_valid() 61 | 62 | 63 | def test_default_datetime_str_value(): 64 | """In this test we check that we can specify default value as str""" 65 | 66 | class DateTimeMapperStr(Mapper): 67 | field = fields.DateTimeField(default="2021-07-12 09:44:49") 68 | 69 | mapper = DateTimeMapperStr({}) 70 | assert mapper.is_valid() 71 | assert mapper.data['field'] == datetime.datetime(2021, 7, 12, 9, 44, 49) 72 | 73 | 74 | def test_default_datetime_datetime_object_value(): 75 | """In this test we check that we can specify default value as datetime""" 76 | 77 | class DateTimeMapperStr(Mapper): 78 | field = fields\ 79 | .DateTimeField(default=datetime.datetime(2021, 7, 12, 9, 44, 49)) 80 | 81 | mapper = DateTimeMapperStr({}) 82 | assert mapper.is_valid() 83 | assert mapper.data['field'] == datetime.datetime(2021, 7, 12, 9, 44, 49) 84 | -------------------------------------------------------------------------------- /tests/mappers/fields/test_float_field.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.mappers import Mapper 2 | from aiohttp_admin2.mappers import fields 3 | 4 | 5 | class FloatMapper(Mapper): 6 | field = fields.FloatField() 7 | 8 | 9 | def test_correct_float_type(): 10 | """ 11 | In this test we check success convert to float type. 12 | """ 13 | mapper = FloatMapper({"field": 1}) 14 | mapper.is_valid() 15 | 16 | assert mapper.data["field"] == 1.0 17 | 18 | mapper = FloatMapper({"field": 2}) 19 | mapper.is_valid() 20 | 21 | assert mapper.data["field"] == 2.0 22 | 23 | mapper = FloatMapper({"field": -3}) 24 | mapper.is_valid() 25 | 26 | assert mapper.data["field"] == -3.0 27 | 28 | mapper = FloatMapper({"field": 0}) 29 | mapper.is_valid() 30 | 31 | assert mapper.data["field"] == 0.0 32 | 33 | 34 | def test_wrong_float_type(): 35 | """ 36 | In this test we check error when we received wrong float type. 37 | """ 38 | assert FloatMapper({"field": "string"}).is_valid() is False 39 | 40 | assert FloatMapper({"field": []}).is_valid() is False 41 | -------------------------------------------------------------------------------- /tests/mappers/fields/test_int_field.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiohttp_admin2.mappers import Mapper 4 | from aiohttp_admin2.mappers import fields 5 | 6 | 7 | class IntMapper(Mapper): 8 | field = fields.IntField() 9 | 10 | 11 | class SmallIntMapper(Mapper): 12 | field = fields.SmallIntField() 13 | 14 | 15 | @pytest.mark.parametrize('mapper_cls', [IntMapper, SmallIntMapper]) 16 | def test_correct_int_type(mapper_cls): 17 | """ 18 | In this test we check success convert to int type. 19 | """ 20 | mapper = mapper_cls({"field": 1}) 21 | mapper.is_valid() 22 | 23 | assert mapper.data["field"] == 1 24 | 25 | mapper = mapper_cls({"field": 2}) 26 | mapper.is_valid() 27 | 28 | assert mapper.data["field"] == 2 29 | 30 | mapper = mapper_cls({"field": -3}) 31 | mapper.is_valid() 32 | 33 | assert mapper.data["field"] == -3 34 | 35 | mapper = mapper_cls({"field": 0}) 36 | mapper.is_valid() 37 | 38 | assert mapper.data["field"] == 0 39 | 40 | mapper = mapper_cls({"field": 1.1}) 41 | mapper.is_valid() 42 | 43 | assert mapper.data["field"] == 1 44 | 45 | 46 | @pytest.mark.parametrize('mapper_cls', [IntMapper, SmallIntMapper]) 47 | def test_wrong_int_type(mapper_cls): 48 | """ 49 | In this test we check error when we received wrong int type. 50 | """ 51 | assert mapper_cls({"field": "string"}).is_valid() is False 52 | 53 | assert mapper_cls({"field": []}).is_valid() is False 54 | 55 | 56 | def test_small_int_validation(): 57 | """ 58 | Small int value must be in range from -32_768 to 32_767. In this test we 59 | check that. 60 | """ 61 | mapper = SmallIntMapper({"field": -32_769}) 62 | assert not mapper.is_valid() 63 | 64 | mapper = SmallIntMapper({"field": 32_768}) 65 | assert not mapper.is_valid() 66 | -------------------------------------------------------------------------------- /tests/mappers/fields/test_string_field.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiohttp_admin2.mappers import Mapper 4 | from aiohttp_admin2.mappers import fields 5 | 6 | 7 | class StringMapper(Mapper): 8 | field = fields.StringField() 9 | 10 | 11 | class LongStringFieldMapper(Mapper): 12 | field = fields.LongStringField() 13 | 14 | 15 | @pytest.mark.parametrize('mapper_cls', [StringMapper, LongStringFieldMapper]) 16 | def test_correct_str_type(mapper_cls): 17 | """ 18 | In this test we check success convert to str type. 19 | """ 20 | mapper = mapper_cls({"field": 1}) 21 | mapper.is_valid() 22 | 23 | assert mapper.data["field"] == '1' 24 | 25 | mapper = mapper_cls({"field": False}) 26 | mapper.is_valid() 27 | 28 | assert mapper.data["field"] == "False" 29 | 30 | mapper = mapper_cls({"field": -3}) 31 | mapper.is_valid() 32 | 33 | assert mapper.data["field"] == "-3" 34 | 35 | mapper = mapper_cls({"field": 0.0}) 36 | mapper.is_valid() 37 | 38 | assert mapper.data["field"] == "0.0" 39 | 40 | mapper = mapper_cls({"field": "string"}) 41 | mapper.is_valid() 42 | 43 | assert mapper.data["field"] == "string" 44 | 45 | mapper = mapper_cls({"field": ""}) 46 | mapper.is_valid() 47 | 48 | assert mapper.data["field"] == "" 49 | 50 | mapper = mapper_cls({"field": [1, 2]}) 51 | mapper.is_valid() 52 | 53 | assert mapper.data["field"] == "[1, 2]" 54 | -------------------------------------------------------------------------------- /tests/mappers/fields/test_url_field.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiohttp_admin2.mappers import Mapper 4 | from aiohttp_admin2.mappers import fields 5 | 6 | 7 | @pytest.mark.parametrize('input_data', [ 8 | "http://localhost:8000/some/path/to", 9 | "http://foo.com/some/path/to", 10 | "https://foo.com/some/path/to", 11 | "https://www.foo.com/some/path/to", 12 | "https://www.foo.com/some/path/to.file_format", 13 | "https://www.foo.com/some/path/to.file_format", 14 | ]) 15 | @pytest.mark.parametrize('field_cls', [ 16 | fields.UrlImageField, 17 | fields.UrlField, 18 | fields.UrlFileField, 19 | ]) 20 | def test_correct_url_type(field_cls, input_data): 21 | """ 22 | In this test we check success convert to url type. 23 | """ 24 | class InnerMapper(Mapper): 25 | field = field_cls() 26 | 27 | mapper = InnerMapper({"field": input_data}) 28 | 29 | assert mapper.is_valid() 30 | assert mapper.data["field"] == input_data 31 | 32 | 33 | @pytest.mark.parametrize('field_cls', [ 34 | fields.UrlImageField, 35 | fields.UrlField, 36 | fields.UrlFileField, 37 | ]) 38 | def test_wrong_url_type(field_cls): 39 | """ 40 | In this test we check handling of the wrong url type. 41 | """ 42 | class InnerMapper(Mapper): 43 | field = field_cls() 44 | 45 | mapper = InnerMapper({"field": 'wrong_url'}) 46 | assert not mapper.is_valid() 47 | -------------------------------------------------------------------------------- /tests/mappers/generics/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/tests/mappers/generics/__init__.py -------------------------------------------------------------------------------- /tests/mappers/test_mapper_inherit.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.mappers import Mapper 2 | from aiohttp_admin2.mappers import fields 3 | 4 | 5 | def test_mapper_inherit(): 6 | """ 7 | In this test we check correct work of combining fields by inherit base 8 | classes. 9 | 10 | 1. Test combine difference fields 11 | 2. Test rewrite existing fields 12 | """ 13 | # 1. Test combine difference fields 14 | class BaseMapper(Mapper): 15 | base_field = fields.StringField() 16 | 17 | class ChildClass(BaseMapper): 18 | child_field = fields.IntField() 19 | 20 | mapping = ChildClass({ 21 | "base_field": "value1", 22 | "child_field": 2 23 | }) 24 | 25 | assert len(mapping.fields) == 2 26 | 27 | assert isinstance(mapping.fields["base_field"], fields.StringField), \ 28 | "base_field must be StringField type" 29 | 30 | assert isinstance(mapping.fields["child_field"], fields.IntField), \ 31 | "child_field must be IntField type" 32 | 33 | # 2. Test rewrite existing fields 34 | class SecondChildClass(ChildClass): 35 | child_field = fields.StringField() 36 | 37 | mapping = SecondChildClass({ 38 | "base_field": "value1", 39 | "child_field": "value2" 40 | }) 41 | 42 | assert len(mapping.fields) == 2 43 | 44 | assert isinstance(mapping.fields["base_field"], fields.StringField), \ 45 | "base_field must be StringField type" 46 | 47 | assert isinstance(mapping.fields["child_field"], fields.StringField), \ 48 | "child_field must be StringField type" 49 | -------------------------------------------------------------------------------- /tests/mappers/validators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/tests/mappers/validators/__init__.py -------------------------------------------------------------------------------- /tests/mappers/validators/test_length.py: -------------------------------------------------------------------------------- 1 | """ 2 | In this file we cower corrected work of `length` validator. 3 | 4 | 1. If we don't have a parameterized validator, do nothing 5 | 2. If value more than max_value then raise an error 6 | 3. If value less than max_value then all is fine 7 | 4. If value less than min_value then raise an error 8 | 5. If value more than min_value then all is fine 9 | 6. Corrected usage with all specified parameters (min and max) 10 | """ 11 | import pytest 12 | 13 | from aiohttp_admin2.mappers.validators.length import length 14 | from aiohttp_admin2.mappers.exceptions import ValidationError 15 | 16 | 17 | def test_without_params(): 18 | """ 19 | 1. If we don't have a parameterized validator, do nothing 20 | """ 21 | length()(5) 22 | 23 | 24 | def test_more_than_max_value(): 25 | """ 26 | 2. If value more than max_value then raise an error 27 | """ 28 | with pytest.raises(ValidationError): 29 | length(max_value=1)([1, 2]) 30 | 31 | 32 | def test_less_than_max_value(): 33 | """ 34 | 3. If value less than max_value then all is fine 35 | """ 36 | length(max_value=2)([1, 2]) 37 | length(max_value=2)([1]) 38 | 39 | 40 | def test_less_than_min_value(): 41 | """ 42 | 4. If value less than min_value then raise an error 43 | """ 44 | with pytest.raises(ValidationError): 45 | length(min_value=1)([]) 46 | 47 | 48 | def test_more_than_min_value(): 49 | """ 50 | 5. If value more than min_value then all is fine 51 | """ 52 | length(min_value=1)([1, 2]) 53 | length(min_value=1)([1]) 54 | 55 | 56 | def test_all_params(): 57 | """ 58 | 6. Corrected usage with all specified parameters (min and max) 59 | """ 60 | length(min_value=1, max_value=3)([1, 2]) 61 | 62 | with pytest.raises(ValidationError): 63 | length(min_value=1, max_value=3)([]) 64 | 65 | with pytest.raises(ValidationError): 66 | length(min_value=1, max_value=3)([1, 2, 3, 4]) 67 | -------------------------------------------------------------------------------- /tests/mappers/validators/test_required.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiohttp_admin2.mappers.validators.required import required 4 | from aiohttp_admin2.mappers.exceptions import ValidationError 5 | 6 | 7 | def test_required(): 8 | with pytest.raises(ValidationError): 9 | required('') 10 | 11 | with pytest.raises(ValidationError): 12 | required(None) 13 | 14 | required(0) 15 | required(False) 16 | required('this') 17 | -------------------------------------------------------------------------------- /tests/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/tests/resources/__init__.py -------------------------------------------------------------------------------- /tests/resources/common_resource/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/tests/resources/common_resource/__init__.py -------------------------------------------------------------------------------- /tests/resources/common_resource/test_create.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiohttp_admin2.resources import Instance 4 | from aiohttp_admin2.resources import DictResource 5 | 6 | 7 | async def test_create_with_success(resource): 8 | """ 9 | In this test check corrected work of create method in resource. 10 | 11 | Success create instance 12 | """ 13 | 14 | obj = Instance() 15 | obj.data = {"val": 'test', "val2": "test2"} 16 | 17 | instance = await resource.create(obj) 18 | 19 | assert instance.data.val == 'test' 20 | assert instance.data.val2 == 'test2' 21 | 22 | assert instance.get_pk() 23 | 24 | 25 | async def test_create_success_with_partial_data(resource): 26 | """ 27 | In this test we check that instance have been created success without full 28 | values. 29 | """ 30 | obj = Instance() 31 | obj.data = {"val": 'test'} 32 | 33 | instance = await resource.create(obj) 34 | 35 | assert instance.get_pk() 36 | assert instance.data.val == 'test' 37 | assert getattr(instance.data, 'val2', None) is None 38 | 39 | 40 | async def test_create_with_error_if_data_no_precent_required_field(resource): 41 | """ 42 | In this test we check that instance will not be create if data don't have 43 | required fields. 44 | """ 45 | obj = Instance() 46 | obj.data = {"val2": 'test2'} 47 | 48 | if not isinstance(resource, DictResource): 49 | with pytest.raises(Exception): 50 | await resource.create(obj) 51 | 52 | 53 | async def test_create_with_error(resource): 54 | """ 55 | In this test check corrected work of create method in resource. 56 | 57 | Create instance with error: 58 | 59 | 1. Instance without data 60 | 2. Instance with empty values 61 | """ 62 | 63 | # 1. Instance without data 64 | if not isinstance(resource, DictResource): 65 | # dict resource doesn't raise any errors 66 | obj = Instance() 67 | 68 | with pytest.raises(Exception): 69 | await resource.create(obj) 70 | 71 | # 2. Instance with empty values 72 | if not isinstance(resource, DictResource): 73 | # dict resource doesn't raise any errors 74 | obj = Instance() 75 | obj.data = {} 76 | 77 | with pytest.raises(Exception): 78 | await resource.create(obj) 79 | -------------------------------------------------------------------------------- /tests/resources/common_resource/test_delete.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiohttp_admin2.resources.exceptions import InstanceDoesNotExist 4 | 5 | from .utils import generate_fake_instance 6 | 7 | 8 | async def test_delete(resource): 9 | """ 10 | In this test check corrected work of delete method in resource. 11 | 12 | 1. Get None for success delete object 13 | 2. Get exception for instance which does not exist 14 | """ 15 | # 1. Get None for success delete object 16 | instances = await generate_fake_instance(resource, 1) 17 | first_id = instances[0].get_pk() 18 | 19 | await resource.delete(first_id) 20 | 21 | # 2. Get exception for instance which does not exist 22 | with pytest.raises(InstanceDoesNotExist): 23 | await resource.delete(first_id) 24 | -------------------------------------------------------------------------------- /tests/resources/common_resource/test_get_many.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .utils import generate_fake_instance 4 | 5 | 6 | async def test_get_many(resource): 7 | """ 8 | In this test check corrected work of get_many method in resource. 9 | 10 | 1. Get existing instances 11 | 2. Get non existing instances 12 | 3. Get existing and non existing instances 13 | """ 14 | 15 | def get_len_not_none_values(d): 16 | return len([i for i in d.values() if i]) 17 | 18 | def assert_bad_response(response, ids_list): 19 | for key in ids_list: 20 | assert response[key].get_pk() == key 21 | 22 | # 1. Get existing instances 23 | instances = await generate_fake_instance( 24 | resource, 25 | # generate more then we need to check that we'll get correct items 26 | 6, 27 | ) 28 | ids = [i.get_pk() for i in instances[:4]] 29 | 30 | res = await resource.get_many(ids) 31 | 32 | assert get_len_not_none_values(res) == 4 33 | assert len(res) == 4 34 | assert_bad_response(res, ids) 35 | 36 | # 2. Get non existing instances 37 | await resource.delete(ids[0]) 38 | await resource.delete(ids[1]) 39 | 40 | res = await resource.get_many(ids[:2]) 41 | assert get_len_not_none_values(res) == 0 42 | # we need return dict with `None` values for corrected work 43 | assert len(res) == 2 44 | 45 | # 3. Get existing and non existing instances 46 | res = await resource.get_many(ids) 47 | 48 | assert get_len_not_none_values(res) == 2 49 | assert_bad_response(res, ids[2:]) 50 | assert len(res) == 4 51 | -------------------------------------------------------------------------------- /tests/resources/common_resource/test_get_one.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiohttp_admin2.resources.exceptions import InstanceDoesNotExist 4 | 5 | from .utils import generate_fake_instance 6 | 7 | 8 | async def test_get_one(resource): 9 | """ 10 | In this test check corrected work of get method in resource. 11 | 12 | 1. Get corrected instance by id 13 | 2. Get exception for instance which does not exist 14 | """ 15 | instances = await generate_fake_instance(resource, 2) 16 | 17 | # 1. Get corrected instance by id 18 | first_id = instances[0].get_pk() 19 | second_id = instances[1].get_pk() 20 | 21 | assert first_id and second_id 22 | 23 | first_instance = await resource.get_one(first_id) 24 | second_instance = await resource.get_one(second_id) 25 | 26 | assert first_id == first_instance.get_pk() 27 | assert second_id == second_instance.get_pk() 28 | 29 | # 2. Get exception of instance does not exist 30 | await resource.delete(second_id) 31 | 32 | with pytest.raises(InstanceDoesNotExist): 33 | await resource.get_one(second_id) 34 | 35 | 36 | async def test_get_one_correct_value_of_field(resource): 37 | """ 38 | In this test we check that get method return instance with right value. 39 | """ 40 | instances = await generate_fake_instance(resource, 1) 41 | 42 | value1 = instances[0].data.val 43 | value2 = instances[0].data.val2 44 | 45 | assert value1 46 | assert value2 47 | 48 | instance_from_db = await resource.get_one(instances[0].get_pk()) 49 | assert instance_from_db.data.val == value1 50 | assert instance_from_db.data.val2 == value2 51 | -------------------------------------------------------------------------------- /tests/resources/common_resource/test_name.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | async def test_name_property(resource): 5 | """ 6 | In this test check corrected generate name of resource. This name will use 7 | for admin interface so it's so important. 8 | """ 9 | assert resource.name 10 | -------------------------------------------------------------------------------- /tests/resources/common_resource/test_update.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiohttp_admin2.resources import DictResource 4 | 5 | from .utils import generate_fake_instance 6 | 7 | 8 | async def test_update(resource): 9 | """ 10 | In this test check corrected work of update method in resource. 11 | 12 | 1. Success update instance 13 | 2. Update instance with error (unknown field) 14 | """ 15 | 16 | # 1. Success update instance 17 | instances = await generate_fake_instance(resource, 1) 18 | instance = instances[0] 19 | 20 | instance.data.val = "new text" 21 | instance = await resource.update(instance.get_pk(), instance) 22 | 23 | assert instance.data.val == "new text" 24 | 25 | # 2. Update instance with error (unknown field) 26 | if not isinstance(resource, DictResource): 27 | instance.data = {"unknown_field": "unknown_field"} 28 | 29 | with pytest.raises(Exception): 30 | await resource.update(instance.get_pk(), instance) 31 | -------------------------------------------------------------------------------- /tests/resources/common_resource/utils.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from aiohttp_admin2.resources import Instance 3 | 4 | 5 | async def generate_fake_instance(resource, n: int = 1) -> t.List[Instance]: 6 | instances: t.List[Instance] = [] 7 | 8 | for i in range(n): 9 | obj = Instance() 10 | obj.data = {"val": f'val1 - {i}', 'val2': f'val2 - {i}'} 11 | 12 | instances.append(await resource.create(obj)) 13 | 14 | return instances 15 | -------------------------------------------------------------------------------- /tests/resources/postgres_resource/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/tests/resources/postgres_resource/__init__.py -------------------------------------------------------------------------------- /tests/resources/postgres_resource/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/tests/resources/postgres_resource/utils/__init__.py -------------------------------------------------------------------------------- /tests/resources/postgres_resource/utils/test_to_column.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sqlalchemy as sa 3 | 4 | from aiohttp_admin2.resources.postgres_resource.utils import to_column 5 | from aiohttp_admin2.resources.exceptions import ClientException 6 | 7 | 8 | table = sa.Table('test_table', sa.MetaData(), 9 | sa.Column('int', sa.Integer, primary_key=True), 10 | sa.Column('string', sa.String(255)), 11 | sa.Column('bool', sa.Boolean), 12 | sa.Column('array', sa.ARRAY(sa.Integer)), 13 | sa.Column('datetime', sa.DateTime), 14 | sa.Column('date', sa.Date), 15 | sa.Column('json', sa.JSON), 16 | sa.Column('text', sa.Text), 17 | ) 18 | 19 | 20 | @pytest.mark.parametrize('name, column', [ 21 | ('int', table.c['int']), 22 | ('string', table.c['string']), 23 | ('bool', table.c['bool']), 24 | ('array', table.c['array']), 25 | ('datetime', table.c['datetime']), 26 | ('date', table.c['date']), 27 | ('text', table.c['text']), 28 | ('json', table.c['json']), 29 | ]) 30 | def test_get_existing_field(name, column): 31 | """ 32 | In this test we check that to_column function success return a column from 33 | a table by the column's name 34 | """ 35 | assert to_column(name, table) == column 36 | 37 | 38 | def test_raise_an_error_if_column_does_not_exist(): 39 | """ 40 | In this test we check tha to_column function raise an error if table 41 | doesn't exist column with received name. 42 | """ 43 | with pytest.raises(ClientException): 44 | to_column("bad_field", table) 45 | -------------------------------------------------------------------------------- /tests/test_connection_injectors.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.connection_injectors import ConnectionInjector 2 | from aiohttp import web 3 | 4 | 5 | async def test_connection_injector(aiohttp_client): 6 | """ 7 | In this test we check corrected work of ConnectionInjector: 8 | 9 | 1. success init in aiohttp context 10 | 2. success inject into some decorated class 11 | """ 12 | 13 | connection_injector = ConnectionInjector() 14 | db_connection_string = "db_connection_string" 15 | 16 | # 1. success init in aiohttp context 17 | async def init_db(_): 18 | connection_injector.init(db_connection_string) 19 | yield 20 | 21 | @connection_injector.inject 22 | class TestController: 23 | pass 24 | 25 | application = web.Application() 26 | application.cleanup_ctx.append(init_db) 27 | 28 | await aiohttp_client(application) 29 | 30 | assert isinstance(TestController.connection_injector, ConnectionInjector) 31 | assert \ 32 | TestController.connection_injector.connection == db_connection_string 33 | -------------------------------------------------------------------------------- /tests/view/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arfey/aiohttp_admin2/d651f6a688e1263f661345d08b415a9ac7572900/tests/view/__init__.py -------------------------------------------------------------------------------- /tests/view/test_middleware_list.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | from aiohttp_admin2 import setup_admin 3 | from aiohttp_admin2.views import Admin 4 | 5 | from .utils import generate_new_admin_class 6 | 7 | 8 | async def index(request): 9 | return web.Response(text="Index") 10 | 11 | 12 | async def test_that_middleware_work_only_for_admin_pages(aiohttp_client): 13 | """ 14 | In this test we check success apply of middleware for admin interface. 15 | 16 | 1. Correct access for not admin page 17 | 2. Wrong access for admin page 18 | """ 19 | 20 | @web.middleware 21 | async def access(request, handler): 22 | raise web.HTTPForbidden() 23 | 24 | app = web.Application() 25 | app.add_routes([web.get('/', index)]) 26 | admin = generate_new_admin_class() 27 | setup_admin(app, middleware_list=[access, ], admin_class=admin) 28 | 29 | cli = await aiohttp_client(app) 30 | 31 | # 1. Correct access for not admin page 32 | res = await cli.get('/') 33 | 34 | assert res.status == 200 35 | 36 | # 2. Wrong access for admin page 37 | res = await cli.get(Admin.admin_url) 38 | 39 | assert res.status == 403 40 | 41 | 42 | 43 | async def test_that_admin_pages_are_available_if_pass_middleware(aiohttp_client): 44 | """ 45 | In this test we check success apply of middleware for admin interface. 46 | 47 | 1. Correct access for admin page 48 | """ 49 | 50 | @web.middleware 51 | async def access(request, handler): 52 | # to do nothing 53 | return await handler(request) 54 | 55 | app = web.Application() 56 | app.add_routes([web.get('/', index)]) 57 | admin = generate_new_admin_class() 58 | setup_admin(app, middleware_list=[access, ], admin_class=admin) 59 | 60 | cli = await aiohttp_client(app) 61 | 62 | res = await cli.get(Admin.admin_url) 63 | assert res.status == 200 64 | -------------------------------------------------------------------------------- /tests/view/test_setup.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | from aiohttp_admin2 import setup_admin 3 | from aiohttp_admin2.views import Admin 4 | from aiohttp_admin2.views import DashboardView 5 | 6 | from .utils import generate_new_admin_class 7 | 8 | 9 | async def test_setup_admin(aiohttp_client): 10 | """ 11 | In this test we check success setup of admin interface. 12 | """ 13 | app = web.Application() 14 | setup_admin(app, admin_class=generate_new_admin_class()) 15 | 16 | cli = await aiohttp_client(app) 17 | res = await cli.get(Admin.admin_url) 18 | 19 | assert res.status == 200 20 | 21 | 22 | async def test_setup_change_index_url(aiohttp_client): 23 | """ 24 | In this test we check correct work after change url to index page. 25 | """ 26 | class MyAdmin(generate_new_admin_class()): 27 | admin_url = '/my_url/' 28 | 29 | app = web.Application() 30 | setup_admin(app, admin_class=MyAdmin) 31 | 32 | cli = await aiohttp_client(app) 33 | res = await cli.get(MyAdmin.admin_url) 34 | 35 | assert res.status == 200 36 | assert DashboardView.name in await res.text() 37 | 38 | 39 | async def test_setup_with_custom_dashboard(aiohttp_client): 40 | """ 41 | In this test we check approach to change a start page. 42 | """ 43 | class MyDashboardView(DashboardView): 44 | index_url = '/new' 45 | name = 'index_new' 46 | title = 'dashboard_new' 47 | 48 | class MyAdmin(Admin): 49 | dashboard_class = MyDashboardView 50 | 51 | app = web.Application() 52 | setup_admin(app, admin_class=MyAdmin) 53 | 54 | url = app['aiohttp_admin'].router[MyDashboardView.name].url_for() 55 | 56 | assert str(url) == '/admin/new' 57 | 58 | cli = await aiohttp_client(app) 59 | res = await cli.get(MyAdmin.admin_url + 'new') 60 | 61 | assert res.status == 200 62 | assert MyDashboardView.name in await res.text() 63 | -------------------------------------------------------------------------------- /tests/view/utils.py: -------------------------------------------------------------------------------- 1 | from aiohttp_admin2.views import Admin 2 | from aiohttp_admin2.views import DashboardView 3 | 4 | __all__ = ["generate_new_admin_class", ] 5 | 6 | 7 | def generate_new_admin_class(): 8 | """ 9 | we need to generate a new dashboard view for each `setup_admin` call. 10 | """ 11 | class MockDashboard(DashboardView): 12 | pass 13 | 14 | class MockAdmin(Admin): 15 | dashboard_class = MockDashboard 16 | 17 | return MockAdmin 18 | --------------------------------------------------------------------------------