├── .codecov.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ └── feature-request.yml ├── dependabot.yml └── workflows │ ├── publish.yml │ └── test-suite.yml ├── .gitignore ├── .mkdocs.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── docs ├── api_reference │ ├── application.md │ ├── authentication.md │ ├── base_view.md │ └── model_view.md ├── assets │ └── images │ │ └── banner.png ├── authentication.md ├── configurations.md ├── cookbook │ ├── deployment_with_https.md │ ├── display_custom_attributes.md │ ├── multiple_databases.md │ ├── optimize_relationship_loading.md │ ├── using_request_object.md │ └── using_wysiwyg.md ├── index.md ├── stylesheets │ └── extra.css ├── working_with_files.md ├── working_with_templates.md └── writing_custom_views.md ├── pyproject.toml ├── sqladmin ├── __init__.py ├── _menu.py ├── _queries.py ├── _types.py ├── _validators.py ├── ajax.py ├── application.py ├── authentication.py ├── exceptions.py ├── fields.py ├── formatters.py ├── forms.py ├── helpers.py ├── models.py ├── pagination.py ├── py.typed ├── statics │ ├── css │ │ ├── flatpickr.min.css │ │ ├── fontawesome.min.css │ │ ├── main.css │ │ ├── select2.min.css │ │ └── tabler.min.css │ ├── js │ │ ├── bootstrap.min.js │ │ ├── flatpickr.min.js │ │ ├── jquery.min.js │ │ ├── main.js │ │ ├── popper.min.js │ │ ├── select2.full.min.js │ │ └── tabler.min.js │ └── webfonts │ │ ├── fa-brands-400.woff2 │ │ ├── fa-regular-400.woff2 │ │ └── fa-solid-900.woff2 ├── templates │ ├── _macros.html │ ├── base.html │ ├── create.html │ ├── details.html │ ├── edit.html │ ├── error.html │ ├── index.html │ ├── layout.html │ ├── list.html │ ├── login.html │ └── modals │ │ ├── delete.html │ │ ├── details_action_confirmation.html │ │ └── list_action_confirmation.html ├── templating.py ├── utils.py └── widgets.py └── tests ├── __init__.py ├── common.py ├── conftest.py ├── dont_test_file_upload.py ├── templates └── custom.html ├── test_ajax.py ├── test_application.py ├── test_authentication.py ├── test_base_view.py ├── test_fields.py ├── test_forms ├── __init__.py ├── test_forms.py └── test_multi_pk_forms.py ├── test_helpers.py ├── test_integrations ├── __init__.py ├── test_sqlalchemy_utils.py └── test_sqlmodel.py ├── test_menu.py ├── test_models.py ├── test_models_action.py ├── test_pagination.py └── test_views ├── __init__.py ├── test_multi_pk_view.py ├── test_view_async.py └── test_view_sync.py /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 1% 7 | 8 | comment: false 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report a bug to help improve this project 3 | body: 4 | - type: checkboxes 5 | id: checks 6 | attributes: 7 | label: Checklist 8 | description: Please make sure you check all these items before submitting your bug report. 9 | options: 10 | - label: The bug is reproducible against the latest release or `master`. 11 | required: true 12 | - label: There are no similar issues or pull requests to fix it yet. 13 | required: true 14 | - type: textarea 15 | id: describe 16 | attributes: 17 | label: Describe the bug 18 | description: A clear and concise description of what the bug is. 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: steps 23 | attributes: 24 | label: Steps to reproduce the bug 25 | description: | 26 | Provide a *minimal* example with steps to reproduce the bug locally. 27 | NOTE: try to keep any external dependencies *at an absolute minimum*. 28 | In other words, remove anything that doesn't make the bug go away. 29 | validations: 30 | required: false 31 | - type: textarea 32 | id: expected 33 | attributes: 34 | label: Expected behavior 35 | description: A clear and concise description of what you expected to happen. 36 | validations: 37 | required: false 38 | - type: textarea 39 | id: actual 40 | attributes: 41 | label: Actual behavior 42 | description: A clear and concise description of what actually happened. 43 | validations: 44 | required: false 45 | - type: textarea 46 | id: notes 47 | attributes: 48 | label: Debugging material 49 | description: | 50 | Any tracebacks, screenshots, etc. that can help understanding the problem. 51 | NOTE: 52 | - Please list tracebacks in full (don't truncate them). 53 | - Consider using `
` to make tracebacks/logs collapsible if they're very large (see https://gist.github.com/ericclemmons/b146fe5da72ca1f706b2ef72a20ac39d). 54 | validations: 55 | required: false 56 | - type: textarea 57 | id: environment 58 | attributes: 59 | label: Environment 60 | description: Describe your environment. 61 | placeholder: | 62 | - OS / Python / SQLAdmin version. 63 | validations: 64 | required: true 65 | - type: textarea 66 | id: additional 67 | attributes: 68 | label: Additional context 69 | description: | 70 | Any additional information that can help understanding the problem. 71 | Eg. linked issues, or a description of what you were trying to achieve. 72 | validations: 73 | required: false 74 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser 2 | blank_issues_enabled: false 3 | contact_links: 4 | - name: Questions 5 | url: https://github.com/aminalaee/sqladmin/discussions 6 | about: > 7 | The "Discussions" forum is where you want to start. 💖 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for a feature 3 | body: 4 | - type: checkboxes 5 | id: checks 6 | attributes: 7 | label: Checklist 8 | description: Please make sure you check all these items before submitting your feature request. 9 | options: 10 | - label: There are no similar issues or pull requests for this yet. 11 | required: true 12 | - type: textarea 13 | id: problem 14 | attributes: 15 | label: Is your feature related to a problem? Please describe. 16 | description: A clear and concise description of what you are trying to achieve. 17 | placeholder: I want to be able to [...] but I can't because [...] 18 | - type: textarea 19 | id: solution 20 | attributes: 21 | label: Describe the solution you would like. 22 | description: | 23 | A clear and concise description of what you would want to happen. 24 | For API changes, try to provide a code snippet of what you would like the API to look like. 25 | - type: textarea 26 | id: alternatives 27 | attributes: 28 | label: Describe alternatives you considered 29 | description: Please describe any alternative solutions or features you've considered to solve your problem and why they wouldn't solve it. 30 | - type: textarea 31 | id: additional 32 | attributes: 33 | label: Additional context 34 | description: Provide any additional context, screenshots, trace backs, etc. about the feature here. 35 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Publish 3 | 4 | on: 5 | push: 6 | tags: 7 | - '*' 8 | 9 | jobs: 10 | publish: 11 | name: "Publish release" 12 | runs-on: "ubuntu-latest" 13 | 14 | steps: 15 | - uses: "actions/checkout@v3" 16 | - uses: "actions/setup-python@v4" 17 | with: 18 | python-version: 3.9 19 | - name: "Install dependencies" 20 | run: pip install hatch 21 | - name: "Build package & docs" 22 | run: | 23 | hatch build 24 | # # hatch run docs:build 25 | - name: "Publish" 26 | run: | 27 | hatch publish --no-prompt 28 | # hatch run docs:deploy 29 | env: 30 | HATCH_INDEX_USER: __token__ 31 | HATCH_INDEX_AUTH: ${{ secrets.PYPI_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/test-suite.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test Suite 3 | 4 | on: 5 | push: 6 | branches: ["main"] 7 | pull_request: 8 | branches: ["main"] 9 | 10 | jobs: 11 | tests: 12 | name: "Python ${{ matrix.python-version }}" 13 | runs-on: "ubuntu-latest" 14 | 15 | strategy: 16 | matrix: 17 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 18 | 19 | services: 20 | postgres: 21 | image: postgres:14-alpine 22 | env: 23 | POSTGRES_USER: username 24 | POSTGRES_PASSWORD: password 25 | POSTGRES_DB: test_db 26 | ports: 27 | - 5432:5432 28 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 29 | 30 | steps: 31 | - uses: "actions/checkout@v3" 32 | - uses: "actions/setup-python@v4" 33 | with: 34 | python-version: "${{ matrix.python-version }}" 35 | - name: "Install dependencies" 36 | run: pip install hatch 37 | - name: "Run linting checks" 38 | run: hatch run lint:check 39 | - name: "Build package & docs" 40 | run: | 41 | hatch build 42 | hatch run docs:build 43 | - name: "Run tests with SQLite" 44 | env: 45 | TEST_DATABASE_URI_SYNC: "sqlite:///test.db?check_same_thread=False" 46 | TEST_DATABASE_URI_ASYNC: "sqlite+aiosqlite:///test.db?check_same_thread=False" 47 | run: hatch run test:test 48 | - name: "Run tests with PostgreSQL" 49 | env: 50 | TEST_DATABASE_URI_SYNC: "postgresql+psycopg2://username:password@localhost:5432/test_db" 51 | TEST_DATABASE_URI_ASYNC: "postgresql+asyncpg://username:password@localhost:5432/test_db" 52 | run: hatch run test:test 53 | - name: "Enforce coverage" 54 | run: hatch run test:cov 55 | - name: "Upload Coverage" 56 | uses: codecov/codecov-action@v3 57 | with: 58 | files: coverage.xml 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .env 4 | .venv/ 5 | venv/ 6 | __pycache__/ 7 | htmlcov/ 8 | .coverage 9 | .pytest_cache/ 10 | .mypy_cache/ 11 | coverage.xml 12 | examples/ 13 | .vscode/ 14 | .uploads 15 | test.db 16 | coverage.xml 17 | .ruff_cache/ -------------------------------------------------------------------------------- /.mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: SQLAlchemy Admin 2 | site_description: Flexible admin dashboard for SQLAlchemy. 3 | site_url: https://aminalaee.dev/sqladmin 4 | 5 | theme: 6 | name: 'material' 7 | palette: 8 | primary: white 9 | 10 | repo_name: aminalaee/sqladmin 11 | repo_url: https://github.com/aminalaee/sqladmin 12 | edit_uri: "" 13 | 14 | nav: 15 | - Introduction: 'index.md' 16 | - Configurations: 'configurations.md' 17 | - Authentication: 'authentication.md' 18 | - Working with Templates: 'working_with_templates.md' 19 | - Working with Custom Views: 'writing_custom_views.md' 20 | - Working with Files and Images: 'working_with_files.md' 21 | - Cookbook: 22 | - Deployment with HTTPS: 'cookbook/deployment_with_https.md' 23 | - Optimize relationship loading: 'cookbook/optimize_relationship_loading.md' 24 | - Display custom attributes: 'cookbook/display_custom_attributes.md' 25 | - Using a request object: 'cookbook/using_request_object.md' 26 | - Multiple databases: 'cookbook/multiple_databases.md' 27 | # - Structuring larger projects: 'cookbook/structuring_larger_projects.md' 28 | # - Admin home page: 'cookbook/admin_home_page.md' 29 | - API Reference: 30 | - Application: 'api_reference/application.md' 31 | - ModelView: 'api_reference/model_view.md' 32 | - BaseView: 'api_reference/base_view.md' 33 | - Authentication: 'api_reference/authentication.md' 34 | 35 | markdown_extensions: 36 | - markdown.extensions.codehilite: 37 | guess_lang: false 38 | - admonition 39 | - pymdownx.details 40 | - pymdownx.highlight 41 | - pymdownx.tabbed 42 | - pymdownx.superfences 43 | 44 | extra_css: 45 | - stylesheets/extra.css 46 | 47 | watch: 48 | - sqladmin/ 49 | 50 | plugins: 51 | - search 52 | - mkdocstrings: 53 | default_handler: python 54 | handlers: 55 | python: 56 | options: 57 | show_root_heading: true 58 | show_source: false 59 | 60 | extra: 61 | analytics: 62 | provider: google 63 | property: G-MV427T1Z9X 64 | social: 65 | - icon: fontawesome/brands/github 66 | link: https://github.com/aminalaee 67 | - icon: fontawesome/brands/twitter 68 | link: https://twitter.com/aminalaee 69 | - icon: fontawesome/brands/linkedin 70 | link: https://www.linkedin.com/in/amin-alaee 71 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every little bit 4 | helps, and credit will always be given. 5 | 6 | You can contribute in many ways: 7 | 8 | ## Types of Contributions 9 | 10 | ### Report Bugs 11 | 12 | Report bugs at https://github.com/aminalaee/sqladmin/issues. 13 | 14 | If you are reporting a bug, please include: 15 | 16 | * Your operating system name and version. 17 | * Any details about your local setup that might be helpful in troubleshooting. 18 | * Detailed steps to reproduce the bug. 19 | 20 | ### Fix Bugs 21 | 22 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 23 | wanted" is open to whoever wants to implement it. 24 | 25 | ### Implement Features 26 | 27 | Look through the GitHub issues for features. Anything tagged with "enhancement" 28 | and "help wanted" is open to whoever wants to implement it. 29 | 30 | ### Write Documentation 31 | 32 | SQLAdmin could always use more documentation, whether as part of the 33 | official SQLAdmin docs, in docstrings, or even on the web in blog posts, 34 | articles, and such. 35 | 36 | ### Submit Feedback 37 | 38 | The best way to send feedback is to file an issue at https://github.com/aminalaee/sqladmin. 39 | 40 | If you are proposing a feature: 41 | 42 | * Explain in detail how it would work. 43 | * Keep the scope as narrow as possible, to make it easier to implement. 44 | * Remember that this is a volunteer-driven project, and that contributions 45 | are welcome :) 46 | 47 | ## Get Started! 48 | 49 | Ready to contribute? Here's how to set up `sqladmin` for local development. 50 | 51 | 1. Fork the `sqladmin` repo on GitHub. 52 | 2. Clone your fork locally 53 | 54 | ``` 55 | $ git clone git@github.com:your_name_here/sqladmin.git 56 | ``` 57 | 58 | 3. Install [`Hatch`](https://hatch.pypa.io/latest/install/) for project management: 59 | 60 | ``` 61 | $ pip install hatch 62 | ``` 63 | 64 | 4. Create a branch for local development: 65 | 66 | ``` 67 | $ git checkout -b name-of-your-bugfix-or-feature 68 | ``` 69 | 70 | Now you can make your changes locally. 71 | 72 | 5. Apply linting and formatting, if not already done: 73 | 74 | ``` 75 | $ hatch run lint 76 | ``` 77 | 78 | 6. When you're done making changes, check that your changes pass the tests: 79 | 80 | ``` 81 | $ hatch run check 82 | $ hatch run test 83 | ``` 84 | 85 | 7. Commit your changes and push your branch to GitHub: 86 | 87 | ``` 88 | $ git add . 89 | $ git commit -m "Your detailed description of your changes." 90 | $ git push origin name-of-your-bugfix-or-feature 91 | ``` 92 | 93 | 8. Submit a pull request through the GitHub website. 94 | 95 | ## Pull Request Guidelines 96 | 97 | Before you submit a pull request, check that it meets these guidelines: 98 | 99 | 1. The pull request should include tests. 100 | 2. If the pull request adds functionality, the docs should be updated. Put 101 | your new functionality into a function with a docstring, and add the 102 | feature to the list in README.md. 103 | 3. The pull request should work for Python 3.7, 3.8, 3.9 and 3.10. Check 104 | https://github.com/aminalaee/sqladmin/actions 105 | and make sure that the tests pass for all supported Python versions. 106 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2022, Amin Alaee. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Use plugin instead 2 | https://github.com/peterschutt/sqladmin-litestar-plugin 3 | Peter from Litestar has a much easier to maintain plugin for SQLAdmin for Litestar. 4 | 5 | 6 |

7 | 8 | 9 | 10 |

11 | 12 |

13 | 14 | Build Status 15 | 16 | 17 | Publish Status 18 | 19 | 20 | Coverage 21 | 22 | 23 | Package version 24 | 25 | 26 | Supported Python versions 27 | 28 |

29 | 30 | --- 31 | 32 | 33 | 34 | # SQLAlchemy Admin for Litestar 35 | 36 | SQLAdmin is a flexible Admin interface for SQLAlchemy models. 37 | 38 | Main features include: 39 | 40 | * [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy) sync/async engines 41 | * [Litestar](https://github.com/litestar-org/litestar) integration 42 | * [WTForms](https://github.com/wtforms/wtforms) form building 43 | * [SQLModel](https://github.com/tiangolo/sqlmodel) support 44 | * UI using [Tabler](https://github.com/tabler/tabler) 45 | 46 | --- 47 | 48 | **Documentation**: [https://aminalaee.dev/sqladmin](https://aminalaee.dev/sqladmin) 49 | 50 | **Source Code**: [https://github.com/cemrehancavdar/sqladmin-litestar](https://github.com/cemrehancavdar/sqladmin-litestar) 51 | 52 | **Original Source Code**: [https://github.com/aminalaee/sqladmin](https://github.com/aminalaee/sqladmin) 53 | 54 | **Online Demo**: [Demo](https://sqladmin-demo.aminalaee.dev/admin/) 55 | 56 | --- 57 | 58 | ## Installation 59 | 60 | Install using `pip`: 61 | 62 | ```shell 63 | $ pip install sqladmin-litestar 64 | ``` 65 | 66 | This will install the full version of sqladmin with optional dependencies: 67 | 68 | ```shell 69 | $ pip install "sqladmin-litestar[full]" 70 | ``` 71 | 72 | --- 73 | 74 | ## Screenshots 75 | 76 | sqladmin-1 77 | sqladmin-2 78 | 79 | ## Quickstart 80 | 81 | Let's define an example SQLAlchemy model: 82 | 83 | ```python 84 | from sqlalchemy import Column, Integer, String, create_engine 85 | from sqlalchemy.orm import declarative_base 86 | 87 | 88 | Base = declarative_base() 89 | engine = create_engine( 90 | "sqlite:///example.db", 91 | connect_args={"check_same_thread": False}, 92 | ) 93 | 94 | 95 | class User(Base): 96 | __tablename__ = "users" 97 | 98 | id = Column(Integer, primary_key=True) 99 | name = Column(String) 100 | 101 | 102 | Base.metadata.create_all(engine) # Create tables 103 | ``` 104 | 105 | If you want to use `SQLAdmin` with `Litestar`: 106 | 107 | ```python 108 | from litestar import Litestar 109 | from sqladmin import Admin, ModelView 110 | 111 | 112 | app = Litestar() 113 | admin = Admin(app, engine) 114 | 115 | 116 | class UserAdmin(ModelView, model=User): 117 | column_list = [User.id, User.name] 118 | 119 | 120 | admin.add_view(UserAdmin) 121 | ``` 122 | 123 | Now visiting `/admin` on your browser you can see the `SQLAdmin` interface. 124 | 125 | 126 | ## Differences between SQLAdmin and SQLAdmin-Litestar 127 | 128 | 1) Working with Custom Views 129 | 130 | SQLAdmin-Litestar templates works sync, and handlers should be annotated. 131 | ``` 132 | @expose("/report", methods=["GET"]) 133 | async def report_page(self, request): 134 | return await self.templates.TemplateResponse(request, "report.html") 135 | ``` 136 | should be changed to 137 | ``` 138 | @expose("/report", methods=["GET"]) 139 | async def report_page(self, request) -> str: 140 | return self.templates.TemplateResponse(request, "report.html") 141 | ``` 142 | 143 | 144 | ## Related projects and inspirations 145 | * [SQLAdmin](https://github.com/aminalaee/sqladmin) The original SQLAdmin Repository 146 | * [Flask-Admin](https://github.com/flask-admin/flask-admin) Admin interface for Flask supporting different database backends and ORMs. This project has inspired SQLAdmin extensively and most of the features and configurations are implemented the same. 147 | * [FastAPI-Admin](https://github.com/fastapi-admin/fastapi-admin) Admin interface for FastAPI which works with `TortoiseORM`. 148 | * [Dashboard](https://github.com/encode/dashboard) Admin interface for ASGI frameworks which works with the `orm` package. 149 | -------------------------------------------------------------------------------- /docs/api_reference/application.md: -------------------------------------------------------------------------------- 1 | ::: sqladmin.application.Admin 2 | handler: python 3 | options: 4 | members: 5 | - __init__ 6 | 7 | ::: sqladmin.application.BaseAdmin 8 | handler: python 9 | options: 10 | members: 11 | - views 12 | - add_view 13 | - add_model_view 14 | - add_base_view 15 | 16 | ::: sqladmin.application.action 17 | handler: python 18 | -------------------------------------------------------------------------------- /docs/api_reference/authentication.md: -------------------------------------------------------------------------------- 1 | ::: sqladmin.authentication.AuthenticationBackend 2 | handler: python 3 | options: 4 | members: 5 | - __init__ 6 | - authenticate 7 | - login 8 | - logout 9 | -------------------------------------------------------------------------------- /docs/api_reference/base_view.md: -------------------------------------------------------------------------------- 1 | ::: sqladmin.models.BaseView 2 | handler: python 3 | options: 4 | members: 5 | - name 6 | - identity 7 | - methods 8 | - icon 9 | - include_in_schema 10 | -------------------------------------------------------------------------------- /docs/api_reference/model_view.md: -------------------------------------------------------------------------------- 1 | ::: sqladmin.models.ModelView 2 | handler: python 3 | options: 4 | members: 5 | - name 6 | - name_plural 7 | - icon 8 | - category 9 | - column_labels 10 | - can_create 11 | - can_edit 12 | - can_delete 13 | - can_view_details 14 | - column_list 15 | - column_exclude_list 16 | - column_formatters 17 | - column_formatters_detail 18 | - page_size 19 | - page_size_options 20 | - column_details_list 21 | - column_details_exclude_list 22 | - index_template 23 | - list_template 24 | - create_template 25 | - details_template 26 | - edit_template 27 | - is_visible 28 | - is_accessible 29 | - column_searchable_list 30 | - search_placeholder 31 | - column_sortable_list 32 | - column_default_sort 33 | - can_export 34 | - column_export_list 35 | - column_export_exclude_list 36 | - export_types 37 | - export_max_rows 38 | - form 39 | - form_args 40 | - form_columns 41 | - form_excluded_columns 42 | - form_overrides 43 | - form_widget_args 44 | - form_include_pk 45 | - form_ajax_refs 46 | - form_converter 47 | - column_type_formatters 48 | - list_query 49 | - count_query 50 | - search_query 51 | - sort_query 52 | - on_model_change 53 | - after_model_change 54 | - on_model_delete 55 | - after_model_delete 56 | - save_as 57 | - save_as_continue 58 | -------------------------------------------------------------------------------- /docs/assets/images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cemrehancavdar/sqladmin-litestar/29e2a23ba08bb04a3fc6e6c5598dc472ea5f9932/docs/assets/images/banner.png -------------------------------------------------------------------------------- /docs/authentication.md: -------------------------------------------------------------------------------- 1 | SQLadmin does not enforce any authentication to your application, 2 | but provides an optional `AuthenticationBackend` you can use. 3 | 4 | ## AuthenticationBackend 5 | 6 | SQLAdmin has a session-based authentication that will allow you 7 | to integrate any existing authentication to it. 8 | 9 | The class `AuthenticationBackend` has three methods you need to override: 10 | 11 | * `authenticate`: Will be called for validating each incoming request. 12 | * `login`: Will be called only in the login page to validate username/password. 13 | * `logout`: Will be called only for the logout, usually clearin the session. 14 | 15 | ```python 16 | from sqladmin import Admin 17 | from sqladmin.authentication import AuthenticationBackend 18 | from starlette.requests import Request 19 | from starlette.responses import RedirectResponse 20 | 21 | 22 | class AdminAuth(AuthenticationBackend): 23 | async def login(self, request: Request) -> bool: 24 | form = await request.form() 25 | username, password = form["username"], form["password"] 26 | 27 | # Validate username/password credentials 28 | # And update session 29 | request.session.update({"token": "..."}) 30 | 31 | return True 32 | 33 | async def logout(self, request: Request) -> bool: 34 | # Usually you'd want to just clear the session 35 | request.session.clear() 36 | return True 37 | 38 | async def authenticate(self, request: Request) -> bool: 39 | token = request.session.get("token") 40 | 41 | if not token: 42 | return False 43 | 44 | # Check the token in depth 45 | return True 46 | 47 | 48 | authentication_backend = AdminAuth(secret_key="...") 49 | admin = Admin(app=..., authentication_backend=authentication_backend، ...) 50 | ``` 51 | 52 | !!! note 53 | In order to use AuthenticationBackend you need to install the `itsdangerous` package. 54 | 55 | ??? example "Full Example" 56 | 57 | ```python 58 | from sqladmin import Admin, ModelView 59 | from sqladmin.authentication import AuthenticationBackend 60 | from sqlalchemy import Column, Integer, String, create_engine 61 | from sqlalchemy.orm import declarative_base 62 | from starlette.applications import Starlette 63 | from starlette.requests import Request 64 | from starlette.responses import RedirectResponse 65 | 66 | 67 | Base = declarative_base() 68 | engine = create_engine( 69 | "sqlite:///example.db", 70 | connect_args={"check_same_thread": False}, 71 | ) 72 | 73 | 74 | class User(Base): 75 | __tablename__ = "users" 76 | 77 | id = Column(Integer, primary_key=True) 78 | name = Column(String) 79 | 80 | 81 | Base.metadata.create_all(engine) 82 | 83 | 84 | class AdminAuth(AuthenticationBackend): 85 | async def login(self, request: Request) -> bool: 86 | request.session.update({"token": "..."}) 87 | return True 88 | 89 | async def logout(self, request: Request) -> bool: 90 | request.session.clear() 91 | return True 92 | 93 | async def authenticate(self, request: Request) -> bool: 94 | token = request.session.get("token") 95 | 96 | if not token: 97 | return False 98 | 99 | # Check the token in depth 100 | return True 101 | 102 | 103 | app = Starlette() 104 | authentication_backend = AdminAuth(secret_key="...") 105 | admin = Admin(app=app, engine=engine, authentication_backend=authentication_backend) 106 | 107 | 108 | class UserAdmin(ModelView, model=User): 109 | def is_visible(self, request: Request) -> bool: 110 | return True 111 | 112 | def is_accessible(self, request: Request) -> bool: 113 | return True 114 | 115 | 116 | admin.add_view(UserAdmin) 117 | ``` 118 | 119 | ## Using OAuth 120 | 121 | You can also integrate OAuth into SQLAdmin, for this example we will integrate Google OAuth using `Authlib`. 122 | If you have followed the previous example, there are only two changes required to the authentication flow: 123 | 124 | ```python 125 | from typing import Union 126 | 127 | from authlib.integrations.starlette_client import OAuth 128 | from sqladmin.authentication import AuthenticationBackend 129 | from starlette.applications import Starlette 130 | from starlette.middleware.sessions import SessionMiddleware 131 | from starlette.requests import Request 132 | from starlette.responses import RedirectResponse 133 | 134 | 135 | app = Starlette() 136 | app.add_middleware(SessionMiddleware, secret_key="test") 137 | 138 | oauth = OAuth() 139 | oauth.register( 140 | 'google', 141 | client_id='...', 142 | client_secret='...', 143 | server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', 144 | client_kwargs={ 145 | 'scope': 'openid email profile', 146 | 'prompt': 'select_account', 147 | }, 148 | ) 149 | google = oauth.create_client('google') 150 | 151 | 152 | class AdminAuth(AuthenticationBackend): 153 | async def login(self, request: Request) -> bool: 154 | return True 155 | 156 | async def logout(self, request: Request) -> bool: 157 | request.session.clear() 158 | return True 159 | 160 | async def authenticate(self, request: Request) -> Union[bool, RedirectResponse]: 161 | user = request.session.get("user") 162 | if not user: 163 | redirect_uri = request.url_for('login_google') 164 | return await google.authorize_redirect(request, redirect_uri) 165 | 166 | return True 167 | 168 | 169 | admin = Admin(app=app, engine=engine, authentication_backend=AdminAuth("test")) 170 | 171 | 172 | @admin.app.route("/auth/google") 173 | async def login_google(request: Request) -> Response: 174 | token = await google.authorize_access_token(request) 175 | user = token.get('userinfo') 176 | if user: 177 | request.session['user'] = user 178 | return RedirectResponse(request.url_for("admin:index")) 179 | ``` 180 | 181 | ## Permissions 182 | 183 | The `ModelView` and `BaseView` classes in SQLAdmin implements two special methods you can override. 184 | You can use these methods to have control over each Model/View in addition to the AuthenticationBackend. 185 | So this is more like checking if the user has access to the specific Model or View. 186 | 187 | * `is_visible` 188 | * `is_accessible` 189 | 190 | As you might guess the `is_visible` controls if this Model/View 191 | should be displayed in the menu or not. 192 | 193 | The `is_accessible` controls if this Model/View should be accessed. 194 | 195 | Both methods implement the same signature and should return a boolean. 196 | 197 | !!! note 198 | For Model/View to be displayed in the sidebar both `is_visible` 199 | and `is_accessible` should return `True`. 200 | 201 | So in order to override these methods: 202 | 203 | ```python 204 | from starlette.requests import Request 205 | 206 | 207 | class UserAdmin(ModelView, model=User): 208 | def is_accessible(self, request: Request) -> bool: 209 | # Check incoming request 210 | # For example request.session if using AuthenticationBackend 211 | return True 212 | 213 | def is_visible(self, request: Request) -> bool: 214 | # Check incoming request 215 | # For example request.session if using AuthenticationBackend 216 | return True 217 | ``` 218 | -------------------------------------------------------------------------------- /docs/configurations.md: -------------------------------------------------------------------------------- 1 | SQLAdmin configuration options are heavily inspired by the Flask-Admin project. 2 | 3 | This page will give you a basic introduction and for all the details 4 | you can visit [API Reference](./api_reference/model_view.md). 5 | 6 | Let's say you've defined your SQLAlchemy models like this: 7 | 8 | ```python 9 | from sqlalchemy import Column, Integer, String, create_engine 10 | from sqlalchemy.orm import declarative_base 11 | 12 | 13 | Base = declarative_base() 14 | engine = create_engine( 15 | "sqlite:///example.db", 16 | connect_args={"check_same_thread": False}, 17 | ) 18 | 19 | 20 | class User(Base): 21 | __tablename__ = "users" 22 | 23 | id = Column(Integer, primary_key=True) 24 | name = Column(String) 25 | email = Column(String) 26 | 27 | 28 | Base.metadata.create_all(engine) # Create tables 29 | ``` 30 | 31 | If you want to integrate SQLAdmin into FastAPI application: 32 | 33 | ```python 34 | from fastapi import FastAPI 35 | from sqladmin import Admin, ModelView 36 | 37 | 38 | app = FastAPI() 39 | admin = Admin(app, engine) 40 | 41 | 42 | class UserAdmin(ModelView, model=User): 43 | column_list = [User.id, User.name] 44 | 45 | 46 | admin.add_view(UserAdmin) 47 | ``` 48 | 49 | As you can see the `UserAdmin` class inherits from `ModelView` and accepts some configurations. 50 | 51 | ## Permissions 52 | 53 | You can configure a few general permissions for this model. 54 | The following options are available: 55 | 56 | * `can_create`: If the model can create new instances via SQLAdmin. Default value is `True`. 57 | * `can_edit`: If the model instances can be edited via SQLAdmin. Default value is `True`. 58 | * `can_delete`: If the model instances can be deleted via SQLAdmin. Default value is `True`. 59 | * `can_view_details`: If the model instance details can be viewed via SQLAdmin. Default value is `True`. 60 | * `can_export`: If the model data can be exported in the list page. Default value is `True`. 61 | 62 | !!! example 63 | 64 | ```python 65 | class UserAdmin(ModelView, model=User): 66 | can_create = True 67 | can_edit = True 68 | can_delete = False 69 | can_view_details = True 70 | ``` 71 | 72 | ## Metadata 73 | 74 | The metadata for the model. The options are: 75 | 76 | * `name`: Display name for this model. Default value is the class name. 77 | * `name_plural`: Display plural name for this model. Default value is class name + `s`. 78 | * `icon`: Icon to be displayed for this model in the admin. Only FontAwesome names are supported. 79 | * `category`: Category name to display group of `ModelView` classes together in dropdown. 80 | 81 | !!! example 82 | 83 | ```python 84 | class UserAdmin(ModelView, model=User): 85 | name = "User" 86 | name_plural = "Users" 87 | icon = "fa-solid fa-user" 88 | category = "accounts" 89 | ``` 90 | 91 | ## List page 92 | 93 | These options allow configurations in the list page, in the case of this example 94 | where you can view list of User records. 95 | 96 | The options available are: 97 | 98 | * `column_list`: List of columns or column names to be displayed in the list page. 99 | * `column_exclude_list`: List of columns or column names to be excluded in the list page. 100 | * `column_formatters`: Dictionary of column formatters in the list page. 101 | * `column_searchable_list`: List of columns or column names to be searchable in the list page. 102 | * `column_sortable_list`: List of columns or column names to be sortable in the list page. 103 | * `column_default_sort`: Default sorting if no sorting is applied, tuple of (column, is_descending) 104 | or list of the tuple for multiple columns. 105 | * `list_query`: A method with the signature of `(request) -> stmt` which can customize the list query. 106 | * `count_query`: A method with the signature of `(request) -> stmt` which can customize the count query. 107 | * `search_query`: A method with the signature of `(stmt, term) -> stmt` which can customize the search query. 108 | 109 | !!! example 110 | 111 | ```python 112 | class UserAdmin(ModelView, model=User): 113 | column_list = [User.id, User.name, "user.address.zip_code"] 114 | column_searchable_list = [User.name] 115 | column_sortable_list = [User.id] 116 | column_formatters = {User.name: lambda m, a: m.name[:10]} 117 | column_default_sort = [(User.email, True), (User.name, False)] 118 | ``` 119 | 120 | 121 | !!! tip 122 | 123 | You can use the special keyword `"__all__"` in `column_list` or `column_details_list` 124 | if you don't want to specify all the columns manually. For example: `column_list = "__all__"` 125 | 126 | ## Details page 127 | 128 | These options allow configurations in the details page, in the case of this example 129 | where you can view details of a single User. 130 | 131 | The options available are: 132 | 133 | * `column_details_list`: List of columns or column names to be displayed in the details page. 134 | * `column_details_exclude_list`: List of columns or column names to be excluded in the details page. 135 | * `column_formatters_detail`: Dictionary of column formatters in the details page. 136 | 137 | !!! example 138 | 139 | ```python 140 | class UserAdmin(ModelView, model=User): 141 | column_details_list = [User.id, User.name, "user.address.zip_code"] 142 | column_formatters_detail = {User.name: lambda m, a: m.name[:10]} 143 | ``` 144 | 145 | !!! tip 146 | 147 | You can show related model's attributes by using a string value. For example 148 | "user.address.zip_code" will load the relationship but it will trigger extra queries for each 149 | relationship loading. 150 | 151 | ## Pagination options 152 | 153 | The pagination options in the list page can be configured. The available options include: 154 | 155 | * `page_size`: Default page size in pagination. Default is `10`. 156 | * `page_size_options`: Pagination selector options. Default is `[10, 25, 50, 100]`. 157 | 158 | !!! example 159 | 160 | ```python 161 | class UserAdmin(ModelView, model=User): 162 | page_size = 50 163 | page_size_options = [25, 50, 100, 200] 164 | ``` 165 | 166 | ## General options 167 | 168 | There are a few options which apply to both List and Detail pages. They include: 169 | 170 | * `column_labels`: A mapping of column labels, used to map column names to new names in all places. 171 | * `column_type_formatters`: A mapping of type keys and callable values to format in all places. 172 | For example you can add custom date formatter to be used in both list and detail pages. 173 | * `save_as`: A boolean to enable "save as new" option when editing an object. 174 | * `save_as_continue`: A boolean to control the redirect URL if `save_as` is enabled. 175 | 176 | !!! example 177 | 178 | ```python 179 | class UserAdmin(ModelView, model=User): 180 | def date_format(value): 181 | return value.strftime("%d.%m.%Y") 182 | 183 | column_labels = {User.mail: "Email"} 184 | column_type_formatters = dict(ModelView.column_type_formatters, date=date_format) 185 | save_as = True 186 | ``` 187 | 188 | ## Form options 189 | 190 | SQLAdmin allows customizing how forms work with your models. 191 | The forms are based on `WTForms` package and include the following options: 192 | 193 | * `form`: Default form to be used for creating or editing the model. Default value is `None` and form is created dynamically. 194 | * `form_base_class`: Default base class for creating forms. Default value is `wtforms.Form`. 195 | * `form_args`: Dictionary of form field arguments supported by WTForms. 196 | * `form_widget_args`: Dictionary of form widget rendering arguments supported by WTForms. 197 | * `form_columns`: List of model columns to be included in the form. Default is all model columns. 198 | * `form_excluded_columns`: List of model columns to be excluded from the form. 199 | * `form_overrides`: Dictionary of form fields to override when creating the form. 200 | * `form_include_pk`: Control if primary key column should be included in create/edit forms. Default is `False`. 201 | * `form_ajax_refs`: Use Ajax with Select2 for loading relationship models async. This is use ful when the related model has a lot of records. 202 | * `form_converter`: Allow adding custom converters to support additional column types. 203 | 204 | !!! example 205 | 206 | ```python 207 | class UserAdmin(ModelView, model=User): 208 | form_columns = [User.name] 209 | form_args = dict(name=dict(label="Full name")) 210 | form_widget_args = dict(email=dict(readonly=True)) 211 | form_overrides = dict(email=wtforms.EmailField) 212 | form_include_pk = True 213 | form_ajax_refs = { 214 | "address": { 215 | "fields": ("zip_code", "street"), 216 | "order_by": ("id",), 217 | } 218 | } 219 | ``` 220 | 221 | ## Export options 222 | 223 | SQLAdmin supports exporting data in the list page. Currently only CSV export is supported. 224 | The export options can be set per model and includes the following options: 225 | 226 | * `can_export`: If the model can be exported. Default value is `True`. 227 | * `column_export_list`: List of columns to include in the export data. Default is all model columns. 228 | * `column_export_exclude_list`: List of columns to exclude in the export data. 229 | * `export_max_rows`: Maximum number of rows to be exported. Default value is `0` which means unlimited. 230 | * `export_types`: List of export types to be enabled. Default value is `["csv"]`. 231 | 232 | ## Templates 233 | 234 | The template files are built using Jinja2 and can be completely overridden in the configurations. 235 | The pages available are: 236 | 237 | * `list_template`: Template to use for models list page. Default is `list.html`. 238 | * `create_template`: Template to use for model creation page. Default is `create.html`. 239 | * `details_template`: Template to use for model details page. Default is `details.html`. 240 | * `edit_template`: Template to use for model edit page. Default is `edit.html`. 241 | 242 | !!! example 243 | 244 | ```python 245 | class UserAdmin(ModelView, model=User): 246 | list_template = "custom_list.html" 247 | ``` 248 | 249 | For more information about working with template see [Working with Templates](./working_with_templates.md). 250 | 251 | ## Events 252 | 253 | There might be some cases which you want to do some actions 254 | before or after a model was created, updated or deleted. 255 | 256 | There are four methods you can override to achieve this: 257 | 258 | * `on_model_change`: Called before a model was created or updated. 259 | * `after_model_change`: Called after a model was created or updated. 260 | * `on_model_delete`: Called before a model was deleted. 261 | * `after_model_delete`: Called after a model was deleted. 262 | 263 | By default these methods do nothing. 264 | 265 | !!! example 266 | 267 | ```python 268 | class UserAdmin(ModelView, model=User): 269 | async def on_model_change(self, data, model, is_created, request): 270 | # Perform some other action 271 | ... 272 | 273 | async def on_model_delete(self, model, request): 274 | # Perform some other action 275 | ... 276 | ``` 277 | 278 | ## Custom Action 279 | 280 | To add custom action on models to the Admin, you can use the `action` decorator. 281 | 282 | !!! example 283 | 284 | ```python 285 | from sqladmin import BaseView, action 286 | 287 | class UserAdmin(ModelView, model=User): 288 | @action( 289 | name="approve_users", 290 | label="Approve", 291 | confirmation_message="Are you sure?", 292 | add_in_detail=True, 293 | add_in_list=True, 294 | ) 295 | async def approve_users(self, request: Request): 296 | pks = request.query_params.get("pks", "").split(",") 297 | if pks: 298 | for pk in pks: 299 | model: User = await self.get_object_for_edit(pk) 300 | ... 301 | 302 | referer = request.headers.get("Referer") 303 | if referer: 304 | return RedirectResponse(referer) 305 | else: 306 | return RedirectResponse(request.url_for("admin:list", identity=self.identity)) 307 | 308 | admin.add_view(UserAdmin) 309 | ``` 310 | 311 | 312 | The available options for `action` are: 313 | 314 | * `name`: A string name to be used in URL for this action. 315 | * `label`: A string for describing this action. 316 | * `add_in_list`: A boolean indicating if this action should be available in list page. 317 | * `add_in_detail`: A boolean indicating if this action should be available in detail page. 318 | * `confirmation_message`: A string message that if defined, will open a modal to ask for confirmation before calling the action method. 319 | -------------------------------------------------------------------------------- /docs/cookbook/deployment_with_https.md: -------------------------------------------------------------------------------- 1 | It is common and useful to deploy your `SQLAdmin` or FastAPI/Starlette application 2 | behind a reverse proxy like Nginx and enable `HTTPS` on the reverse proxy. 3 | 4 | Running the app locally you would not face any issues but with HTTPS enabled 5 | behind the reverse proxy you might see errors like this in your browser developer console: 6 | 7 | ``` 8 | Mixed Content: The page at '' was loaded over HTTPS, but requested an insecure script ''. This request has been blocked; the content must be served over HTTPS. 9 | ``` 10 | 11 | This means the CSS and Javascript files for the Admin were not loaded properly. 12 | This is not exactly related to the `SQLAdmin` but more related to how 13 | you are deploying your project. 14 | 15 | For example if you are using `Uvicorn` as your ASGI server you can add the following options 16 | to solve this issue: 17 | 18 | - `--forwarded-allow-ips='*'` 19 | - `--proxy-headers` 20 | 21 | So it would be : 22 | 23 | ```shell 24 | uvicorn : --forwarded-allow-ips='*' --proxy-headers 25 | ``` 26 | 27 | You can find more information and full docs for this at `Uvicorn` website 28 | here at [ running behind nginx](https://www.uvicorn.org/deployment/#running-behind-nginx). 29 | -------------------------------------------------------------------------------- /docs/cookbook/display_custom_attributes.md: -------------------------------------------------------------------------------- 1 | If you need to display a custom attribute of your model, 2 | or a calculated attribute or property which is not direclty from the database, 3 | it is possible out of the box with `SQLAdmin`. 4 | 5 | Let's see an example model: 6 | 7 | ```py 8 | class User(Base): 9 | __tablename__ = "user" 10 | 11 | id = mapped_column(Integer, primary_key=True) 12 | first_name = mapped_column(String) 13 | last_name = mapped_column(String) 14 | 15 | @property 16 | def full_name(self) -> str: 17 | return f"{self.first_name} {self.last_name}" 18 | ``` 19 | 20 | And in order to for example show the `full_name` property in the 21 | admin, you can just use the `full_name` just like other string model properties. 22 | 23 | For example: 24 | 25 | ```py 26 | class UserAdmin(ModelView, model=User): 27 | column_list = [User.id, "full_name"] 28 | column_details_list = [User.id, "full_name"] 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/cookbook/multiple_databases.md: -------------------------------------------------------------------------------- 1 | SQLAlchemy offers some partitioning strategies to use multiple databases per session. 2 | An example from the SQLAlchemy docs is: 3 | 4 | ```py 5 | from sqlalchemy.orm.session import sessionmaker, Session 6 | 7 | engine1 = create_engine("postgresql+psycopg2://db1") 8 | engine2 = create_engine("postgresql+psycopg2://db2") 9 | 10 | Session = sessionmaker() 11 | 12 | # bind User operations to engine 1, Account operations to engine 2 13 | Session.configure(binds={User: engine1, Account: engine2}) 14 | ``` 15 | 16 | With this `Session` the `User` table will be in engine1 17 | and `Account` will be in engine2. 18 | 19 | And when instantiating the `Admin` object you can use the `sessionmaker` factory you have: 20 | 21 | ```py 22 | from sqladmin import Admin 23 | 24 | 25 | admin = Admin(app=app, session_maker=Session) 26 | admin.add_view(...) 27 | ``` 28 | 29 | This is different from other places where you could just use `engine` argument, 30 | and now you can use the `sessionmaker` factory. 31 | 32 | !!! tip 33 | In addition to being useful for partitioning, you could use the `sessionmaker` factory 34 | instead of the `engine` if you have one database for your application, SQLAdmin internally 35 | creates a `sessionmaker` for your `engine` but if you pass the `sessionmaker` you can keep 36 | any configuration you have on your sessions. 37 | -------------------------------------------------------------------------------- /docs/cookbook/optimize_relationship_loading.md: -------------------------------------------------------------------------------- 1 | When dealing with any kind of relationship in your models, 2 | be it One-To-Many, Many-To-One or Many-To-Many, 3 | `SQLAdmin` will load related models in your edit page. 4 | 5 | For example if we have the following model definition: 6 | 7 | ```python 8 | class Parent(Base): 9 | __tablename__ = "parent_table" 10 | 11 | id = mapped_column(Integer, primary_key=True) 12 | children = relationship("Child", back_populates="parent") 13 | 14 | 15 | class Child(Base): 16 | __tablename__ = "child_table" 17 | 18 | id = mapped_column(Integer, primary_key=True) 19 | parent_id = mapped_column(ForeignKey("parent_table.id")) 20 | parent = relationship("Parent", back_populates="children") 21 | ``` 22 | 23 | When we are editing a `Parent` object in the Admin, 24 | there will be an HTML `select` option which loads all possible `Child` objects to be selected. 25 | 26 | This is fine for small projects, but if you more than a few hundred records in your tables, 27 | it will be very slow and inefficient. 28 | Practically for each request to the Edit page, all records of `Child` table will be loaded. 29 | 30 | In order to solve this you can use Form options available in [configuration](./../configurations.md#form-options). 31 | 32 | You have a few options to improve this: 33 | 34 | ### Using `form_ajax_refs` 35 | 36 | Instead of loading all the `Child` objects when editing a `Parent` object, 37 | you can use `form_ajax_refs` to load `Child` objects with an AJAX call: 38 | 39 | ```py 40 | class ParentAdmin(ModelView, model=Parent): 41 | form_ajax_refs = { 42 | "children": { 43 | "fields": ("id",), 44 | "order_by": "id", 45 | } 46 | } 47 | ``` 48 | 49 | This will allow you to search `Child` objects using the `id` field while also ordering the results. 50 | 51 | ### Using `form_columns` or `form_excluded_columns` 52 | 53 | Another option, which is not as useful as the previous one, is that you might not need 54 | the relationship `children` to be edited for your `Pranet` objects. 55 | 56 | In that case you can just exclude that or specifically include the columns 57 | which should be available in the form. 58 | 59 | ```py 60 | class ParentAdmin(ModelView, model=Parent): 61 | form_excluded_columns = [Parent.children] 62 | ``` 63 | -------------------------------------------------------------------------------- /docs/cookbook/using_request_object.md: -------------------------------------------------------------------------------- 1 | If you want to access the `request` object for the admin, 2 | doing actions like create/update/delete you can override the specific `ModelView` methods. 3 | 4 | These methods include: 5 | 6 | - `insert_model(request, data)` 7 | - `update_model(request, pk, data)` 8 | - `delete_model(request, pk)` 9 | 10 | A common use case is to access the `request.user` and store that in create/update model: 11 | 12 | ```python 13 | class User(Base): 14 | __tablename__ = "user" 15 | 16 | id = mapped_column(Integer, primary_key=True) 17 | name = mapped_column(String) 18 | 19 | 20 | class Post(Base): 21 | __tablename__ = "post" 22 | 23 | id = mapped_column(Integer, primary_key=True) 24 | text = mapped_column(String) 25 | author = relationship("User") 26 | author_id = mapped_column(Integer, ForeignKey("user.id"), index=True) 27 | ``` 28 | 29 | And whenever a new `Post` is created we want to store the current admin user creating it. 30 | This can be done by overriding the `insert_model` method: 31 | 32 | ```python 33 | class PostAdmin(ModelView, model=Post): 34 | async def insert_model(self, request, data): 35 | data["author_id"] = request.user.id 36 | return await super().insert_model(request, data) 37 | ``` 38 | 39 | Here we've set the current `request.user.id` into the dictionary 40 | of data which will create the `Post`. 41 | 42 | The same thing can be done to control `update` and `delete` actions with the methods mentioned above. 43 | -------------------------------------------------------------------------------- /docs/cookbook/using_wysiwyg.md: -------------------------------------------------------------------------------- 1 | You can customize the templates and add custom javascript code to enable CKEditor to your fields. 2 | In order to use `CKEditor` you need to inject some JS code into the SQLAdmin and that works by customizing the templates. 3 | 4 | Let's say you have the following model: 5 | 6 | ```py 7 | class Post(Base): 8 | id = Column(Integer, primary_key=True) 9 | content = Column(Text, nullable=False) 10 | ``` 11 | 12 | - First create a `templates` directory in your project. 13 | - Then add a file `custom_edit.html` there with the following content: 14 | ```html name="custom_edit.html" 15 | {% extends "edit.html" %} 16 | {% block tail %} 17 | 18 | 25 | {% endblock %} 26 | ``` 27 | 28 | - Use the `custom_edit.html` template in your admin: 29 | 30 | ```py 31 | class PostAdmin(ModelView, model=Post): 32 | edit_template = "custom_edit.html" 33 | ``` 34 | 35 | Now whenever editing a Post object in admin, the CKEditor will be applied to the `content` field of the model. 36 | You can do the same thing with `create_template` field. 37 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

6 | 7 |

8 | 9 | Build Status 10 | 11 | 12 | Publish Status 13 | 14 | 15 | Coverage 16 | 17 | 18 | Package version 19 | 20 | 21 | Supported Python versions 22 | 23 |

24 | 25 | --- 26 | 27 | # SQLAlchemy Admin for Starlette/FastAPI 28 | 29 | SQLAdmin is a flexible Admin interface for SQLAlchemy models. 30 | 31 | Main features include: 32 | 33 | * [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy) sync/async engines 34 | * [Starlette](https://github.com/encode/starlette) integration 35 | * [FastAPI](https://github.com/tiangolo/fastapi) integration 36 | * [WTForms](https://github.com/wtforms/wtforms) form building 37 | * [SQLModel](https://github.com/tiangolo/sqlmodel) support 38 | * UI using [Tabler](https://github.com/tabler/tabler) 39 | 40 | --- 41 | 42 | **Documentation**: [https://aminalaee.dev/sqladmin](https://aminalaee.dev/sqladmin) 43 | 44 | **Source Code**: [https://github.com/aminalaee/sqladmin](https://github.com/aminalaee/sqladmin) 45 | 46 | **Online Demo**: [Demo](https://sqladmin-demo.aminalaee.dev/admin/) 47 | 48 | --- 49 | 50 | ## Installation 51 | 52 | ```shell 53 | $ pip install sqladmin 54 | $ pip install sqladmin[full] 55 | ``` 56 | 57 | --- 58 | 59 | ## Screenshots 60 | 61 | sqladmin-1 62 | sqladmin-2 63 | 64 | ## Quickstart 65 | 66 | Let's define an example SQLAlchemy model: 67 | 68 | ```python 69 | from sqlalchemy import Column, Integer, String, create_engine 70 | from sqlalchemy.orm import declarative_base 71 | 72 | 73 | Base = declarative_base() 74 | engine = create_engine( 75 | "sqlite:///example.db", 76 | connect_args={"check_same_thread": False}, 77 | ) 78 | 79 | 80 | class User(Base): 81 | __tablename__ = "users" 82 | 83 | id = Column(Integer, primary_key=True) 84 | name = Column(String) 85 | 86 | 87 | Base.metadata.create_all(engine) # Create tables 88 | ``` 89 | 90 | If you want to use `SQLAdmin` with `FastAPI`: 91 | 92 | ```python 93 | from fastapi import FastAPI 94 | from sqladmin import Admin, ModelView 95 | 96 | 97 | app = FastAPI() 98 | admin = Admin(app, engine) 99 | 100 | 101 | class UserAdmin(ModelView, model=User): 102 | column_list = [User.id, User.name] 103 | 104 | 105 | admin.add_view(UserAdmin) 106 | ``` 107 | 108 | Or if you want to use `SQLAdmin` with `Starlette`: 109 | 110 | ```python 111 | from sqladmin import Admin, ModelView 112 | from starlette.applications import Starlette 113 | 114 | 115 | app = Starlette() 116 | admin = Admin(app, engine) 117 | 118 | 119 | class UserAdmin(ModelView, model=User): 120 | column_list = [User.id, User.name] 121 | 122 | 123 | admin.add_view(UserAdmin) 124 | ``` 125 | 126 | Now visiting `/admin` on your browser you can see the `SQLAdmin` interface. 127 | 128 | ## Related projects and inspirations 129 | 130 | * [Flask-Admin](https://github.com/flask-admin/flask-admin) Admin interface for Flask supporting different database backends and ORMs. This project has inspired SQLAdmin extensively and most of the features and configurations are implemented the same. 131 | * [FastAPI-Admin](https://github.com/fastapi-admin/fastapi-admin) Admin interface for FastAPI which works with `TortoiseORM`. 132 | * [Dashboard](https://github.com/encode/dashboard) Admin interface for ASGI frameworks which works with the `orm` package. 133 | -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | /* Let some code block headings have usual code backgrounds */ 2 | .md-typeset :is(h3,h4,h5,h6) code { 3 | background-color: var(--md-code-bg-color); 4 | } 5 | -------------------------------------------------------------------------------- /docs/working_with_files.md: -------------------------------------------------------------------------------- 1 | # Working with Files and Images 2 | 3 | You can use [fastapi-storages](https://github.com/aminalaee/fastapi-storages) package 4 | to make file management easy in `SQLAdmin`. 5 | 6 | Right now `fastapi-storages` provides two storage backends: 7 | 8 | - `FileSystemStorage` for storing files in local file system. 9 | - `S3Storage` for storing files in Amazon S3 or S3-compatible storages. 10 | 11 | It also includes custom SQLAlchemy types to make it easier to integrate into `SQLAdmin`: 12 | 13 | - `FileType` 14 | - `ImageType` 15 | 16 | Let's see a minimal example: 17 | 18 | ```python 19 | from fastapi import FastAPI 20 | from sqladmin import Admin, ModelView 21 | from sqlalchemy import Column, Integer, create_engine 22 | from sqlalchemy.orm import declarative_base 23 | from fastapi_storages import FileSystemStorage 24 | from fastapi_storages.integrations.sqlalchemy import FileType 25 | 26 | 27 | Base = declarative_base() 28 | engine = create_engine("sqlite:///example.db") 29 | app = FastAPI() 30 | admin = Admin(app, engine) 31 | storage = FileSystemStorage(path="/tmp") 32 | 33 | 34 | class User(Base): 35 | __tablename__ = "users" 36 | 37 | id = Column(Integer, primary_key=True) 38 | file = Column(FileType(storage=storage)) 39 | 40 | 41 | class UserAdmin(ModelView, model=User): 42 | column_list = [User.id, User.file] 43 | 44 | 45 | Base.metadata.create_all(engine) # Create tables 46 | 47 | admin.add_view(UserAdmin) 48 | ``` 49 | 50 | First we define a `FileSystemStorage(path="/tmp")` and configure it to use our local `/tmp` directory for file uploads. 51 | Then we define a custom field called `file` in our model using the `FileType` and our storage. 52 | 53 | Now visiting `/admin/user` to create a new User, 54 | there's an HTML file field to upload files form local. 55 | After creating the file you will see that the file name is stored in the database 56 | and displayed in the admin dashboard. 57 | 58 | You can replace `FileSystemStorage` with `S3Storage` to upload to S3 or any S3-compatible API. 59 | 60 | For complete features and API reference of the `fastapi-storages` you can visit the docs at [https://aminalaee.dev/fastapi-storages](https://aminalaee.dev/fastapi-storages). 61 | -------------------------------------------------------------------------------- /docs/working_with_templates.md: -------------------------------------------------------------------------------- 1 | The template uses `Jinja2` template engine and by default looks for a `templates` directory in your project. 2 | 3 | If your `templates` directory has the default template files like `list.html` or `create.html` then they wiill be used. 4 | Otherwise you can create custom templates and use them. 5 | 6 | ## Customizing templates 7 | 8 | As the first step you should create a `templates` directory in you project. 9 | 10 | Since `Jinja2` is modular, you can override your specific template file and do your changes. 11 | For example you can create a `custom_details.html` file which overrides the `details.html` from 12 | SQLAdmin and in the `content` block it adds custom HTML tags: 13 | 14 | !!! example 15 | 16 | ```html name="custom_details.html" 17 | {% extends "details.html" %} 18 | {% block content %} 19 | {{ super() }} 20 |

Custom HTML

21 | {% endblock %} 22 | ``` 23 | 24 | ```python name="admin.py" 25 | class UserAdmin(ModelView, model=User): 26 | details_template = "custom_details.html" 27 | ``` 28 | 29 | ## Customizing Jinja2 environment 30 | 31 | You can add custom environment options to use it on your custom templates. First set up a project: 32 | 33 | ```python 34 | from sqladmin import Admin 35 | from starlette.applications import Starlette 36 | 37 | 38 | app = Starlette() 39 | admin = Admin(app, engine) 40 | ``` 41 | 42 | Then you can add your environment options: 43 | 44 | ### Adding filters 45 | 46 | ```python 47 | def datetime_format(value, format="%H:%M %d-%m-%y"): 48 | return value.strftime(format) 49 | 50 | admin.templates.env.filters["datetime_format"] = datetime_format 51 | ``` 52 | 53 | Usage in templates: 54 | 55 | ``` 56 | {{ article.pub_date|datetimeformat }} 57 | {{ article.pub_date|datetimeformat("%B %Y") }} 58 | ``` 59 | 60 | ### Adding tests 61 | 62 | ```python 63 | import math 64 | 65 | def is_prime(n): 66 | if n == 2: 67 | return True 68 | 69 | for i in range(2, int(math.ceil(math.sqrt(n))) + 1): 70 | if n % i == 0: 71 | return False 72 | 73 | return True 74 | 75 | admin.templates.env.tests["prime"] = is_prime 76 | ``` 77 | 78 | Usage in templates: 79 | 80 | ``` 81 | {% if value is prime %} 82 | {{ value }} is a prime number 83 | {% else %} 84 | {{ value }} is not a prime number 85 | {% endif %} 86 | ``` 87 | 88 | # Adding globals 89 | 90 | ```python 91 | def value_is_filepath(value: Any) -> bool: 92 | return isinstance(value, str) and os.path.isfile(value) 93 | 94 | admin.templates.env.globals["value_is_filepath"] = value_is_filepath 95 | ``` 96 | 97 | Usage in templates: 98 | 99 | ``` 100 | {% if value_is_filepath(value) %} 101 | {{ value }} is file path 102 | {% else %} 103 | {{ value }} is not file path 104 | {% endif %} 105 | ``` 106 | -------------------------------------------------------------------------------- /docs/writing_custom_views.md: -------------------------------------------------------------------------------- 1 | ### Basic example 2 | 3 | You might need to add custom views to the existing SQLAdmin views, for example to create dashboards, show custom info or add new forms. 4 | 5 | To add custom views to the Admin interface, you can use the `BaseView` included in SQLAdmin. Here's an example to add custom views: 6 | 7 | !!! example 8 | 9 | ```python 10 | from sqladmin import BaseView, expose 11 | 12 | class ReportView(BaseView): 13 | name = "Report Page" 14 | icon = "fa-solid fa-chart-line" 15 | 16 | @expose("/report", methods=["GET"]) 17 | async def report_page(self, request): 18 | return await self.templates.TemplateResponse(request, "report.html") 19 | 20 | admin.add_view(ReportView) 21 | ``` 22 | 23 | This will assume there's a `templates` directory in your project and you have created a `report.html` in that directory. 24 | 25 | If you want to use a custom directory name, you can change that with: 26 | 27 | ```python 28 | from sqladmin import Admin 29 | 30 | admin = Admin(templates_dir="my_templates", ...) 31 | ``` 32 | 33 | Now visiting `/admin/report` you can render your `report.html` file. 34 | 35 | ### Database access 36 | 37 | The example above was very basic and you probably want to access database and SQLAlchemy models in your custom view. You can use `sessionmaker` the same way SQLAdmin is using it to do so: 38 | 39 | !!! example 40 | 41 | ```python 42 | from sqlalchemy import Column, Integer, String, select, func 43 | from sqlalchemy.orm import sessionmaker, declarative_base 44 | from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine 45 | from sqladmin import Admin, BaseView, expose 46 | from starlette.applications import Starlette 47 | 48 | Base = declarative_base() 49 | engine = create_async_engine("sqlite+aiosqlite:///test.db") 50 | Session = sessionmaker(bind=engine, class_=AsyncSession) 51 | 52 | app = Starlette() 53 | admin = Admin(app=app, engine=engine) 54 | 55 | 56 | class User(Base): 57 | __tablename__ = "users" 58 | 59 | id = Column(Integer, primary_key=True) 60 | name = Column(String(length=16)) 61 | 62 | 63 | class ReportView(BaseView): 64 | name = "Report Page" 65 | icon = "fa-solid fa-chart-line" 66 | 67 | @expose("/report", methods=["GET"]) 68 | async def report_page(self, request): 69 | # async with engine.begin() as conn: 70 | # await conn.run_sync(Base.metadata.create_all) 71 | 72 | async with Session(expire_on_commit=False) as session: 73 | stmt = select(func.count(User.id)) 74 | result = await session.execute(stmt) 75 | users_count = result.scalar_one() 76 | 77 | return await self.templates.TemplateResponse( 78 | request, 79 | "report.html", 80 | context={"users_count": users_count}, 81 | ) 82 | 83 | 84 | admin.add_view(ReportView) 85 | 86 | ``` 87 | 88 | Next we update the `report.html` file in the `templates` directory with the following content: 89 | 90 | !!! example 91 | ```html 92 | {% extends "layout.html" %} 93 | {% block content %} 94 |
95 |
96 |
97 |

User reports

98 |
99 |
100 | Users count: {{ users_count }} 101 |
102 |
103 |
104 | {% endblock %} 105 | ``` 106 | 107 | Now running your server you can head to `/admin/report` and you can see the number of users. 108 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "sqladmin-litestar" 7 | description = 'SQLAlchemy admin for Litestar' 8 | readme = "README.md" 9 | requires-python = ">=3.8" 10 | license = "BSD-3-Clause" 11 | keywords = ["sqlalchemy", "litestar", "admin"] 12 | authors = [ 13 | { name = "Amin Alaee", email = "me@aminalaee.dev" }, 14 | { name = "Cemrehan Çavdar", email = "cemrehancavdar@hotmail.com" }, 15 | ] 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Environment :: Web Environment", 25 | "Intended Audience :: Developers", 26 | "License :: OSI Approved :: BSD License", 27 | "Topic :: Internet :: WWW/HTTP", 28 | "Operating System :: OS Independent", 29 | ] 30 | dependencies = [ 31 | "litestar", 32 | "sqlalchemy >=1.4", 33 | "wtforms >=3.1, <3.2", 34 | "jinja2", 35 | "python-multipart", 36 | ] 37 | dynamic = ["version"] 38 | 39 | [project.optional-dependencies] 40 | full = ["itsdangerous"] 41 | 42 | [project.urls] 43 | Documentation = "https://aminalaee.dev/sqladmin" 44 | Issues = "https://github.com/aminalaee/sqladmin/issues" 45 | Source = "https://github.com/aminalaee/sqladmin" 46 | 47 | [tool.hatch.version] 48 | path = "sqladmin/__init__.py" 49 | 50 | 51 | [tool.hatch.build.targets.wheel] 52 | packages = ["/sqladmin"] 53 | [tool.hatch.build.targets.sdist] 54 | include = ["/sqladmin"] 55 | 56 | [tool.hatch.build] 57 | exclude = ["tests/*"] 58 | 59 | [tool.hatch.envs.test] 60 | dependencies = [ 61 | "aiosqlite==0.19.0", 62 | "arrow==1.3.0", 63 | "asyncpg==0.29.0", 64 | "babel==2.13.1", 65 | "build==1.0.3", 66 | "colour==0.1.5", 67 | "coverage==7.3.2", 68 | "email-validator==2.1.0", 69 | "fastapi-storages==0.1.0", 70 | "greenlet==3.0.1", 71 | "httpx==0.25.1", 72 | "itsdangerous==2.1.2", 73 | "phonenumbers==8.13.24", 74 | "pillow==10.1.0", 75 | "psycopg2-binary==2.9.9", 76 | "pytest==7.4.2", 77 | "python-dateutil==2.8.2", 78 | "sqlalchemy_utils==0.41.1", 79 | ] 80 | 81 | [[tool.hatch.envs.test.matrix]] 82 | python = ["38", "39", "310", "311", "3.12"] 83 | 84 | [tool.hatch.envs.lint] 85 | dependencies = [ 86 | "mypy==1.8.0", 87 | "ruff==0.1.5", 88 | "sqlalchemy~=1.4", # MyPy issues with SQLAlchemy V2 89 | ] 90 | 91 | [tool.hatch.envs.docs] 92 | dependencies = [ 93 | "mkdocs-material==9.1.3", 94 | "mkdocs==1.4.2", 95 | "mkdocstrings[python]==0.20.0", 96 | ] 97 | 98 | [tool.hatch.envs.test.scripts] 99 | cov = [ 100 | "coverage report --show-missing --skip-covered --fail-under=99", 101 | "coverage xml", 102 | ] 103 | test = "coverage run -a --concurrency=thread,greenlet -m pytest {args}" 104 | 105 | [tool.hatch.envs.lint.scripts] 106 | check = ["ruff .", "ruff format --check .", "mypy sqladmin"] 107 | format = ["ruff format .", "ruff --fix ."] 108 | 109 | [tool.hatch.envs.docs.scripts] 110 | build = "mkdocs build" 111 | serve = "mkdocs serve --dev-addr localhost:8080" 112 | deploy = "mkdocs gh-deploy --force" 113 | 114 | [[tool.hatch.envs.test.matrix]] 115 | sqlalchemy = ["1.4", "2.0"] 116 | 117 | [tool.hatch.envs.test.overrides] 118 | matrix.sqlalchemy.dependencies = [ 119 | { value = "sqlalchemy==1.4.41", if = [ 120 | "1.4", 121 | ] }, 122 | { value = "sqlmodel==0.0.8", if = [ 123 | "1.4", 124 | ] }, 125 | { value = "sqlalchemy==2.0", if = [ 126 | "2.0", 127 | ] }, 128 | ] 129 | 130 | [tool.mypy] 131 | disallow_untyped_defs = true 132 | ignore_missing_imports = true 133 | show_error_codes = true 134 | 135 | [tool.ruff] 136 | select = ["E", "F", "I"] 137 | 138 | [tool.coverage.run] 139 | source_pkgs = ["sqladmin", "tests"] 140 | 141 | [tool.coverage.report] 142 | exclude_lines = [ 143 | "pragma: no cover", 144 | "pragma: nocover", 145 | "except NotImplementedError", 146 | "raise NotImplementedError", 147 | "if TYPE_CHECKING:", 148 | ] 149 | -------------------------------------------------------------------------------- /sqladmin/__init__.py: -------------------------------------------------------------------------------- 1 | from sqladmin.application import Admin, action, expose 2 | from sqladmin.models import BaseView, ModelView 3 | 4 | __version__ = "0.16.1" 5 | __original_version__ = "0.16.0" 6 | 7 | __all__ = [ 8 | "Admin", 9 | "expose", 10 | "action", 11 | "BaseView", 12 | "ModelView", 13 | ] 14 | -------------------------------------------------------------------------------- /sqladmin/_menu.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, List, Optional, Union 2 | 3 | from litestar import Request 4 | from litestar.datastructures import URL 5 | 6 | if TYPE_CHECKING: 7 | from sqladmin.application import BaseView, ModelView 8 | 9 | 10 | class ItemMenu: 11 | def __init__(self, name: str, icon: Optional[str] = None) -> None: 12 | self.name = name 13 | self.icon = icon 14 | self.parent: Optional["ItemMenu"] = None 15 | self.children: List["ItemMenu"] = [] 16 | 17 | def add_child(self, item: "ItemMenu") -> None: 18 | item.parent = self 19 | self.children.append(item) 20 | 21 | def is_visible(self, request: Request) -> bool: 22 | return True 23 | 24 | def is_accessible(self, request: Request) -> bool: 25 | return True 26 | 27 | def is_active(self, request: Request) -> bool: 28 | return False 29 | 30 | def url(self, request: Request) -> Union[str, URL]: 31 | return "#" 32 | 33 | @property 34 | def display_name(self) -> str: 35 | return self.name 36 | 37 | @property 38 | def type_(self) -> str: 39 | return self.__class__.__name__ 40 | 41 | 42 | class CategoryMenu(ItemMenu): 43 | def is_active(self, request: Request) -> bool: 44 | return any( 45 | c.is_active(request) and c.is_accessible(request) for c in self.children 46 | ) 47 | 48 | @property 49 | def type_(self) -> str: 50 | return "Category" 51 | 52 | 53 | class ViewMenu(ItemMenu): 54 | def __init__( 55 | self, 56 | view: Union["BaseView", "ModelView"], 57 | name: str, 58 | icon: Optional[str] = None, 59 | ) -> None: 60 | super().__init__(name=name, icon=icon) 61 | self.view = view 62 | 63 | def is_visible(self, request: Request) -> bool: 64 | return self.view.is_visible(request) 65 | 66 | def is_accessible(self, request: Request) -> bool: 67 | return self.view.is_accessible(request) 68 | 69 | def is_active(self, request: Request) -> bool: 70 | return self.view.identity == request.path_params.get("identity") 71 | 72 | def url(self, request: Request) -> Union[str, URL]: 73 | if self.view.is_model: 74 | return request.url_for("admin:list", identity=self.view.identity) 75 | return request.url_for(f"admin:{self.view.identity}") 76 | 77 | @property 78 | def display_name(self) -> str: 79 | return getattr(self.view, "name_plural", None) or self.view.name 80 | 81 | @property 82 | def type_(self) -> str: 83 | return "View" 84 | 85 | 86 | class Menu: 87 | def __init__(self) -> None: 88 | self.items: List[ItemMenu] = [] 89 | 90 | def add(self, item: ItemMenu) -> None: 91 | # Only works for one-level menu 92 | for root in self.items: 93 | if root.name == item.name: 94 | root.children.append(*item.children) 95 | return 96 | self.items.append(item) 97 | -------------------------------------------------------------------------------- /sqladmin/_queries.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Dict, List 2 | 3 | import anyio 4 | from litestar import Request 5 | from sqlalchemy import select 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | from sqlalchemy.orm import Session, joinedload 8 | from sqlalchemy.sql.expression import Select, and_, or_ 9 | 10 | from sqladmin._types import MODEL_PROPERTY 11 | from sqladmin.helpers import ( 12 | get_column_python_type, 13 | get_direction, 14 | get_primary_keys, 15 | is_falsy_value, 16 | object_identifier_values, 17 | ) 18 | 19 | if TYPE_CHECKING: 20 | from sqladmin.models import ModelView 21 | 22 | 23 | class Query: 24 | def __init__(self, model_view: "ModelView") -> None: 25 | self.model_view = model_view 26 | 27 | def _get_to_many_stmt(self, relation: MODEL_PROPERTY, values: List[Any]) -> Select: 28 | target = relation.mapper.class_ 29 | 30 | target_pks = get_primary_keys(target) 31 | 32 | if len(target_pks) == 1: 33 | target_pk = target_pks[0] 34 | target_pk_type = get_column_python_type(target_pk) 35 | pk_values = [target_pk_type(value) for value in values] 36 | return select(target).where(target_pk.in_(pk_values)) 37 | 38 | conditions = [] 39 | for value in values: 40 | conditions.append( 41 | and_( 42 | pk == value 43 | for pk, value in zip( 44 | target_pks, 45 | object_identifier_values(value, target), 46 | ) 47 | ) 48 | ) 49 | return select(target).where(or_(*conditions)) 50 | 51 | def _get_to_one_stmt(self, relation: MODEL_PROPERTY, value: Any) -> Select: 52 | target = relation.mapper.class_ 53 | target_pks = get_primary_keys(target) 54 | target_pk_types = [get_column_python_type(pk) for pk in target_pks] 55 | conditions = [pk == typ(value) for pk, typ in zip(target_pks, target_pk_types)] 56 | related_stmt = select(target).where(*conditions) 57 | return related_stmt 58 | 59 | def _set_many_to_one(self, obj: Any, relation: MODEL_PROPERTY, ident: Any) -> Any: 60 | values = object_identifier_values(ident, relation.entity) 61 | pks = get_primary_keys(relation.entity) 62 | 63 | # ``relation.local_remote_pairs`` is ordered by the foreign keys 64 | # but the values are ordered by the primary keys. This dict 65 | # ensures we write the correct value to the fk fields 66 | pk_value = {pk: value for pk, value in zip(pks, values)} 67 | 68 | for fk, pk in relation.local_remote_pairs: 69 | setattr(obj, fk.name, pk_value[pk]) 70 | 71 | return obj 72 | 73 | def _set_attributes_sync(self, session: Session, obj: Any, data: dict) -> Any: 74 | for key, value in data.items(): 75 | column = self.model_view._mapper.columns.get(key) 76 | relation = self.model_view._mapper.relationships.get(key) 77 | 78 | # Set falsy values to None, if column is Nullable 79 | if not value: 80 | if is_falsy_value(value) and not relation and column.nullable: 81 | value = None 82 | setattr(obj, key, value) 83 | continue 84 | 85 | if relation: 86 | direction = get_direction(relation) 87 | if direction in ["ONETOMANY", "MANYTOMANY"]: 88 | related_stmt = self._get_to_many_stmt(relation, value) 89 | related_objs = session.execute(related_stmt).scalars().all() 90 | setattr(obj, key, related_objs) 91 | elif direction == "ONETOONE": 92 | related_stmt = self._get_to_one_stmt(relation, value) 93 | related_obj = session.execute(related_stmt).scalars().first() 94 | setattr(obj, key, related_obj) 95 | else: 96 | obj = self._set_many_to_one(obj, relation, value) 97 | else: 98 | setattr(obj, key, value) 99 | 100 | return obj 101 | 102 | async def _set_attributes_async( 103 | self, session: AsyncSession, obj: Any, data: dict 104 | ) -> Any: 105 | for key, value in data.items(): 106 | column = self.model_view._mapper.columns.get(key) 107 | relation = self.model_view._mapper.relationships.get(key) 108 | 109 | # Set falsy values to None, if column is Nullable 110 | if not value: 111 | if is_falsy_value(value) and not relation and column.nullable: 112 | value = None 113 | setattr(obj, key, value) 114 | continue 115 | 116 | if relation: 117 | direction = get_direction(relation) 118 | if direction in ["ONETOMANY", "MANYTOMANY"]: 119 | related_stmt = self._get_to_many_stmt(relation, value) 120 | result = await session.execute(related_stmt) 121 | related_objs = result.scalars().all() 122 | setattr(obj, key, related_objs) 123 | elif direction == "ONETOONE": 124 | related_stmt = self._get_to_one_stmt(relation, value) 125 | result = await session.execute(related_stmt) 126 | related_obj = result.scalars().first() 127 | setattr(obj, key, related_obj) 128 | else: 129 | obj = self._set_many_to_one(obj, relation, value) 130 | else: 131 | setattr(obj, key, value) 132 | return obj 133 | 134 | def _update_sync(self, pk: Any, data: Dict[str, Any], request: Request) -> Any: 135 | stmt = self.model_view._stmt_by_identifier(pk) 136 | 137 | with self.model_view.session_maker(expire_on_commit=False) as session: 138 | obj = session.execute(stmt).scalars().first() 139 | anyio.from_thread.run( 140 | self.model_view.on_model_change, data, obj, False, request 141 | ) 142 | obj = self._set_attributes_sync(session, obj, data) 143 | session.commit() 144 | anyio.from_thread.run( 145 | self.model_view.after_model_change, data, obj, False, request 146 | ) 147 | return obj 148 | 149 | async def _update_async( 150 | self, pk: Any, data: Dict[str, Any], request: Request 151 | ) -> Any: 152 | stmt = self.model_view._stmt_by_identifier(pk) 153 | 154 | for relation in self.model_view._form_relations: 155 | stmt = stmt.options(joinedload(relation)) 156 | 157 | async with self.model_view.session_maker(expire_on_commit=False) as session: 158 | result = await session.execute(stmt) 159 | obj = result.scalars().first() 160 | await self.model_view.on_model_change(data, obj, False, request) 161 | obj = await self._set_attributes_async(session, obj, data) 162 | await session.commit() 163 | await self.model_view.after_model_change(data, obj, False, request) 164 | return obj 165 | 166 | def _get_delete_stmt(self, pk: str) -> Select: 167 | stmt = select(self.model_view.model) 168 | pks = get_primary_keys(self.model_view.model) 169 | values = object_identifier_values(pk, self.model_view.model) 170 | conditions = [pk == value for (pk, value) in zip(pks, values)] 171 | return stmt.where(*conditions) 172 | 173 | def _delete_sync(self, pk: str, request: Request) -> None: 174 | with self.model_view.session_maker() as session: 175 | obj = session.execute(self._get_delete_stmt(pk)).scalar_one_or_none() 176 | anyio.from_thread.run(self.model_view.on_model_delete, obj, request) 177 | session.delete(obj) 178 | session.commit() 179 | anyio.from_thread.run(self.model_view.after_model_delete, obj, request) 180 | 181 | async def _delete_async(self, pk: str, request: Request) -> None: 182 | async with self.model_view.session_maker() as session: 183 | result = await session.execute(self._get_delete_stmt(pk)) 184 | obj = result.scalars().first() 185 | await self.model_view.on_model_delete(obj, request) 186 | await session.delete(obj) 187 | await session.commit() 188 | await self.model_view.after_model_delete(obj, request) 189 | 190 | def _insert_sync(self, data: Dict[str, Any], request: Request) -> Any: 191 | obj = self.model_view.model() 192 | 193 | with self.model_view.session_maker(expire_on_commit=False) as session: 194 | anyio.from_thread.run( 195 | self.model_view.on_model_change, data, obj, True, request 196 | ) 197 | obj = self._set_attributes_sync(session, obj, data) 198 | session.add(obj) 199 | session.commit() 200 | anyio.from_thread.run( 201 | self.model_view.after_model_change, data, obj, True, request 202 | ) 203 | return obj 204 | 205 | async def _insert_async(self, data: Dict[str, Any], request: Request) -> Any: 206 | obj = self.model_view.model() 207 | 208 | async with self.model_view.session_maker(expire_on_commit=False) as session: 209 | await self.model_view.on_model_change(data, obj, True, request) 210 | obj = await self._set_attributes_async(session, obj, data) 211 | session.add(obj) 212 | await session.commit() 213 | await self.model_view.after_model_change(data, obj, True, request) 214 | return obj 215 | 216 | async def delete(self, obj: Any, request: Request) -> None: 217 | if self.model_view.is_async: 218 | await self._delete_async(obj, request) 219 | else: 220 | await anyio.to_thread.run_sync(self._delete_sync, obj, request) 221 | 222 | async def insert(self, data: dict, request: Request) -> Any: 223 | if self.model_view.is_async: 224 | return await self._insert_async(data, request) 225 | else: 226 | return await anyio.to_thread.run_sync(self._insert_sync, data, request) 227 | 228 | async def update(self, pk: Any, data: dict, request: Request) -> Any: 229 | if self.model_view.is_async: 230 | return await self._update_async(pk, data, request) 231 | else: 232 | return await anyio.to_thread.run_sync(self._update_sync, pk, data, request) 233 | -------------------------------------------------------------------------------- /sqladmin/_types.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from sqlalchemy.engine import Engine 4 | from sqlalchemy.ext.asyncio import AsyncEngine 5 | from sqlalchemy.orm import ColumnProperty, InstrumentedAttribute, RelationshipProperty 6 | 7 | MODEL_PROPERTY = Union[ColumnProperty, RelationshipProperty] 8 | ENGINE_TYPE = Union[Engine, AsyncEngine] 9 | MODEL_ATTR = Union[str, InstrumentedAttribute] 10 | -------------------------------------------------------------------------------- /sqladmin/_validators.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | from wtforms import Field, Form, ValidationError 4 | 5 | 6 | class CurrencyValidator: 7 | """Form validator for sqlalchemy_utils CurrencyType.""" 8 | 9 | def __call__(self, form: Form, field: Field) -> None: 10 | from sqlalchemy_utils import Currency 11 | 12 | try: 13 | Currency(field.data) 14 | except (TypeError, ValueError): 15 | raise ValidationError("Not a valid ISO currency code (e.g. USD, EUR, CNY).") 16 | 17 | 18 | class PhoneNumberValidator: 19 | """Form validator for sqlalchemy_utils PhoneNumberType.""" 20 | 21 | def __call__(self, form: Form, field: Field) -> None: 22 | from sqlalchemy_utils import PhoneNumber, PhoneNumberParseException 23 | 24 | try: 25 | PhoneNumber(field.data) 26 | except PhoneNumberParseException: 27 | raise ValidationError("Not a valid phone number.") 28 | 29 | 30 | class ColorValidator: 31 | """General Color validator using `colour` package.""" 32 | 33 | def __call__(self, form: Form, field: Field) -> None: 34 | from colour import Color 35 | 36 | try: 37 | Color(field.data) 38 | except ValueError: 39 | raise ValidationError('Not a valid color (e.g. "red", "#f00", "#ff0000").') 40 | 41 | 42 | class TimezoneValidator: 43 | """Form validator for sqlalchemy_utils TimezoneType.""" 44 | 45 | def __init__(self, coerce_function: Callable[[Any], Any]) -> None: 46 | self.coerce_function = coerce_function 47 | 48 | def __call__(self, form: Form, field: Field) -> None: 49 | try: 50 | self.coerce_function(str(field.data)) 51 | except Exception: 52 | raise ValidationError("Not a valid timezone (e.g. 'Asia/Singapore').") 53 | -------------------------------------------------------------------------------- /sqladmin/ajax.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Dict, List 2 | 3 | from sqlalchemy import String, cast, inspect, or_, select 4 | 5 | from sqladmin.helpers import get_object_identifier, get_primary_keys 6 | 7 | if TYPE_CHECKING: 8 | from sqladmin.models import ModelView 9 | 10 | 11 | DEFAULT_PAGE_SIZE = 10 12 | 13 | 14 | class QueryAjaxModelLoader: 15 | def __init__( 16 | self, 17 | name: str, 18 | model: type, 19 | model_admin: "ModelView", 20 | **options: Any, 21 | ): 22 | self.name = name 23 | self.model = model 24 | self.model_admin = model_admin 25 | self.fields = options.get("fields", {}) 26 | self.order_by = options.get("order_by") 27 | 28 | pks = get_primary_keys(self.model) 29 | self.pk = pks[0] if len(pks) == 1 else None 30 | 31 | if not self.fields: 32 | raise ValueError( 33 | "AJAX loading requires `fields` to be specified for " 34 | f"{self.model}.{self.name}" 35 | ) 36 | 37 | self._cached_fields = self._process_fields() 38 | 39 | def _process_fields(self) -> list: 40 | remote_fields = [] 41 | 42 | for field in self.fields: 43 | if isinstance(field, str): 44 | attr = getattr(self.model, field, None) 45 | 46 | if not attr: 47 | raise ValueError(f"{self.model}.{field} does not exist.") 48 | 49 | remote_fields.append(attr) 50 | else: 51 | remote_fields.append(field) 52 | 53 | return remote_fields 54 | 55 | def format(self, model: type) -> Dict[str, Any]: 56 | if not model: 57 | return {} 58 | 59 | return {"id": str(get_object_identifier(model)), "text": str(model)} 60 | 61 | async def get_list(self, term: str, limit: int = DEFAULT_PAGE_SIZE) -> List[Any]: 62 | stmt = select(self.model) 63 | 64 | # no type casting to string if a ColumnAssociationProxyInstance is given 65 | filters = [ 66 | cast(field, String).ilike("%%%s%%" % term) for field in self._cached_fields 67 | ] 68 | 69 | stmt = stmt.filter(or_(*filters)) 70 | 71 | if self.order_by: 72 | stmt = stmt.order_by(self.order_by) 73 | 74 | stmt = stmt.limit(limit) 75 | result = await self.model_admin._run_query(stmt) 76 | return result 77 | 78 | 79 | def create_ajax_loader( 80 | *, 81 | model_admin: "ModelView", 82 | name: str, 83 | options: dict, 84 | ) -> QueryAjaxModelLoader: 85 | mapper = inspect(model_admin.model) 86 | 87 | try: 88 | attr = mapper.relationships[name] 89 | except KeyError: 90 | raise ValueError(f"{model_admin.model}.{name} is not a relation.") 91 | 92 | remote_model = attr.mapper.class_ 93 | return QueryAjaxModelLoader(name, remote_model, model_admin, **options) 94 | -------------------------------------------------------------------------------- /sqladmin/authentication.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import inspect 3 | from typing import Any, Callable, Union 4 | 5 | from litestar import Request 6 | from litestar.response import Redirect as RedirectResponse 7 | from litestar.response import Response 8 | 9 | 10 | class AuthenticationBackend: 11 | """Base class for implementing the Authentication into SQLAdmin. 12 | You need to inherit this class and override the methods: 13 | `login`, `logout` and `authenticate`. 14 | """ 15 | 16 | def __init__(self, secret_key: str) -> None: 17 | from litestar.middleware.session.client_side import CookieBackendConfig 18 | 19 | session_config = CookieBackendConfig(secret=secret_key.encode("utf-8")) 20 | self.middlewares = [ 21 | session_config.middleware, 22 | ] 23 | 24 | async def login(self, request: Request) -> bool: 25 | """Implement login logic here. 26 | You can access the login form data `await request.form()` 27 | andvalidate the credentials. 28 | """ 29 | raise NotImplementedError() 30 | 31 | async def logout(self, request: Request) -> bool: 32 | """Implement logout logic here. 33 | This will usually clear the session with `request.session.clear()`. 34 | """ 35 | raise NotImplementedError() 36 | 37 | async def authenticate(self, request: Request) -> Union[Response, bool]: 38 | """Implement authenticate logic here. 39 | This method will be called for each incoming request 40 | to validate the authentication. 41 | 42 | If a 'Response' or `RedirectResponse` is returned, 43 | that response is returned to the user, 44 | otherwise a True/False is expected. 45 | """ 46 | raise NotImplementedError() 47 | 48 | 49 | def login_required(func: Callable[..., Any]) -> Callable[..., Any]: 50 | """Decorator to check authentication of Admin routes. 51 | If no authentication backend is setup, this will do nothing. 52 | """ 53 | 54 | @functools.wraps(func) 55 | async def wrapper_decorator(*args: Any, **kwargs: Any) -> Any: 56 | view, request = args[0], kwargs["request"] 57 | admin = getattr(view, "_admin_ref", view) 58 | auth_backend = getattr(admin, "authentication_backend", None) 59 | if auth_backend is not None: 60 | response = await auth_backend.authenticate(request) 61 | if isinstance(response, Response): 62 | return response 63 | if not bool(response): 64 | return RedirectResponse(request.url_for("admin:login"), status_code=302) 65 | 66 | if inspect.iscoroutinefunction(func): 67 | return await func(*args, **kwargs) 68 | return func(*args, **kwargs) 69 | 70 | return wrapper_decorator 71 | -------------------------------------------------------------------------------- /sqladmin/exceptions.py: -------------------------------------------------------------------------------- 1 | class SQLAdminException(Exception): 2 | pass 3 | 4 | 5 | class InvalidModelError(SQLAdminException): 6 | pass 7 | 8 | 9 | class NoConverterFound(SQLAdminException): 10 | pass 11 | -------------------------------------------------------------------------------- /sqladmin/formatters.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from markupsafe import Markup 4 | 5 | 6 | def empty_formatter(value: Any) -> str: 7 | """Return empty string for `None` value""" 8 | return "" 9 | 10 | 11 | def bool_formatter(value: bool) -> Markup: 12 | """Return check icon if value is `True` or X otherwise.""" 13 | icon_class = "fa-check text-success" if value else "fa-times text-danger" 14 | return Markup(f"") 15 | 16 | 17 | BASE_FORMATTERS = { 18 | type(None): empty_formatter, 19 | bool: bool_formatter, 20 | } 21 | -------------------------------------------------------------------------------- /sqladmin/helpers.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import enum 3 | import os 4 | import re 5 | import unicodedata 6 | from abc import ABC, abstractmethod 7 | from datetime import timedelta 8 | from typing import ( 9 | Any, 10 | AsyncGenerator, 11 | Callable, 12 | Dict, 13 | Generator, 14 | List, 15 | Optional, 16 | Tuple, 17 | TypeVar, 18 | ) 19 | 20 | from sqlalchemy import Column, inspect 21 | from sqlalchemy.ext.asyncio import AsyncSession 22 | from sqlalchemy.orm import RelationshipProperty, sessionmaker 23 | 24 | from sqladmin._types import MODEL_PROPERTY 25 | 26 | T = TypeVar("T") 27 | 28 | 29 | _filename_ascii_strip_re = re.compile(r"[^A-Za-z0-9_.-]") 30 | _windows_device_files = ( 31 | "CON", 32 | "AUX", 33 | "COM1", 34 | "COM2", 35 | "COM3", 36 | "COM4", 37 | "LPT1", 38 | "LPT2", 39 | "LPT3", 40 | "PRN", 41 | "NUL", 42 | ) 43 | 44 | standard_duration_re = re.compile( 45 | r"^" 46 | r"(?:(?P-?\d+) (days?, )?)?" 47 | r"(?P-?)" 48 | r"((?:(?P\d+):)(?=\d+:\d+))?" 49 | r"(?:(?P\d+):)?" 50 | r"(?P\d+)" 51 | r"(?:[\.,](?P\d{1,6})\d{0,6})?" 52 | r"$" 53 | ) 54 | 55 | # Support the sections of ISO 8601 date representation that are accepted by timedelta 56 | iso8601_duration_re = re.compile( 57 | r"^(?P[-+]?)" 58 | r"P" 59 | r"(?:(?P\d+([\.,]\d+)?)D)?" 60 | r"(?:T" 61 | r"(?:(?P\d+([\.,]\d+)?)H)?" 62 | r"(?:(?P\d+([\.,]\d+)?)M)?" 63 | r"(?:(?P\d+([\.,]\d+)?)S)?" 64 | r")?" 65 | r"$" 66 | ) 67 | 68 | # Support PostgreSQL's day-time interval format, e.g. "3 days 04:05:06". The 69 | # year-month and mixed intervals cannot be converted to a timedelta and thus 70 | # aren't accepted. 71 | postgres_interval_re = re.compile( 72 | r"^" 73 | r"(?:(?P-?\d+) (days? ?))?" 74 | r"(?:(?P[-+])?" 75 | r"(?P\d+):" 76 | r"(?P\d\d):" 77 | r"(?P\d\d)" 78 | r"(?:\.(?P\d{1,6}))?" 79 | r")?$" 80 | ) 81 | 82 | 83 | def prettify_class_name(name: str) -> str: 84 | return re.sub(r"(?<=.)([A-Z])", r" \1", name) 85 | 86 | 87 | def slugify_class_name(name: str) -> str: 88 | dashed = re.sub("(.)([A-Z][a-z]+)", r"\1-\2", name) 89 | return re.sub("([a-z0-9])([A-Z])", r"\1-\2", dashed).lower() 90 | 91 | 92 | def slugify_action_name(name: str) -> str: 93 | if not re.search(r"^[A-Za-z0-9 \-_]+$", name): 94 | raise ValueError( 95 | "name must be non-empty and contain only allowed characters" 96 | " - use `label` for more expressive names" 97 | ) 98 | 99 | return re.sub(r"[_ ]", "-", name).lower() 100 | 101 | 102 | def secure_filename(filename: str) -> str: 103 | """Ported from Werkzeug. 104 | 105 | Pass it a filename and it will return a secure version of it. This 106 | filename can then safely be stored on a regular file system and passed 107 | to :func:`os.path.join`. The filename returned is an ASCII only string 108 | for maximum portability. 109 | On windows systems the function also makes sure that the file is not 110 | named after one of the special device files. 111 | """ 112 | filename = unicodedata.normalize("NFKD", filename) 113 | filename = filename.encode("ascii", "ignore").decode("ascii") 114 | 115 | for sep in os.path.sep, os.path.altsep: 116 | if sep: 117 | filename = filename.replace(sep, " ") 118 | filename = str(_filename_ascii_strip_re.sub("", "_".join(filename.split()))).strip( 119 | "._" 120 | ) 121 | 122 | # on nt a couple of special files are present in each folder. We 123 | # have to ensure that the target file is not such a filename. In 124 | # this case we prepend an underline 125 | if ( 126 | os.name == "nt" 127 | and filename 128 | and filename.split(".")[0].upper() in _windows_device_files 129 | ): 130 | filename = f"_{filename}" # pragma: no cover 131 | 132 | return filename 133 | 134 | 135 | class Writer(ABC): 136 | """https://docs.python.org/3/library/csv.html#writer-objects""" 137 | 138 | @abstractmethod 139 | def writerow(self, row: List[str]) -> None: 140 | pass # pragma: no cover 141 | 142 | @abstractmethod 143 | def writerows(self, rows: List[List[str]]) -> None: 144 | pass # pragma: no cover 145 | 146 | @property 147 | @abstractmethod 148 | def dialect(self) -> csv.Dialect: 149 | pass # pragma: no cover 150 | 151 | 152 | class _PseudoBuffer: 153 | """An object that implements just the write method of the file-like 154 | interface. 155 | """ 156 | 157 | def write(self, value: T) -> T: 158 | return value 159 | 160 | 161 | def stream_to_csv( 162 | callback: Callable[[Writer], AsyncGenerator[T, None]], 163 | ) -> Generator[T, None, None]: 164 | """Function that takes a callable (that yields from a CSV Writer), and 165 | provides it a writer that streams the output directly instead of 166 | storing it in a buffer. The direct output stream is intended to go 167 | inside a `litestar.response.Stream`. 168 | 169 | Loosely adapted from here: 170 | 171 | https://docs.djangoproject.com/en/1.8/howto/outputting-csv/ 172 | """ 173 | writer = csv.writer(_PseudoBuffer()) 174 | return callback(writer) # type: ignore 175 | 176 | 177 | def get_primary_keys(model: Any) -> Tuple[Column, ...]: 178 | return tuple(inspect(model).mapper.primary_key) 179 | 180 | 181 | def get_object_identifier(obj: Any) -> Any: 182 | """Returns a value that uniquely identifies this object.""" 183 | primary_keys = get_primary_keys(obj) 184 | values = [getattr(obj, pk.name) for pk in primary_keys] 185 | 186 | # Unaltered value for tables with a single primary key 187 | if len(values) == 1: 188 | return values[0] 189 | 190 | # Combine into single string for multiple primary key support 191 | return ";".join(str(v).replace("\\", "\\\\").replace(";", r"\;") for v in values) 192 | 193 | 194 | def _object_identifier_parts(id_string: str, model: type) -> Tuple[str, ...]: 195 | pks = get_primary_keys(model) 196 | if len(pks) == 1: 197 | # Only one primary key so no special processing 198 | return (id_string,) 199 | 200 | values = [] 201 | escape_next = False 202 | value_start = 0 203 | for idx, char in enumerate(id_string): 204 | if escape_next: 205 | escape_next = False 206 | continue 207 | 208 | if char == ";": 209 | values.append(id_string[value_start:idx]) 210 | value_start = idx + 1 211 | 212 | escape_next = char == "\\" 213 | 214 | # Add the last part that's not followed by semicolon 215 | values.append(id_string[value_start:]) 216 | 217 | if len(values) != len(pks): 218 | raise ValueError(f"Malformed identifier string for model {model.__name__}.") 219 | 220 | # Undo escaping for ; and \ 221 | return tuple(v.replace(r"\;", ";").replace(r"\\", "\\") for v in values) 222 | 223 | 224 | def object_identifier_values(id_string: str, model: Any) -> tuple: 225 | values = [] 226 | pks = get_primary_keys(model) 227 | for pk, part in zip(pks, _object_identifier_parts(id_string, model)): 228 | type_ = get_column_python_type(pk) 229 | value = False if type_ is bool and part == "False" else type_(part) 230 | values.append(value) 231 | return tuple(values) 232 | 233 | 234 | def get_direction(prop: MODEL_PROPERTY) -> str: 235 | assert isinstance(prop, RelationshipProperty) 236 | name = prop.direction.name 237 | if name == "ONETOMANY" and not prop.uselist: 238 | return "ONETOONE" 239 | return name 240 | 241 | 242 | def get_column_python_type(column: Column) -> type: 243 | try: 244 | if hasattr(column.type, "impl"): 245 | return column.type.impl.python_type 246 | return column.type.python_type 247 | except NotImplementedError: 248 | return str 249 | 250 | 251 | def is_relationship(prop: MODEL_PROPERTY) -> bool: 252 | return isinstance(prop, RelationshipProperty) 253 | 254 | 255 | def parse_interval(value: str) -> Optional[timedelta]: 256 | match = ( 257 | standard_duration_re.match(value) 258 | or iso8601_duration_re.match(value) 259 | or postgres_interval_re.match(value) 260 | ) 261 | 262 | if not match: 263 | return None 264 | 265 | kw: Dict[str, Any] = match.groupdict() 266 | sign = -1 if kw.pop("sign", "+") == "-" else 1 267 | if kw.get("microseconds"): 268 | kw["microseconds"] = kw["microseconds"].ljust(6, "0") 269 | kw = {k: float(v.replace(",", ".")) for k, v in kw.items() if v is not None} 270 | days = timedelta(kw.pop("days", 0.0) or 0.0) 271 | if match.re == iso8601_duration_re: 272 | days *= sign 273 | return days + sign * timedelta(**kw) 274 | 275 | 276 | def is_falsy_value(value: Any) -> bool: 277 | if value is None: 278 | return True 279 | elif not value and isinstance(value, str): 280 | return True 281 | else: 282 | return False 283 | 284 | 285 | def choice_type_coerce_factory(type_: Any) -> Callable[[Any], Any]: 286 | from sqlalchemy_utils import Choice 287 | 288 | choices = type_.choices 289 | if isinstance(choices, type) and issubclass(choices, enum.Enum): 290 | key, choice_cls = "value", choices 291 | else: 292 | key, choice_cls = "code", Choice 293 | 294 | def choice_coerce(value: Any) -> Any: 295 | if value is None: 296 | return None 297 | 298 | return ( 299 | getattr(value, key) 300 | if isinstance(value, choice_cls) 301 | else type_.python_type(value) 302 | ) 303 | 304 | return choice_coerce 305 | 306 | 307 | def is_async_session_maker(session_maker: sessionmaker) -> bool: 308 | return AsyncSession in session_maker.class_.__mro__ 309 | -------------------------------------------------------------------------------- /sqladmin/pagination.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Any, List 3 | 4 | from litestar.datastructures import URL 5 | 6 | from sqladmin.utils import include_query_params 7 | 8 | 9 | @dataclass 10 | class PageControl: 11 | number: int 12 | url: str 13 | 14 | 15 | @dataclass 16 | class Pagination: 17 | rows: List[Any] 18 | page: int 19 | page_size: int 20 | count: int 21 | page_controls: List[PageControl] = field(default_factory=list) 22 | max_page_controls: int = 7 23 | 24 | @property 25 | def has_previous(self) -> bool: 26 | return self.page > 1 27 | 28 | @property 29 | def has_next(self) -> bool: 30 | next_page = (self.page + 1) * self.page_size 31 | return next_page <= self.count or next_page - self.count < self.page_size 32 | 33 | @property 34 | def previous_page(self) -> PageControl: 35 | for page_control in self.page_controls: 36 | if page_control.number == self.page - 1: 37 | return page_control 38 | 39 | raise RuntimeError("Previous page not found.") 40 | 41 | @property 42 | def next_page(self) -> PageControl: 43 | for page_control in self.page_controls: 44 | if page_control.number == self.page + 1: 45 | return page_control 46 | 47 | raise RuntimeError("Next page not found.") 48 | 49 | def add_pagination_urls(self, base_url: URL) -> None: 50 | # Previous pages 51 | for p in range(self.page - min(self.max_page_controls, 3), self.page): 52 | if p > 0: 53 | self._add_page_control(base_url, p) 54 | 55 | # Current page 56 | self._add_page_control(base_url, self.page) 57 | 58 | # Next pages 59 | for p in range(self.page + 1, self.page + self.max_page_controls + 1): 60 | current = p * self.page_size 61 | if current <= self.count or current - self.count < self.page_size: 62 | self._add_page_control(base_url, p) 63 | 64 | # Rebalance previous pages if next pages less than 3 65 | for p in range(self.page - self.max_page_controls - 3, self.page - 3): 66 | if p > 0: 67 | self._add_page_control(base_url, p) 68 | 69 | self.page_controls.sort(key=lambda p: p.number) 70 | 71 | def _add_page_control(self, base_url: URL, page: int) -> None: 72 | self.max_page_controls -= 1 73 | 74 | url = str(include_query_params(base_url, page=page)) 75 | 76 | page_control = PageControl(number=page, url=url) 77 | self.page_controls.append(page_control) 78 | -------------------------------------------------------------------------------- /sqladmin/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cemrehancavdar/sqladmin-litestar/29e2a23ba08bb04a3fc6e6c5598dc472ea5f9932/sqladmin/py.typed -------------------------------------------------------------------------------- /sqladmin/statics/css/main.css: -------------------------------------------------------------------------------- 1 | .table thead th { 2 | text-transform: none; 3 | } -------------------------------------------------------------------------------- /sqladmin/statics/js/main.js: -------------------------------------------------------------------------------- 1 | // Handle delete modal 2 | $(document).on('shown.bs.modal', '#modal-delete', function (event) { 3 | var element = $(event.relatedTarget); 4 | 5 | var name = element.data("name"); 6 | var pk = element.data("pk"); 7 | $("#modal-delete-text").text("This will permanently delete " + name + " " + pk + " ?"); 8 | 9 | $("#modal-delete-button").attr("data-url", element.data("url")); 10 | }); 11 | 12 | $(document).on('click', '#modal-delete-button', function () { 13 | $.ajax({ 14 | url: $(this).attr('data-url'), 15 | method: 'DELETE', 16 | success: function (result) { 17 | window.location.href = result; 18 | }, 19 | error: function (request, status, error) { 20 | alert(request.responseText); 21 | } 22 | }); 23 | }); 24 | 25 | // Search 26 | $(document).on('click', '#search-button', function () { 27 | var searchTerm = encodeURIComponent($("#search-input").val()); 28 | 29 | newUrl = ""; 30 | if (window.location.search && window.location.search.indexOf('search=') != -1) { 31 | newUrl = window.location.search.replace(/search=[^&]*/, "search=" + searchTerm); 32 | } else if (window.location.search) { 33 | newUrl = window.location.search + "&search=" + searchTerm; 34 | } else { 35 | newUrl = window.location.search + "?search=" + searchTerm; 36 | } 37 | window.location.href = newUrl; 38 | }); 39 | 40 | // Reset search 41 | $(document).on('click', '#search-reset', function () { 42 | if (window.location.search && window.location.search.indexOf('search=') != -1) { 43 | window.location.href = window.location.search.replace(/search=[^&]*/, ""); 44 | } 45 | }); 46 | 47 | // Press enter to search 48 | $(document).on('keypress', '#search-input', function (e) { 49 | if (e.which === 13) { 50 | $('#search-button').click(); 51 | } 52 | }); 53 | 54 | // Init a timeout variable to be used below 55 | var timeout = null; 56 | // Search 57 | $(document).on('keyup', '#search-input', function (e) { 58 | clearTimeout(timeout); 59 | // Make a new timeout set to go off in 1000ms (1 second) 60 | timeout = setTimeout(function () { 61 | $('#search-button').click(); 62 | }, 1000); 63 | }); 64 | 65 | // Date picker 66 | $(':input[data-role="datepicker"]:not([readonly])').each(function () { 67 | $(this).flatpickr({ 68 | enableTime: false, 69 | allowInput: true, 70 | dateFormat: "Y-m-d", 71 | }); 72 | }); 73 | 74 | // DateTime picker 75 | $(':input[data-role="datetimepicker"]:not([readonly])').each(function () { 76 | $(this).flatpickr({ 77 | enableTime: true, 78 | allowInput: true, 79 | enableSeconds: true, 80 | time_24hr: true, 81 | dateFormat: "Y-m-d H:i:s", 82 | }); 83 | }); 84 | 85 | // Ajax Refs 86 | $(':input[data-role="select2-ajax"]').each(function () { 87 | $(this).select2({ 88 | minimumInputLength: 1, 89 | ajax: { 90 | url: $(this).data("url"), 91 | dataType: 'json', 92 | data: function (params) { 93 | var query = { 94 | name: $(this).attr("name"), 95 | term: params.term, 96 | } 97 | return query; 98 | } 99 | } 100 | }); 101 | 102 | existing_data = $(this).data("json") || []; 103 | for (var i = 0; i < existing_data.length; i++) { 104 | data = existing_data[i]; 105 | var option = new Option(data.text, data.id, true, true); 106 | $(this).append(option).trigger('change'); 107 | } 108 | }); 109 | 110 | // Checkbox select 111 | $("#select-all").click(function () { 112 | $('input.select-box:checkbox').prop('checked', this.checked); 113 | }); 114 | 115 | // Bulk delete 116 | $("#action-delete").click(function () { 117 | var pks = []; 118 | $('.select-box').each(function () { 119 | if ($(this).is(':checked')) { 120 | pks.push($(this).siblings().get(0).value); 121 | } 122 | }); 123 | 124 | $('#action-delete').data("pk", pks); 125 | $('#action-delete').data("url", $(this).data('url') + '?pks=' + pks.join(",")); 126 | $('#modal-delete').modal('show'); 127 | }); 128 | 129 | $("[id^='action-custom-']").click(function () { 130 | var pks = []; 131 | $('.select-box').each(function () { 132 | if ($(this).is(':checked')) { 133 | pks.push($(this).siblings().get(0).value); 134 | } 135 | }); 136 | 137 | window.location.href = $(this).attr('data-url') + '?pks=' + pks.join(","); 138 | }); 139 | 140 | // Select2 Tags 141 | $(':input[data-role="select2-tags"]').each(function () { 142 | $(this).select2({ 143 | tags: true, 144 | multiple: true, 145 | }); 146 | 147 | existing_data = $(this).data("json") || []; 148 | for (var i = 0; i < existing_data.length; i++) { 149 | var option = new Option(existing_data[i], existing_data[i], true, true); 150 | $(this).append(option).trigger('change'); 151 | } 152 | }); 153 | -------------------------------------------------------------------------------- /sqladmin/statics/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cemrehancavdar/sqladmin-litestar/29e2a23ba08bb04a3fc6e6c5598dc472ea5f9932/sqladmin/statics/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /sqladmin/statics/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cemrehancavdar/sqladmin-litestar/29e2a23ba08bb04a3fc6e6c5598dc472ea5f9932/sqladmin/statics/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /sqladmin/statics/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cemrehancavdar/sqladmin-litestar/29e2a23ba08bb04a3fc6e6c5598dc472ea5f9932/sqladmin/statics/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /sqladmin/templates/_macros.html: -------------------------------------------------------------------------------- 1 | {% macro menu_category(menu, request) %} 2 | {% if menu.is_visible(request) and menu.is_accessible(request) %} 3 | 27 | {% endif %} 28 | {% endmacro %} 29 | 30 | {% macro menu_item(menu, request) %} 31 | {% if menu.is_visible(request) and menu.is_accessible(request) %} 32 | 40 | {% endif %} 41 | {% endmacro %} 42 | 43 | {% macro display_menu(menu, request) %} 44 | 53 | {% endmacro %} -------------------------------------------------------------------------------- /sqladmin/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% block head %} 13 | {% endblock %} 14 | {{ admin.title }} 15 | 16 | 17 | 18 | {% block body %} 19 |
20 | {% block main %} 21 | {% endblock %} 22 |
23 | {% endblock %} 24 | 26 | 28 | 30 | 32 | 34 | 36 | 37 | {% block tail %} 38 | {% endblock %} 39 | 40 | 41 | -------------------------------------------------------------------------------- /sqladmin/templates/create.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |
4 |
5 |
6 |

New {{ model_view.name }}

7 |
8 |
9 |
11 |
12 | {% if error %} 13 | 14 | {% endif %} 15 |
16 |
17 | {% for field in form %} 18 |
19 | {{ field.label(class_="form-label col-sm-2 col-form-label") }} 20 |
21 | {% if field.errors %} 22 | {{ field(class_="form-control is-invalid") }} 23 | {% else %} 24 | {{ field() }} 25 | {% endif %} 26 | {% for error in field.errors %} 27 |
{{ error }}
28 | {% endfor %} 29 |
30 |
31 | {% endfor %} 32 |
33 |
34 | 39 |
40 |
41 | 42 | 43 | 44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | {% endblock %} -------------------------------------------------------------------------------- /sqladmin/templates/details.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |
4 |
5 |
6 |

7 | {% for pk in model_view.pk_columns -%} 8 | {{ pk.name }} 9 | {%- if not loop.last %};{% endif -%} 10 | {% endfor %}: {{ get_object_identifier(model) }}

11 |
12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% for name in model_view._details_prop_names %} 23 | {% set label = model_view._column_labels.get(name, name) %} 24 | 25 | 26 | {% set value, formatted_value = detail_values[model][name] %} 27 | {% if name in model_view._relation_names %} 28 | {% if is_list( value ) %} 29 | 34 | {% else %} 35 | 37 | {% endif %} 38 | {% else %} 39 | 40 | {% endif %} 41 | 42 | {% endfor %} 43 | 44 |
ColumnValue
{{ label }} 30 | {% for elem, formatted_elem in zip(value, formatted_value) %} 31 | ({{ formatted_elem }}) 32 | {% endfor %} 33 | {{ formatted_value }} 36 | {{ formatted_value }}
45 |
46 | 86 |
87 |
88 |
89 | {% if model_view.can_delete %} 90 | {% include 'modals/delete.html' %} 91 | {% endif %} 92 | 93 | {% for custom_action in model_view._custom_actions_in_detail %} 94 | {% if custom_action in model_view._custom_actions_confirmation %} 95 | {% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action, 96 | url=model_view._url_for_action(request, custom_action) + '?pks=' + (get_object_identifier(model) | string) %} 97 | {% include 'modals/details_action_confirmation.html' %} 98 | {% endwith %} 99 | {% endif %} 100 | {% endfor %} 101 | 102 | {% endblock %} -------------------------------------------------------------------------------- /sqladmin/templates/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |
4 |
5 |
6 |

Edit {{ model_view.name }}

7 |
8 |
9 |
11 |
12 | {% if error %} 13 | 14 | {% endif %} 15 |
16 |
17 | {% for field in form %} 18 |
19 | {{ field.label(class_="form-label col-sm-2 col-form-label") }} 20 |
21 | {% if field.errors %} 22 | {{ field(class_="form-control is-invalid") }} 23 | {% else %} 24 | {{ field() }} 25 | {% endif %} 26 | {% for error in field.errors %} 27 |
{{ error }}
28 | {% endfor %} 29 |
30 |
31 | {% endfor %} 32 |
33 |
34 | 39 |
40 |
41 | 42 | 43 | {% if model_view.save_as %} 44 | 45 | {% else %} 46 | 47 | {% endif %} 48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | {% endblock %} -------------------------------------------------------------------------------- /sqladmin/templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 |
4 |
5 |
6 |
{{ status_code }}
7 |

{{ message }}

8 |
9 |
10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /sqladmin/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 | {% endblock %} -------------------------------------------------------------------------------- /sqladmin/templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from '_macros.html' import display_menu %} 3 | {% block body %} 4 |
5 | 33 |
34 |
35 | 45 |
46 |
47 |
48 |
49 | {% block content %} {% endblock %} 50 |
51 |
52 |
53 |
54 |
55 | {% endblock %} -------------------------------------------------------------------------------- /sqladmin/templates/list.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |
4 |
5 |
6 |

{{ model_view.name_plural }}

7 |
8 | {% if model_view.can_export %} 9 | {% if model_view.export_types | length > 1 %} 10 | 23 | {% elif model_view.export_types | length == 1 %} 24 | 30 | {% endif %} 31 | {% endif %} 32 | {% if model_view.can_create %} 33 | 38 | {% endif %} 39 |
40 |
41 |
42 |
43 | 72 | {% if model_view.column_searchable_list %} 73 |
74 |
75 | 78 | 79 | 81 |
82 |
83 | {% endif %} 84 |
85 |
86 |
87 | 88 | 89 | 90 | 92 | 93 | {% for name in model_view._list_prop_names %} 94 | {% set label = model_view._column_labels.get(name, name) %} 95 | 110 | {% endfor %} 111 | 112 | 113 | 114 | {% for row in pagination.rows %} 115 | 116 | 120 | 141 | {% for name in model_view._list_prop_names %} 142 | {% set value, formatted_value = list_values[row][name] %} 143 | {% if name in model_view._relation_names %} 144 | {% if is_list( value ) %} 145 | 150 | {% else %} 151 | 152 | {% endif %} 153 | {% else %} 154 | 155 | {% endif %} 156 | {% endfor %} 157 | 158 | {% endfor %} 159 | 160 |
96 | {% if name in model_view._sort_fields %} 97 | {% if request.query_params.get("sortBy") == name and request.query_params.get("sort") == "asc" %} 98 | {{ 99 | label }} 100 | {% elif request.query_params.get("sortBy") == name and request.query_params.get("sort") == "desc" %} 101 | {{ label 102 | }} 103 | {% else %} 104 | {{ label }} 105 | {% endif %} 106 | {% else %} 107 | {{ label }} 108 | {% endif %} 109 |
117 | 118 | 119 | 121 | {% if model_view.can_view_details %} 122 | 124 | 125 | 126 | {% endif %} 127 | {% if model_view.can_edit %} 128 | 130 | 131 | 132 | {% endif %} 133 | {% if model_view.can_delete %} 134 | 137 | 138 | 139 | {% endif %} 140 | 146 | {% for elem, formatted_elem in zip(value, formatted_value) %} 147 | ({{ formatted_elem }}) 148 | {% endfor %} 149 | {{ formatted_value }}{{ formatted_value }}
161 |
162 | 208 |
209 | {% if model_view.can_delete %} 210 | {% include 'modals/delete.html' %} 211 | {% endif %} 212 | 213 | {% for custom_action in model_view._custom_actions_in_list %} 214 | {% if custom_action in model_view._custom_actions_confirmation %} 215 | {% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action, 216 | url=model_view._url_for_action(request, custom_action) %} 217 | {% include 'modals/list_action_confirmation.html' %} 218 | {% endwith %} 219 | {% endif %} 220 | {% endfor %} 221 |
222 | {% endblock %} -------------------------------------------------------------------------------- /sqladmin/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block body %} 3 |
4 |
5 |
6 |

Login to {{ admin.title }}

7 |
8 | 9 | {% if error %} 10 | 12 |
{{ error }}
13 | {% else %} 14 | 15 | {% endif %} 16 |
17 |
18 | 21 |
22 | {% if error %} 23 | 25 |
{{ error }}
26 | {% else %} 27 | 28 | {% endif %} 29 |
30 |
31 | 34 |
35 |
36 |
37 | {% endblock %} -------------------------------------------------------------------------------- /sqladmin/templates/modals/delete.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sqladmin/templates/modals/details_action_confirmation.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sqladmin/templates/modals/list_action_confirmation.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sqladmin/templating.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | import jinja2 4 | from litestar import Request 5 | from litestar.datastructures import URL 6 | from litestar.response import Response 7 | 8 | from sqladmin.utils import include_query_params 9 | 10 | 11 | class Jinja2Templates: 12 | def __init__(self, directory: str) -> None: 13 | @jinja2.pass_context 14 | def url_for(context: Dict, __name: str, **path_params: Any) -> URL: 15 | request = context["request"] 16 | return request.url_for(__name, **path_params) 17 | 18 | @jinja2.pass_context 19 | def url_for_static_asset(context: Dict, __name: str, **path_params: Any) -> str: 20 | request: Request = context["request"] 21 | return request.url_for_static_asset(__name, **path_params) 22 | 23 | loader = jinja2.FileSystemLoader(directory) 24 | self.env = jinja2.Environment(loader=loader, autoescape=True) 25 | self.env.globals["url_for"] = url_for 26 | self.env.globals["url_for_static_asset"] = url_for_static_asset 27 | self.env.globals["include_query_params"] = include_query_params 28 | 29 | def TemplateResponse( 30 | self, 31 | request: Request, 32 | name: str, 33 | context: Optional[Dict] = None, 34 | status_code: int = 200, 35 | ) -> Response: 36 | context = context or {} 37 | context.setdefault("request", request) 38 | template = self.env.get_template(name) 39 | content = template.render(context) 40 | return Response(content, media_type="text/html", status_code=status_code) 41 | -------------------------------------------------------------------------------- /sqladmin/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from litestar.datastructures import URL 4 | 5 | 6 | def include_query_params(url: URL, **kwargs: Any) -> URL: 7 | query_params = url.query_params.copy() 8 | query_params.update(kwargs) 9 | return url.with_replacements(query=query_params) 10 | -------------------------------------------------------------------------------- /sqladmin/widgets.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | from markupsafe import Markup 5 | from wtforms import Field, widgets 6 | from wtforms.widgets import html_params 7 | 8 | __all__ = [ 9 | "AjaxSelect2Widget", 10 | "DatePickerWidget", 11 | "DateTimePickerWidget", 12 | "Select2TagsWidget", 13 | ] 14 | 15 | 16 | class DatePickerWidget(widgets.TextInput): 17 | """ 18 | Date picker widget. 19 | """ 20 | 21 | def __call__(self, field: Field, **kwargs: Any) -> str: 22 | kwargs.setdefault("data-role", "datepicker") 23 | return super().__call__(field, **kwargs) 24 | 25 | 26 | class DateTimePickerWidget(widgets.TextInput): 27 | """ 28 | Datetime picker widget. 29 | """ 30 | 31 | def __call__(self, field: Field, **kwargs: Any) -> str: 32 | kwargs.setdefault("data-role", "datetimepicker") 33 | return super().__call__(field, **kwargs) 34 | 35 | 36 | class AjaxSelect2Widget(widgets.Select): 37 | def __init__(self, multiple: bool = False): 38 | self.multiple = multiple 39 | self.lookup_url = "" 40 | 41 | def __call__(self, field: Field, **kwargs: Any) -> Markup: 42 | kwargs.setdefault("data-role", "select2-ajax") 43 | kwargs.setdefault("data-url", field.loader.model_admin.ajax_lookup_url) 44 | 45 | allow_blank = getattr(field, "allow_blank", False) 46 | if allow_blank and not self.multiple: 47 | kwargs["data-allow-blank"] = "1" 48 | 49 | kwargs.setdefault("id", field.id) 50 | kwargs.setdefault("type", "hidden") 51 | 52 | if self.multiple: 53 | result = [field.loader.format(value) for value in field.data] 54 | kwargs["data-json"] = json.dumps(result) 55 | kwargs["multiple"] = "1" 56 | else: 57 | data = field.loader.format(field.data) 58 | if data: 59 | kwargs["data-json"] = json.dumps([data]) 60 | 61 | return Markup(f"") 62 | 63 | 64 | class Select2TagsWidget(widgets.Select): 65 | def __call__(self, field: Field, **kwargs: Any) -> str: 66 | kwargs.setdefault("data-role", "select2-tags") 67 | kwargs.setdefault("data-json", json.dumps(field.data)) 68 | kwargs.setdefault("multiple", "multiple") 69 | return super().__call__(field, **kwargs) 70 | 71 | 72 | class FileInputWidget(widgets.FileInput): 73 | """ 74 | File input widget with clear checkbox. 75 | """ 76 | 77 | def __call__(self, field: Field, **kwargs: Any) -> str: 78 | file_input = super().__call__(field, **kwargs) 79 | checkbox_id = f"{field.id}_checkbox" 80 | checkbox_label = Markup( 81 | f'' 82 | ) 83 | checkbox_input = Markup( 84 | f'' # noqa: E501 85 | ) 86 | checkbox = Markup( 87 | f'
{checkbox_input}{checkbox_label}
' 88 | ) 89 | return file_input + checkbox 90 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cemrehancavdar/sqladmin-litestar/29e2a23ba08bb04a3fc6e6c5598dc472ea5f9932/tests/__init__.py -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any, List 3 | 4 | from sqlalchemy import create_engine 5 | from sqlalchemy.ext.asyncio import create_async_engine 6 | 7 | test_database_uri_sync = os.environ.get( 8 | "TEST_DATABASE_URI_SYNC", "sqlite:///test.db?check_same_thread=False" 9 | ) 10 | test_database_uri_async = os.environ.get( 11 | "TEST_DATABASE_URI_ASYNC", 12 | "sqlite+aiosqlite:///test.db", 13 | ) 14 | 15 | sync_engine = create_engine(test_database_uri_sync) 16 | async_engine = create_async_engine(test_database_uri_async) 17 | 18 | 19 | class DummyData(dict): # pragma: no cover 20 | def getlist(self, key: str) -> List[Any]: 21 | v = self[key] 22 | if not isinstance(v, (list, tuple)): 23 | v = [v] 24 | return v 25 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture(scope="module") 7 | def anyio_backend() -> Tuple[str, dict]: 8 | return ("asyncio", {"debug": True}) 9 | -------------------------------------------------------------------------------- /tests/dont_test_file_upload.py: -------------------------------------------------------------------------------- 1 | # this th 2 | from typing import Any, AsyncGenerator 3 | 4 | import pytest 5 | from fastapi_storages import FileSystemStorage, StorageFile 6 | from fastapi_storages.integrations.sqlalchemy import FileType, ImageType 7 | from httpx import AsyncClient 8 | from sqlalchemy import Column, Integer, select 9 | from sqlalchemy.ext.asyncio import AsyncSession 10 | from sqlalchemy.orm import declarative_base, sessionmaker 11 | from starlette.applications import Starlette 12 | 13 | from sqladmin import Admin, ModelView 14 | from tests.common import async_engine as engine 15 | 16 | pytestmark = pytest.mark.anyio 17 | 18 | Base = declarative_base() # type: Any 19 | session_maker = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) 20 | 21 | app = Starlette() 22 | admin = Admin(app=app, engine=engine) 23 | 24 | 25 | class User(Base): 26 | __tablename__ = "users" 27 | 28 | id = Column(Integer, primary_key=True) 29 | file = Column(FileType(FileSystemStorage(".uploads"))) 30 | image = Column(ImageType(FileSystemStorage(".uploads"))) 31 | 32 | 33 | @pytest.fixture 34 | async def prepare_database() -> AsyncGenerator[None, None]: 35 | async with engine.begin() as conn: 36 | await conn.run_sync(Base.metadata.create_all) 37 | yield 38 | async with engine.begin() as conn: 39 | await conn.run_sync(Base.metadata.drop_all) 40 | 41 | await engine.dispose() 42 | 43 | 44 | @pytest.fixture 45 | async def client(prepare_database: Any) -> AsyncGenerator[AsyncClient, None]: 46 | async with AsyncClient(app=app, base_url="http://testserver") as c: 47 | yield c 48 | 49 | 50 | class UserAdmin(ModelView, model=User): 51 | ... 52 | 53 | 54 | admin.add_view(UserAdmin) 55 | 56 | 57 | async def _query_user() -> Any: 58 | stmt = select(User).limit(1) 59 | async with session_maker() as s: 60 | result = await s.execute(stmt) 61 | return result.scalar_one() 62 | 63 | 64 | async def test_create_form_fields(client: AsyncClient) -> None: 65 | response = await client.get("/admin/user/create") 66 | 67 | assert response.status_code == 200 68 | assert ( 69 | '' 70 | in response.text 71 | ) 72 | assert 'Clear' 75 | in response.text 76 | ) 77 | 78 | 79 | async def test_create_form_post(client: AsyncClient) -> None: 80 | files = {"file": ("upload.txt", b"abc")} 81 | response = await client.post("/admin/user/create", files=files) 82 | 83 | user = await _query_user() 84 | 85 | assert response.status_code == 302 86 | assert isinstance(user.file, StorageFile) is True 87 | assert user.file.name == "upload.txt" 88 | assert user.file.path == ".uploads/upload.txt" 89 | assert user.file.open().read() == b"abc" 90 | 91 | 92 | async def test_create_form_update(client: AsyncClient) -> None: 93 | files = {"file": ("upload.txt", b"abc")} 94 | response = await client.post("/admin/user/create", files=files) 95 | 96 | user = await _query_user() 97 | 98 | files = {"file": ("new_upload.txt", b"abc")} 99 | response = await client.post("/admin/user/edit/1", files=files) 100 | 101 | user = await _query_user() 102 | assert response.status_code == 302 103 | assert user.file.name == "new_upload.txt" 104 | assert user.file.path == ".uploads/new_upload.txt" 105 | 106 | files = {"file": ("empty.txt", b"")} 107 | response = await client.post("/admin/user/edit/1", files=files) 108 | 109 | user = await _query_user() 110 | assert user.file.name == "new_upload.txt" 111 | assert user.file.path == ".uploads/new_upload.txt" 112 | 113 | files = {"file": ("new_upload.txt", b"abc")} 114 | response = await client.post( 115 | "/admin/user/edit/1", files=files, data={"file_checkbox": True} 116 | ) 117 | 118 | user = await _query_user() 119 | assert user.file is None 120 | -------------------------------------------------------------------------------- /tests/templates/custom.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% block content %} 3 |

Here I'm going to display some data.

4 | {% endblock %} -------------------------------------------------------------------------------- /tests/test_ajax.py: -------------------------------------------------------------------------------- 1 | from typing import Any, AsyncGenerator 2 | 3 | import pytest 4 | from httpx import AsyncClient 5 | from litestar import Litestar 6 | from sqlalchemy import Column, ForeignKey, Integer, String, select 7 | from sqlalchemy.ext.asyncio import AsyncSession 8 | from sqlalchemy.orm import declarative_base, relationship, selectinload, sessionmaker 9 | 10 | from sqladmin import Admin, ModelView 11 | from sqladmin.ajax import create_ajax_loader 12 | from tests.common import async_engine as engine 13 | 14 | pytestmark = pytest.mark.anyio 15 | 16 | Base = declarative_base() # type: Any 17 | session_maker = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) 18 | 19 | app = Litestar() 20 | admin = Admin(app=app, engine=engine) 21 | 22 | 23 | class User(Base): 24 | __tablename__ = "users" 25 | 26 | id = Column(Integer, primary_key=True) 27 | name = Column(String(length=16)) 28 | 29 | addresses = relationship("Address", back_populates="user") 30 | 31 | def __str__(self) -> str: 32 | return f"User {self.id}" 33 | 34 | 35 | class Address(Base): 36 | __tablename__ = "addresses" 37 | 38 | id = Column(Integer, primary_key=True) 39 | user_id = Column(Integer, ForeignKey("users.id")) 40 | 41 | user = relationship("User", back_populates="addresses") 42 | 43 | def __str__(self) -> str: 44 | return f"Address {self.id}" 45 | 46 | 47 | class UserAdmin(ModelView, model=User): 48 | form_ajax_refs = { 49 | "addresses": { 50 | "fields": ("id",), 51 | } 52 | } 53 | 54 | 55 | class AddressAdmin(ModelView, model=Address): 56 | form_ajax_refs = { 57 | "user": { 58 | "fields": ("name",), 59 | "order_by": ("id"), 60 | } 61 | } 62 | 63 | 64 | admin.add_view(UserAdmin) 65 | admin.add_view(AddressAdmin) 66 | 67 | 68 | @pytest.fixture 69 | async def prepare_database() -> AsyncGenerator[None, None]: 70 | async with engine.begin() as conn: 71 | await conn.run_sync(Base.metadata.create_all) 72 | yield 73 | async with engine.begin() as conn: 74 | await conn.run_sync(Base.metadata.drop_all) 75 | 76 | await engine.dispose() 77 | 78 | 79 | @pytest.fixture 80 | async def client(prepare_database: Any) -> AsyncGenerator[AsyncClient, None]: 81 | async with AsyncClient(app=app, base_url="http://testserver") as c: 82 | yield c 83 | 84 | 85 | async def test_ajax_lookup_invalid_query_params(client: AsyncClient) -> None: 86 | response = await client.get("/admin/user/ajax/lookup") 87 | assert response.status_code == 400 88 | 89 | response = await client.get("/admin/address/ajax/lookup") 90 | assert response.status_code == 400 91 | 92 | response = await client.get("/admin/user/ajax/lookup?name=test&term=x") 93 | assert response.status_code == 400 94 | 95 | 96 | async def test_ajax_response(client: AsyncClient) -> None: 97 | user = User(name="John Snow") 98 | async with session_maker() as s: 99 | s.add(user) 100 | await s.commit() 101 | 102 | response = await client.get("/admin/address/ajax/lookup?name=user&term=john") 103 | 104 | assert response.status_code == 200 105 | assert response.json() == {"results": [{"id": "1", "text": "User 1"}]} 106 | 107 | 108 | async def test_create_ajax_loader_exceptions() -> None: 109 | with pytest.raises(ValueError): 110 | create_ajax_loader(model_admin=AddressAdmin(), name="x", options={}) 111 | 112 | with pytest.raises(ValueError): 113 | create_ajax_loader(model_admin=AddressAdmin(), name="user", options={}) 114 | 115 | 116 | async def test_create_page_template(client: AsyncClient) -> None: 117 | response = await client.get("/admin/user/create") 118 | 119 | assert 'data-json="[]"' in response.text 120 | assert 'data-role="select2-ajax"' in response.text 121 | assert 'data-url="/admin/user/ajax/lookup"' in response.text 122 | 123 | response = await client.get("/admin/address/create") 124 | 125 | assert 'data-role="select2-ajax"' in response.text 126 | assert 'data-url="/admin/address/ajax/lookup"' in response.text 127 | 128 | 129 | async def test_edit_page_template(client: AsyncClient) -> None: 130 | user = User(name="John Snow") 131 | async with session_maker() as s: 132 | s.add(user) 133 | await s.flush() 134 | 135 | address = Address(user=user) 136 | s.add(address) 137 | await s.commit() 138 | 139 | response = await client.get("/admin/user/edit/1") 140 | assert ( 141 | 'data-json="[{"id": "1", "text": "Address 1"}]"' 142 | in response.text 143 | ) 144 | assert 'data-role="select2-ajax"' in response.text 145 | assert 'data-url="/admin/user/ajax/lookup"' in response.text 146 | 147 | response = await client.get("/admin/address/edit/1") 148 | assert ( 149 | 'data-json="[{"id": "1", "text": "User 1"}]"' 150 | in response.text 151 | ) 152 | assert 'data-role="select2-ajax"' in response.text 153 | assert 'data-url="/admin/address/ajax/lookup"' in response.text 154 | 155 | 156 | async def test_create_and_edit_forms(client: AsyncClient) -> None: 157 | response = await client.post("/admin/address/create", data={}) 158 | assert response.status_code == 302 159 | response = await client.post("/admin/address/create", data={"id": "2"}) 160 | assert response.status_code == 302 161 | 162 | data = {"addresses": ["1"], "name": "Tyrion"} 163 | response = await client.post("/admin/user/create", data=data) 164 | assert response.status_code == 302 165 | 166 | data = {} 167 | response = await client.post("/admin/address/edit/1", data=data) 168 | assert response.status_code == 302 169 | 170 | async with session_maker() as s: 171 | stmt = select(User).options(selectinload(User.addresses)) 172 | result = await s.execute(stmt) 173 | 174 | user = result.scalar_one() 175 | assert len(user.addresses) == 0 176 | 177 | data = {"addresses": ["1"]} 178 | response = await client.post("/admin/user/edit/1", data=data) 179 | assert response.status_code == 302 180 | 181 | async with session_maker() as s: 182 | stmt = select(User).options(selectinload(User.addresses)) 183 | result = await s.execute(stmt) 184 | 185 | user = result.scalar_one() 186 | assert len(user.addresses) == 1 187 | 188 | data = {"addresses": ["1", "2"]} 189 | response = await client.post("/admin/user/edit/1", data=data) 190 | assert response.status_code == 302 191 | 192 | async with session_maker() as s: 193 | stmt = select(User).options(selectinload(User.addresses)) 194 | result = await s.execute(stmt) 195 | 196 | user = result.scalar_one() 197 | assert len(user.addresses) == 2 198 | -------------------------------------------------------------------------------- /tests/test_application.py: -------------------------------------------------------------------------------- 1 | from litestar import Litestar, Request, route 2 | from litestar.datastructures import MutableScopeHeaders as MutableHeaders 3 | from litestar.middleware import DefineMiddleware as Middleware 4 | from litestar.response import Response 5 | from litestar.testing import TestClient 6 | from litestar.types import ASGIApp, Message, Receive, Scope, Send 7 | from sqlalchemy import Column, Integer, String 8 | from sqlalchemy.orm import declarative_base 9 | 10 | from sqladmin import Admin, ModelView 11 | from tests.common import sync_engine as engine 12 | 13 | Base = declarative_base() # type: ignore 14 | 15 | 16 | class DataModel(Base): 17 | __tablename__ = "datamodel" 18 | id = Column(Integer, primary_key=True) 19 | data = Column(String) 20 | 21 | 22 | class User(Base): 23 | __tablename__ = "users" 24 | 25 | id = Column(Integer, primary_key=True) 26 | name = Column(String(32), default="SQLAdmin") 27 | 28 | 29 | def test_application_title() -> None: 30 | app = Litestar() 31 | Admin(app=app, engine=engine) 32 | 33 | with TestClient(app) as client: 34 | response = client.get("/admin") 35 | 36 | assert response.status_code == 200 37 | assert "

Admin

" in response.text 38 | assert "Admin" in response.text 39 | 40 | 41 | def test_application_logo() -> None: 42 | app = Litestar() 43 | Admin( 44 | app=app, 45 | engine=engine, 46 | logo_url="https://example.com/logo.svg", 47 | base_url="/dashboard", 48 | ) 49 | 50 | with TestClient(app) as client: 51 | response = client.get("/dashboard") 52 | 53 | assert response.status_code == 200 54 | assert ( 55 | ' None: 61 | class CorrelationIdMiddleware: 62 | def __init__(self, app: ASGIApp) -> None: 63 | self.app = app 64 | 65 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 66 | async def send_wrapper(message: Message) -> None: 67 | if message["type"] == "http.response.start": 68 | headers = MutableHeaders(scope=message) 69 | headers.add("X-Correlation-ID", "UUID") 70 | await send(message) 71 | 72 | await self.app(scope, receive, send_wrapper) 73 | 74 | app = Litestar() 75 | Admin( 76 | app=app, 77 | engine=engine, 78 | middlewares=[Middleware(CorrelationIdMiddleware)], 79 | ) 80 | 81 | with TestClient(app) as client: 82 | response = client.get("/admin") 83 | 84 | assert response.status_code == 200 85 | assert "x-correlation-id" in response.headers 86 | 87 | 88 | def test_get_save_redirect_url(): 89 | @route("/x/{identity:str}", http_method=["POST"]) 90 | async def index(request: Request) -> Response: 91 | obj = User(id=1) 92 | form_data = await request.form() 93 | url = admin.get_save_redirect_url(request, form_data, admin.views[0], obj) 94 | return Response(str(url)) 95 | 96 | app = Litestar(route_handlers=[index]) 97 | admin = Admin(app=app, engine=engine) 98 | 99 | class UserAdmin(ModelView, model=User): 100 | save_as = True 101 | 102 | admin.add_view(UserAdmin) 103 | 104 | client = TestClient(app) 105 | 106 | response = client.post("/x/user", data={"save": "Save"}) 107 | assert response.text == "http://testserver/admin/user/list" 108 | 109 | response = client.post("/x/user", data={"save": "Save and continue editing"}) 110 | assert response.text == "http://testserver/admin/user/edit/1" 111 | 112 | response = client.post("/x/user", data={"save": "Save as new"}) 113 | assert response.text == "http://testserver/admin/user/edit/1" 114 | 115 | response = client.post("/x/user", data={"save": "Save and add another"}) 116 | assert response.text == "http://testserver/admin/user/create" 117 | 118 | 119 | def test_build_category_menu(): 120 | app = Litestar() 121 | admin = Admin(app=app, engine=engine) 122 | 123 | class UserAdmin(ModelView, model=User): 124 | category = "Accounts" 125 | 126 | admin.add_view(UserAdmin) 127 | 128 | admin._menu.items.pop().name = "Accounts" 129 | 130 | 131 | def test_normalize_wtform_fields() -> None: 132 | app = Litestar() 133 | admin = Admin(app=app, engine=engine) 134 | 135 | class DataModelAdmin(ModelView, model=DataModel): 136 | ... 137 | 138 | datamodel = DataModel(id=1, data="abcdef") 139 | admin.add_view(DataModelAdmin) 140 | assert admin._normalize_wtform_data(datamodel) == {"data_": "abcdef"} 141 | 142 | 143 | def test_denormalize_wtform_fields() -> None: 144 | app = Litestar() 145 | admin = Admin(app=app, engine=engine) 146 | 147 | class DataModelAdmin(ModelView, model=DataModel): 148 | ... 149 | 150 | datamodel = DataModel(id=1, data="abcdef") 151 | admin.add_view(DataModelAdmin) 152 | assert admin._denormalize_wtform_data({"data_": "abcdef"}, datamodel) == { 153 | "data": "abcdef" 154 | } 155 | -------------------------------------------------------------------------------- /tests/test_authentication.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | import pytest 4 | from litestar import Litestar, Request, Response 5 | from litestar.response import Redirect 6 | from litestar.testing import TestClient 7 | 8 | from sqladmin import Admin 9 | from sqladmin.authentication import AuthenticationBackend 10 | from tests.common import sync_engine as engine 11 | 12 | 13 | class CustomBackend(AuthenticationBackend): 14 | async def login(self, request: Request) -> bool: 15 | form = await request.form() 16 | if form["username"] != "a": 17 | return False 18 | 19 | request.session.update({"token": "amin"}) 20 | return True 21 | 22 | async def logout(self, request: Request) -> bool: 23 | request.session.clear() 24 | request.cookies.clear() 25 | return True 26 | 27 | async def authenticate(self, request: Request) -> bool | Response: 28 | if "token" in request.session: 29 | return Redirect(request.url_for("admin:login"), status_code=302) 30 | return False 31 | 32 | 33 | app = Litestar() 34 | authentication_backend = CustomBackend(secret_key="1234567891234561") 35 | admin = Admin(app=app, engine=engine, authentication_backend=authentication_backend) 36 | 37 | 38 | @pytest.fixture 39 | def client() -> Generator[TestClient, None, None]: 40 | with TestClient(app=app, base_url="http://testserver") as c: 41 | yield c 42 | 43 | 44 | def test_access_logion_required_views(client: TestClient) -> None: 45 | response = client.get("/admin/") 46 | assert response.url == "http://testserver/admin/login" 47 | 48 | response = client.get("/admin/users/list") 49 | assert response.url == "http://testserver/admin/login" 50 | 51 | 52 | def test_login_failure(client: TestClient) -> None: 53 | response = client.post("/admin/login", data={"username": "x", "password": "b"}) 54 | 55 | assert response.status_code == 400 56 | assert response.url == "http://testserver/admin/login" 57 | 58 | 59 | def test_login(client: TestClient) -> None: 60 | response = client.post("/admin/login", data={"username": "a", "password": "b"}) 61 | 62 | assert len(response.cookies) == 1 63 | assert response.status_code == 200 64 | 65 | 66 | def test_logout(client: TestClient) -> None: 67 | response = client.get("/admin/logout") 68 | 69 | assert len(response.cookies) == 0 70 | assert response.status_code == 200 71 | assert response.url == "http://testserver/admin/login" 72 | -------------------------------------------------------------------------------- /tests/test_base_view.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | import pytest 4 | from litestar import Litestar, Request, Response 5 | from litestar.testing import TestClient 6 | from sqlalchemy.orm import declarative_base 7 | 8 | from sqladmin import Admin, BaseView, expose 9 | from tests.common import sync_engine as engine 10 | 11 | Base = declarative_base() # type: ignore 12 | 13 | app = Litestar() 14 | admin = Admin(app=app, engine=engine, templates_dir="tests/templates") 15 | 16 | 17 | class CustomAdmin(BaseView): 18 | name = "test" 19 | icon = "fa fa-test" 20 | 21 | @expose("/custom", methods=["GET"]) 22 | async def custom(self, request: Request) -> Response: 23 | return self.templates.TemplateResponse(request, "custom.html") 24 | 25 | @expose("/custom/report") 26 | async def custom_report(self, request: Request) -> Response: 27 | return self.templates.TemplateResponse(request, "custom.html") 28 | 29 | 30 | @pytest.fixture 31 | def client() -> Generator[TestClient, None, None]: 32 | with TestClient(app=app, base_url="http://testserver") as c: 33 | yield c 34 | 35 | 36 | def test_base_view(client: TestClient) -> None: 37 | admin.add_view(CustomAdmin) 38 | 39 | response = client.get("/admin/custom") 40 | 41 | assert response.status_code == 200 42 | assert "

Here I'm going to display some data.

" in response.text 43 | 44 | response = client.get("/admin/custom/report") 45 | assert response.status_code == 200 46 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, timedelta 2 | from typing import Generator 3 | 4 | import pytest 5 | from sqlalchemy import Column, Integer, String 6 | from sqlalchemy.orm import declarative_base 7 | from wtforms import Form 8 | 9 | from sqladmin.fields import ( 10 | DateField, 11 | DateTimeField, 12 | IntervalField, 13 | JSONField, 14 | QuerySelectField, 15 | QuerySelectMultipleField, 16 | Select2TagsField, 17 | SelectField, 18 | ) 19 | from tests.common import DummyData 20 | from tests.common import sync_engine as engine 21 | 22 | Base = declarative_base() # type: ignore 23 | 24 | 25 | class User(Base): 26 | __tablename__ = "users" 27 | 28 | id = Column(Integer, primary_key=True) 29 | name = Column(String) 30 | 31 | 32 | @pytest.fixture(autouse=True, scope="function") 33 | def prepare_database() -> Generator[None, None, None]: 34 | Base.metadata.create_all(engine) 35 | yield 36 | Base.metadata.drop_all(engine) 37 | 38 | 39 | def test_date_field() -> None: 40 | class F(Form): 41 | date = DateField() 42 | 43 | form = F() 44 | 45 | assert form.date.format == ["%Y-%m-%d"] 46 | assert 'data-role="datepicker"' in form.date() 47 | 48 | form = F(DummyData(date=["2021-12-22"])) 49 | assert form.date.data == date(2021, 12, 22) 50 | 51 | 52 | def test_datetime_field() -> None: 53 | class F(Form): 54 | datetime = DateTimeField() 55 | 56 | form = F() 57 | 58 | assert form.datetime.format == ["%Y-%m-%d %H:%M:%S"] 59 | assert 'data-role="datetimepicker"' in form.datetime() 60 | 61 | form = F(DummyData(datetime=["2021-12-22 12:30:00"])) 62 | assert form.datetime.data == datetime(2021, 12, 22, 12, 30, 0, 0) 63 | 64 | 65 | def test_json_field() -> None: 66 | class F(Form): 67 | json = JSONField() 68 | 69 | form = F() 70 | assert form.json() == """""" 71 | 72 | form = F(DummyData(json=[""])) 73 | assert form.json.data is None 74 | 75 | form = F(DummyData(json=['{"a": 1}'])) 76 | assert form.json.data == {"a": 1} 77 | assert ( 78 | form.json() 79 | == """""" 80 | ) 81 | 82 | form = F(DummyData(json=["""'{"A": 10}'"""])) 83 | assert form.json.data is None 84 | 85 | 86 | def test_select_field() -> None: 87 | class F(Form): 88 | select = SelectField( 89 | choices=[(1, "A"), (2, "B")], 90 | coerce=int, 91 | ) 92 | 93 | form = F() 94 | assert '' in form.select() 95 | 96 | form = F(DummyData(select=["1"])) 97 | assert form.validate() is True 98 | assert form.select.data == 1 99 | 100 | form = F(DummyData(select=["A"])) 101 | assert form.validate() is False 102 | assert form.select.data is None 103 | 104 | class F(Form): # type: ignore 105 | select = SelectField(coerce=int, allow_blank=True) 106 | 107 | form = F() 108 | assert '