diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..2ff985a67 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +charset = utf-8 +max_line_length = 88 + +[*.{css,html,js,json,jsx,scss,ts,tsx,yaml,yml}] +indent_size = 2 diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 000000000..f1377a89c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Report a bug in Flask-Admin (not other projects which depend on Flask-Admin) +--- + + + + + + + +Environment: + +- Python version: +- Flask version: +- Flask-Admin version: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..4e64f3d22 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Questions on Discussions + url: https://github.com/pallets-eco/flask-admin/discussions/ + about: Ask questions about your own code on the Discussions tab. + - name: Questions on Chat + url: https://discord.gg/pallets + about: Ask questions about your own code on our Discord chat. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 000000000..39c8f0875 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,15 @@ +--- +name: Feature request +about: Suggest a new feature for Flask-Admin +--- + + + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..1f47f125e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + github-actions: + patterns: + - '*' + - package-ecosystem: pip + directory: /requirements/ + schedule: + interval: monthly + groups: + python-requirements: + patterns: + - '*' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..dbb67e35f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ + + + + + diff --git a/.github/workflows/lock.yaml b/.github/workflows/lock.yaml new file mode 100644 index 000000000..22228a1cd --- /dev/null +++ b/.github/workflows/lock.yaml @@ -0,0 +1,23 @@ +name: Lock inactive closed issues +# Lock closed issues that have not received any further activity for two weeks. +# This does not close open issues, only humans may do that. It is easier to +# respond to new issues with fresh examples rather than continuing discussions +# on old issues. + +on: + schedule: + - cron: '0 0 * * *' +permissions: + issues: write + pull-requests: write +concurrency: + group: lock +jobs: + lock: + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 + with: + issue-inactive-days: 14 + pr-inactive-days: 14 + discussion-inactive-days: 14 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 000000000..ae7950395 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,73 @@ +name: Publish +on: + push: + tags: + - '*' +jobs: + build: + runs-on: ubuntu-latest + outputs: + hash: ${{ steps.hash.outputs.hash }} + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 + with: + python-version: '3.x' + cache: pip + cache-dependency-path: requirements*/*.txt + - run: pip install -r requirements/build.txt + # Use the commit date instead of the current date during the build. + - run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV + - run: python -m build + # Generate hashes used for provenance. + - name: generate hash + id: hash + run: cd dist && echo "hash=$(sha256sum * | base64 -w0)" >> $GITHUB_OUTPUT + - uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 + with: + path: ./dist + provenance: + needs: [build] + permissions: + actions: read + id-token: write + contents: write + # Can't pin with hash due to how this workflow works. + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 + with: + base64-subjects: ${{ needs.build.outputs.hash }} + create-release: + # Upload the sdist, wheels, and provenance to a GitHub release. They remain + # available as build artifacts for a while as well. + needs: [provenance] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + - name: create release + run: > + gh release create --draft --repo ${{ github.repository }} + ${{ github.ref_name }} + *.intoto.jsonl/* artifact/* + env: + GH_TOKEN: ${{ github.token }} + publish-pypi: + needs: [provenance] + # Wait for approval before attempting to upload to PyPI. This allows reviewing the + # files in the draft release. + environment: + name: publish + url: https://pypi.org/project/Flask-Admin/${{ github.ref_name }} + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + - uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 # v1.9.0 + with: + repository-url: https://test.pypi.org/legacy/ + packages-dir: artifact/ + - uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 # v1.9.0 + with: + packages-dir: artifact/ diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml deleted file mode 100644 index 869510403..000000000 --- a/.github/workflows/test.yaml +++ /dev/null @@ -1,62 +0,0 @@ -name: Run tests -on: - - pull_request - - push - -jobs: - test-job: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] - tox-version: [ 'WTForms2' ] - include: - - python-version: 3.6 - tox-version: flake8 - - python-version: 3.6 - tox-version: docs-html - services: - # Label used to access the service container - postgres: - # Docker Hub image - image: postgis/postgis:12-master # postgres with postgis installed - # Provide the password for postgres - env: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: flask_admin_test - ports: - - 5432:5432 - # Set health checks to wait until postgres has started - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - mongo: - image: mongo:5.0.4-focal - ports: - - 27017:27017 - azurite: - image: arafato/azurite:2.6.5 - env: - executable: blob - ports: - - 10000:10000 - steps: - # Downloads a copy of the code in your repository before running CI tests - - name: Check out repository code - uses: actions/checkout@v2 - - name: Install postgis - run: sudo apt-get install -y postgis postgresql-12-postgis-3 postgresql-12-postgis-3-scripts - - name: Setup postgis - env: - PGPASSWORD: postgres - run: psql -U postgres -h localhost -c 'CREATE EXTENSION hstore;' flask_admin_test - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install tox - run: pip install tox - - name: Run tests - run: tox -e ${{ matrix.tox-version }} diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 000000000..d468c965f --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,99 @@ +name: Tests +on: + push: + branches: + - master + - '*.x' + paths-ignore: + - 'docs/**' + - '*.md' + - '*.rst' + pull_request: + paths-ignore: + - 'docs/**' + - '*.md' + - '*.rst' +jobs: + tests: + name: ${{ matrix.tox == 'normal' && format('py{0}-flask{1}-wtforms{2}', matrix.python, matrix.flask, matrix.wtforms) || matrix.tox }} + runs-on: ${{ matrix.os || 'ubuntu-latest' }} + strategy: + fail-fast: false + matrix: + python: ['3.8', '3.9', '3.10', '3.11', '3.12'] + flask: ['2'] + wtforms: ['2'] + tox: ['normal'] + include: + - python: '3.12' + flask: '2' + wtforms: '2' + tox: 'py312-flask2-wtforms2-no-flask-babel' + services: + # Label used to access the service container + postgres: + # Docker Hub image + image: postgis/postgis:12-master # postgres with postgis installed + # Provide the password for postgres + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: flask_admin_test + ports: + - 5432:5432 + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + mongo: + image: mongo:5.0.14-focal + ports: + - 27017:27017 + azurite: + image: arafato/azurite:2.6.5 + env: + executable: blob + ports: + - 10000:10000 + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 + with: + python-version: ${{ matrix.python }} + allow-prereleases: true + cache: pip + cache-dependency-path: requirements*/*.txt + - name: Install Ubuntu packages + run: | + sudo apt-get update + sudo apt-get install -y libgeos-c1v5 + - name: Check out repository code + uses: actions/checkout@v4 + - name: Set up PostgreSQL hstore module + env: + PGPASSWORD: postgres + run: psql -U postgres -h localhost -c 'CREATE EXTENSION hstore;' flask_admin_test + - run: pip install tox + - run: tox run -e ${{ matrix.tox == 'normal' && format('py{0}-flask{1}-wtforms{2}', matrix.python, matrix.flask, matrix.wtforms) || matrix.tox }} + not_tests: + name: ${{ matrix.tox }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + tox: ['docs', 'typing'] + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 + with: + python-version: 3.11 + cache: pip + cache-dependency-path: requirements*/*.txt + - name: cache mypy + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: ./.mypy_cache + key: mypy|${{ hashFiles('pyproject.toml') }} + - run: pip install tox + - run: tox run -e ${{ matrix.tox }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..0b0bdf927 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +ci: + autoupdate_schedule: monthly +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.7 + hooks: + - id: ruff + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-merge-conflict + - id: debug-statements + - id: fix-byte-order-marker + - id: trailing-whitespace + - id: end-of-file-fixer diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..49c6e5e2f --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,13 @@ +version: 2 +build: + os: ubuntu-22.04 + tools: + python: '3.11' +python: + install: + - requirements: requirements/docs.txt + - method: pip + path: . +sphinx: + builder: dirhtml + fail_on_warning: true diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 000000000..c76f9036c --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,24 @@ +Copyright 2011 Pallets Community Ecosystem + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in index 2e4bf21db..7be8f6c7a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ include LICENSE -include README.rst +include README.md recursive-include flask_admin/static * recursive-include flask_admin/templates * recursive-include flask_admin/translations * diff --git a/README.md b/README.md new file mode 100644 index 000000000..5f43d6f7c --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# Flask-Admin + +The project was recently moved into its own organization. Please update +your references to `git@github.com:pallets-eco/flask-admin.git`. + +[![image](https://d322cqt584bo4o.cloudfront.net/flask-admin/localized.svg)](https://crowdin.com/project/flask-admin) [![image](https://github.com/pallets-eco/flask-admin/actions/workflows/test.yaml/badge.svg)](https://github.com/pallets-eco/flask-admin/actions/workflows/test.yaml) + +## Pallets Community Ecosystem + +> [!IMPORTANT]\ +> This project is part of the Pallets Community Ecosystem. Pallets is the open +> source organization that maintains Flask; Pallets-Eco enables community +> maintenance of related projects. If you are interested in helping maintain +> this project, please reach out on [the Pallets Discord server][discord]. + +[discord]: https://discord.gg/pallets + +## Introduction + +Flask-Admin is a batteries-included, simple-to-use +[Flask](http://flask.pocoo.org/) extension that lets you add admin +interfaces to Flask applications. It is inspired by the *django-admin* +package, but implemented in such a way that the developer has total +control over the look, feel, functionality and user experience of the resulting +application. + +Out-of-the-box, Flask-Admin plays nicely with various ORM\'s, including + +- [SQLAlchemy](http://www.sqlalchemy.org/) +- [MongoEngine](http://mongoengine.org/) +- [pymongo](http://api.mongodb.org/python/current/) +- and [Peewee](https://github.com/coleifer/peewee). + +It also boasts a simple file management interface and a [Redis +client](http://redis.io/) console. + +The biggest feature of Flask-Admin is its flexibility. It aims to provide a +set of simple tools that can be used to build admin interfaces of +any complexity. To start off, you can create a very simple +application in no time, with auto-generated CRUD-views for each of your +models. Then you can further customize those views and forms as +the need arises. + +Flask-Admin is an active project, well-tested and production-ready. + +## Examples + +Several usage examples are included in the */examples* folder. Please +add your own, or improve on the existing examples, and submit a +*pull-request*. + +To run the examples in your local environment: +1. Clone the repository: + + ```bash + git clone https://github.com/pallets-eco/flask-admin.git + cd flask-admin + ``` +2. Create and activate a virtual environment: + + ```bash + # Windows: + python -m venv .venv + .venv\Scripts\activate + + # Linux: + python3 -m venv .venv + source .venv/bin/activate + ``` +3. Install requirements: + + ```bash + pip install -r examples/sqla/requirements.txt + ``` +4. Run the application: + + ```bash + python examples/sqla/run_server.py + ``` +5. Check the Flask app running on . + +## Documentation + +Flask-Admin is extensively documented, you can find all of the +documentation at . + +The docs are auto-generated from the *.rst* files in the */doc* folder. +If you come across any errors or if you think of anything else that +should be included, feel free to make the changes and submit a *pull-request*. + +To build the docs in your local environment, from the project directory: + + tox -e docs-html + +And if you want to preview any *.rst* snippets that you may want to +contribute, please go to . + +## Installation + +To install Flask-Admin, simply: + + pip install flask-admin + +Or alternatively, you can download the repository and install manually +by doing: + + git clone git@github.com:pallets-eco/flask-admin.git + cd flask-admin + pip install . + +## Tests + +Tests are run with *pytest*. If you are not familiar with this package, you can find out more on [their website](https://pytest.org/). + +To run the tests, from the project directory, simply run: + + pip install --use-pep517 -r requirements/dev.txt + pytest + +You should see output similar to: + + ............................................. + ---------------------------------------------------------------------- + Ran 102 tests in 13.132s + + OK + +**NOTE!** For all the tests to pass successfully, you\'ll need Postgres (with +the postgis and hstore extension) & MongoDB to be running locally. You'll +also need *libgeos* available. + +For Postgres: +```bash +psql postgres +> CREATE DATABASE flask_admin_test; +> # Connect to database "flask_admin_test": +> \c flask_admin_test; +> CREATE EXTENSION postgis; +> CREATE EXTENSION hstore; +``` +If you\'re using Homebrew on MacOS, you might need this: + +```bash +# Install postgis and geos +brew install postgis +brew install geos + +# Set up a PostgreSQL user +createuser -s postgresql +brew services restart postgresql +``` + +You can also run the tests on multiple environments using *tox*. + +## 3rd Party Stuff + +Flask-Admin is built with the help of +[Bootstrap](http://getbootstrap.com/), +[Select2](https://github.com/ivaynberg/select2) and +[Bootswatch](http://bootswatch.com/). + +If you want to localize your application, install the +[Flask-Babel](https://pypi.python.org/pypi/Flask-Babel) package. + +You can help improve Flask-Admin\'s translations through Crowdin: + diff --git a/README.rst b/README.rst deleted file mode 100644 index 22b08b68b..000000000 --- a/README.rst +++ /dev/null @@ -1,136 +0,0 @@ -Flask-Admin -=========== - -The project was recently moved into its own organization. Please update your -references to *git@github.com:flask-admin/flask-admin.git*. - -.. image:: https://d322cqt584bo4o.cloudfront.net/flask-admin/localized.svg - :target: https://crowdin.com/project/flask-admin - -.. image:: https://github.com/flask-admin/flask-admin/actions/workflows/test.yaml/badge.svg - :target: https://github.com/flask-admin/flask-admin/actions/workflows/test.yaml - - -Introduction ------------- - -Flask-Admin is a batteries-included, simple-to-use `Flask `_ extension that lets you -add admin interfaces to Flask applications. It is inspired by the *django-admin* package, but implemented in such -a way that the developer has total control of the look, feel and functionality of the resulting application. - -Out-of-the-box, Flask-Admin plays nicely with various ORM's, including - -- `SQLAlchemy `_, - -- `MongoEngine `_, - -- `pymongo `_ and - -- `Peewee `_. - -It also boasts a simple file management interface and a `redis client `_ console. - -The biggest feature of Flask-Admin is flexibility. It aims to provide a set of simple tools that can be used for -building admin interfaces of any complexity. So, to start off with you can create a very simple application in no time, -with auto-generated CRUD-views for each of your models. But then you can go further and customize those views & forms -as the need arises. - -Flask-Admin is an active project, well-tested and production ready. - -Examples --------- -Several usage examples are included in the */examples* folder. Please add your own, or improve -on the existing examples, and submit a *pull-request*. - -To run the examples in your local environment:: - - 1. Clone the repository:: - - git clone https://github.com/flask-admin/flask-admin.git - cd flask-admin - - 2. Create and activate a virtual environment:: - - virtualenv env -p python3 - source env/bin/activate - - 3. Install requirements:: - - pip install -r examples/sqla/requirements.txt - - 4. Run the application:: - - python examples/sqla/run_server.py - -Documentation -------------- -Flask-Admin is extensively documented, you can find all of the documentation at `https://flask-admin.readthedocs.io/en/latest/ `_. - -The docs are auto-generated from the *.rst* files in the */doc* folder. So if you come across any errors, or -if you think of anything else that should be included, then please make the changes and submit them as a *pull-request*. - -To build the docs in your local environment, from the project directory:: - - tox -e docs-html - -And if you want to preview any *.rst* snippets that you may want to contribute, go to `http://rst.ninjs.org/ `_. - -Installation ------------- -To install Flask-Admin, simply:: - - pip install flask-admin - -Or alternatively, you can download the repository and install manually by doing:: - - git clone git@github.com:flask-admin/flask-admin.git - cd flask-admin - python setup.py install - -Tests ------ -Test are run with *pytest*. If you are not familiar with this package you can get some more info from `their website `_. - -To run the tests, from the project directory, simply:: - - pip install -r requirements-dev.txt - pytest - -You should see output similar to:: - - ............................................. - ---------------------------------------------------------------------- - Ran 102 tests in 13.132s - - OK - -For all the tests to pass successfully, you'll need Postgres & MongoDB to be running locally. For Postgres:: - - > psql postgres - CREATE DATABASE flask_admin_test; - \q - - > psql flask_admin_test - CREATE EXTENSION postgis; - CREATE EXTENSION hstore; - -If you're using Homebrew on MacOS, you might need this:: - - # install postgis - > brew install postgis - - # set up postgresql user - > createuser -s postgresql - > brew services restart postgresql - -You can also run the tests on multiple environments using *tox*. - -3rd Party Stuff ---------------- - -Flask-Admin is built with the help of `Bootstrap `_, `Select2 `_ -and `Bootswatch `_. - -If you want to localize your application, install the `Flask-BabelEx `_ package. - -You can help improve Flask-Admin's translations through Crowdin: https://crowdin.com/project/flask-admin diff --git a/doc/advanced.rst b/doc/advanced.rst index fc5602cca..c01eeb555 100644 --- a/doc/advanced.rst +++ b/doc/advanced.rst @@ -6,6 +6,8 @@ Advanced Functionality Enabling CSRF Protection ------------------------ +**** + To add CSRF protection to the forms that are generated by *ModelView* instances, use the SecureForm class in your *ModelView* subclass by specifying the *form_base_class* parameter:: @@ -18,43 +20,52 @@ SecureForm class in your *ModelView* subclass by specifying the *form_base_class SecureForm requires WTForms 2 or greater. It uses the WTForms SessionCSRF class to generate and validate the tokens for you when the forms are submitted. -Localization With Flask-Babelex -------------------------------- +Adding Custom Javascript and CSS +-------------------------------- **** -Flask-Admin comes with translations for several languages. -Enabling localization is simple: +To add custom JavaScript or CSS in your *ModelView* use *extra_js* or *extra_css* parameters:: -#. Install `Flask-BabelEx `_ to do the heavy - lifting. It's a fork of the - `Flask-Babel `_ package:: + class MyModelView(ModelView): + extra_js = ['https://example.com/custom.js'] + extra_css = ['https://example.com/custom.css'] - pip install flask-babelex +Localization With Flask-Babel +------------------------------- -#. Initialize Flask-BabelEx by creating instance of `Babel` class:: +**** - from flask import Flask - from flask_babelex import Babel +Flask-Admin comes with translations for several languages. +Enabling localization is simple: - app = Flask(__name__) - babel = Babel(app) +#. Install `Flask-Babel `_ to do the heavy + lifting. + + pip install flask-babel #. Create a locale selector function:: - @babel.localeselector def get_locale(): if request.args.get('lang'): session['lang'] = request.args.get('lang') return session.get('lang', 'en') +#. Initialize Flask-Babel by creating instance of `Babel` class:: + + from flask import Flask + from flask_babel import Babel + + app = Flask(__name__) + babel = Babel(app, locale_selector=get_locale) + Now, you could try a French version of the application at: `http://localhost:5000/admin/?lang=fr `_. Go ahead and add your own logic to the locale selector function. The application can store locale in a user profile, cookie, session, etc. It can also use the `Accept-Language` header to make the selection automatically. -If the built-in translations are not enough, look at the `Flask-BabelEx documentation `_ +If the built-in translations are not enough, look at the `Flask-Babel documentation `_ to see how you can add your own. .. _file-admin: @@ -199,7 +210,8 @@ from the GeoAlchemy backend, rather than the usual SQLAlchemy backend:: from flask_admin.contrib.geoa import ModelView # .. flask initialization - db = SQLAlchemy(app) + db = SQLAlchemy() + db.init_app(app) class Location(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/doc/changelog.rst b/doc/changelog.rst index 2a4c2663c..66b2d8e66 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,13 @@ Changelog ========= +1.6.1 +----- + +* SQLAlchemy 2.x support +* General updates and bug fixes +* Dropped WTForms 1 support + 1.6.0 ----- diff --git a/doc/introduction.rst b/doc/introduction.rst index 3e177d8d0..6468197e1 100644 --- a/doc/introduction.rst +++ b/doc/introduction.rst @@ -124,7 +124,7 @@ Using Flask-Security -------------------- If you want a more polished solution, you could -use `Flask-Security `_, +use `Flask-Security `_, which is a higher-level library. It comes with lots of built-in views for doing common things like user registration, login, email address confirmation, password resets, etc. diff --git a/examples/auth/app.py b/examples/auth/app.py index 8cae63282..ae6de22d9 100644 --- a/examples/auth/app.py +++ b/examples/auth/app.py @@ -2,8 +2,8 @@ from flask import Flask, url_for, redirect, render_template, request, abort from flask_sqlalchemy import SQLAlchemy from flask_security import Security, SQLAlchemyUserDatastore, \ - UserMixin, RoleMixin, login_required, current_user -from flask_security.utils import encrypt_password + UserMixin, RoleMixin, current_user +from flask_security.utils import hash_password import flask_admin from flask_admin.contrib import sqla from flask_admin import helpers as admin_helpers @@ -42,6 +42,7 @@ class User(db.Model, UserMixin): confirmed_at = db.Column(db.DateTime()) roles = db.relationship('Role', secondary=roles_users, backref=db.backref('users', lazy='dynamic')) + fs_uniquifier = db.Column(db.String(64), unique=True, nullable=False) def __str__(self): return self.email @@ -121,8 +122,8 @@ def build_sample_db(): test_user = user_datastore.create_user( first_name='Admin', - email='admin', - password=encrypt_password('admin'), + email='admin@example.com', + password=hash_password('admin'), roles=[user_role, super_user_role] ) @@ -144,7 +145,7 @@ def build_sample_db(): first_name=first_names[i], last_name=last_names[i], email=tmp_email, - password=encrypt_password(tmp_pass), + password=hash_password(tmp_pass), roles=[user_role, ] ) db.session.commit() diff --git a/examples/auth/requirements.txt b/examples/auth/requirements.txt index 1ca0b38e6..33f9f6216 100644 --- a/examples/auth/requirements.txt +++ b/examples/auth/requirements.txt @@ -1,5 +1,5 @@ Flask Flask-Admin Flask-SQLAlchemy -Flask-Security>=1.7.5 +flask-security-too email_validator diff --git a/examples/auth/templates/admin/index.html b/examples/auth/templates/admin/index.html index a31f06809..5079206a5 100644 --- a/examples/auth/templates/admin/index.html +++ b/examples/auth/templates/admin/index.html @@ -14,7 +14,7 @@

Flask-Admin example

{% if not current_user.is_authenticated %}

You can register as a regular user, or log in as a superuser with the following credentials:

    -
  • email: admin
  • +
  • email: admin@example.com
  • password: admin

diff --git a/examples/babel/README.rst b/examples/babel/README.rst index 6363b61f5..5d4c9c791 100644 --- a/examples/babel/README.rst +++ b/examples/babel/README.rst @@ -1,4 +1,4 @@ -This example show how to translate Flask-Admin into different language using customized version of the `Flask-Babel ` +This example show how to translate Flask-Admin into different language using customized version of the `Flask-Babel ` To run this example: @@ -19,5 +19,3 @@ To run this example: 4. Run the application:: python examples/babel/app.py - - diff --git a/examples/babel/app.py b/examples/babel/app.py index 2149f47b1..b7b36abba 100644 --- a/examples/babel/app.py +++ b/examples/babel/app.py @@ -2,7 +2,7 @@ from flask_sqlalchemy import SQLAlchemy import flask_admin as admin -from flask_babelex import Babel +from flask_babel import Babel from flask_admin.contrib import sqla @@ -17,11 +17,7 @@ app.config['SQLALCHEMY_ECHO'] = True db = SQLAlchemy(app) -# Initialize babel -babel = Babel(app) - -@babel.localeselector def get_locale(): override = request.args.get('lang') @@ -30,6 +26,9 @@ def get_locale(): return session.get('lang', 'en') +# Initialize babel +babel = Babel(app, locale_selector=get_locale) + # Create models class User(db.Model): diff --git a/examples/babel/requirements.txt b/examples/babel/requirements.txt index f4bfb6542..f0e72f2b4 100644 --- a/examples/babel/requirements.txt +++ b/examples/babel/requirements.txt @@ -1,4 +1,4 @@ Flask Flask-Admin Flask-SQLAlchemy -Flask-BabelEx +Flask-Babel diff --git a/examples/bootstrap4/README.rst b/examples/bootstrap4/README.rst index 9dbc43fcf..f4b3e7396 100644 --- a/examples/bootstrap4/README.rst +++ b/examples/bootstrap4/README.rst @@ -14,11 +14,11 @@ To run this example: 3. Install requirements:: - pip install -r 'examples/bootstrap4/requirements.txt' + pip install -r examples/bootstrap4/requirements.txt 4. Run the application:: - python examples/custom-layout/app.py + python examples/bootstrap4/app.py The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour, comment the following lines in app.py::: diff --git a/examples/sqla/admin/__init__.py b/examples/sqla/admin/__init__.py index 99c979b39..6a44f298c 100644 --- a/examples/sqla/admin/__init__.py +++ b/examples/sqla/admin/__init__.py @@ -1,6 +1,6 @@ from flask import Flask, request, session from flask_sqlalchemy import SQLAlchemy -from flask_babelex import Babel +from flask_babel import Babel app = Flask(__name__) diff --git a/examples/sqla/requirements.txt b/examples/sqla/requirements.txt index f010af00b..efaafbd20 100644 --- a/examples/sqla/requirements.txt +++ b/examples/sqla/requirements.txt @@ -1,6 +1,6 @@ Flask Flask-Admin -Flask-BabelEx +Flask-Babel Flask-SQLAlchemy tablib enum34; python_version < '3.0' diff --git a/flask_admin/__init__.py b/flask_admin/__init__.py index a3147b708..a074a308c 100644 --- a/flask_admin/__init__.py +++ b/flask_admin/__init__.py @@ -1,6 +1,6 @@ -__version__ = '1.6.0' +__version__ = '1.6.1' __author__ = 'Flask-Admin team' -__email__ = 'serge.koval+github@gmail.com' +__email__ = 'contact@palletsproject.com' from .base import expose, expose_plugview, Admin, BaseView, AdminIndexView # noqa: F401 diff --git a/flask_admin/_backwards.py b/flask_admin/_backwards.py index 0f1a2a49b..03929e02e 100644 --- a/flask_admin/_backwards.py +++ b/flask_admin/_backwards.py @@ -9,8 +9,9 @@ import warnings try: - from wtforms.widgets import HTMLString as Markup + from wtforms.widgets import HTMLString as Markup # type: ignore[attr-defined] except ImportError: + # WTForms 2.3.0 from markupsafe import Markup # noqa: F401 diff --git a/flask_admin/_compat.py b/flask_admin/_compat.py index 610d29301..934ace4f9 100644 --- a/flask_admin/_compat.py +++ b/flask_admin/_compat.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # flake8: noqa """ flask_admin._compat @@ -11,87 +10,37 @@ :copyright: (c) 2013 by Armin Ronacher. :license: BSD, see LICENSE for more details. """ -import sys +from typing import Callable -PY2 = sys.version_info[0] == 2 -VER = sys.version_info +text_type = str +string_types = (str,) -if not PY2: - text_type = str - string_types = (str,) - integer_types = (int, ) - iterkeys = lambda d: iter(d.keys()) - itervalues = lambda d: iter(d.values()) - iteritems = lambda d: iter(d.items()) - filter_list = lambda f, l: list(filter(f, l)) +def itervalues(d: dict): + return iter(d.values()) - def as_unicode(s): - if isinstance(s, bytes): - return s.decode('utf-8') - return str(s) +def iteritems(d: dict): + return iter(d.items()) - def csv_encode(s): - ''' Returns unicode string expected by Python 3's csv module ''' - return as_unicode(s) - # Various tools - from functools import reduce - from urllib.parse import urljoin, urlparse, quote -else: - text_type = unicode - string_types = (str, unicode) - integer_types = (int, long) +def filter_list(f: Callable, l: list): + return list(filter(f, l)) - iterkeys = lambda d: d.iterkeys() - itervalues = lambda d: d.itervalues() - iteritems = lambda d: d.iteritems() - filter_list = filter - def as_unicode(s): - if isinstance(s, str): - return s.decode('utf-8') +def as_unicode(s): + if isinstance(s, bytes): + return s.decode('utf-8') - return unicode(s) + return str(s) - def csv_encode(s): - ''' Returns byte string expected by Python 2's csv module ''' - return as_unicode(s).encode('utf-8') +def csv_encode(s): + ''' Returns unicode string expected by Python 3's csv module ''' + return as_unicode(s) - # Helpers - reduce = __builtins__['reduce'] if isinstance(__builtins__, dict) else __builtins__.reduce - from urlparse import urljoin, urlparse - from urllib import quote - - -def with_metaclass(meta, *bases): - # This requires a bit of explanation: the basic idea is to make a - # dummy metaclass for one level of class instantiation that replaces - # itself with the actual metaclass. Because of internal type checks - # we also need to make sure that we downgrade the custom metaclass - # for one level to something closer to type (that's why __call__ and - # __init__ comes back from type etc.). - # - # This has the advantage over six.with_metaclass in that it does not - # introduce dummy classes into the final MRO. - class metaclass(meta): - __call__ = type.__call__ - __init__ = type.__init__ - - def __new__(cls, name, this_bases, d): - if this_bases is None: - return type.__new__(cls, name, (), d) - return meta(name, bases, d) - return metaclass('temporary_class', None, {}) - - -try: - from collections import OrderedDict -except ImportError: - from ordereddict import OrderedDict try: - from jinja2 import pass_context + # jinja2 3.0.0 + from jinja2 import pass_context # type: ignore[attr-defined] except ImportError: from jinja2 import contextfunction as pass_context diff --git a/flask_admin/_types.py b/flask_admin/_types.py new file mode 100644 index 000000000..57d221d22 --- /dev/null +++ b/flask_admin/_types.py @@ -0,0 +1,7 @@ +from typing import Union, Sequence, Dict, Callable, Any + +import sqlalchemy + +T_COLUMN_LIST = Sequence[Union[str, sqlalchemy.Column]] +T_FORMATTER = Callable[[Any, Any, Any], Any] +T_FORMATTERS = Dict[type, T_FORMATTER] diff --git a/flask_admin/babel.py b/flask_admin/babel.py index e9d18ecca..762ccb2ee 100644 --- a/flask_admin/babel.py +++ b/flask_admin/babel.py @@ -1,8 +1,5 @@ try: - try: - from flask_babelex import Domain - except ImportError: - from flask_babel import Domain + from flask_babel import Domain except ImportError: def gettext(string, **variables): @@ -45,14 +42,11 @@ def get_translations_path(self, ctx): ngettext = domain.ngettext lazy_gettext = domain.lazy_gettext - try: - from wtforms.i18n import messages_path - except ImportError: - from wtforms.ext.i18n.utils import messages_path + from wtforms.i18n import messages_path wtforms_domain = Domain(messages_path(), domain='wtforms') - class Translations(object): + class Translations(object): # type: ignore[no-redef] ''' Fixes WTForms translation support and uses wtforms translations ''' def gettext(self, string): t = wtforms_domain.get_translations() diff --git a/flask_admin/base.py b/flask_admin/base.py index e706c916e..1c913da8d 100644 --- a/flask_admin/base.py +++ b/flask_admin/base.py @@ -5,7 +5,7 @@ from flask import Blueprint, current_app, render_template, abort, g, url_for from flask_admin import babel -from flask_admin._compat import with_metaclass, as_unicode +from flask_admin._compat import as_unicode from flask_admin import helpers as h # For compatibility reasons import MenuLink @@ -106,7 +106,7 @@ class BaseViewClass(object): pass -class BaseView(with_metaclass(AdminViewMeta, BaseViewClass)): +class BaseView(BaseViewClass, metaclass=AdminViewMeta): """ Base administrative view. @@ -365,7 +365,10 @@ def _run_view(self, fn, *args, **kwargs): :param kwargs: Arguments """ - return fn(self, *args, **kwargs) + try: + return fn(self, *args, **kwargs) + except TypeError: + return fn(cls=self, **kwargs) def inaccessible_callback(self, name, **kwargs): """ diff --git a/flask_admin/contrib/__init__.py b/flask_admin/contrib/__init__.py index 42e33a76c..24608e116 100644 --- a/flask_admin/contrib/__init__.py +++ b/flask_admin/contrib/__init__.py @@ -1,4 +1,4 @@ try: - __import__('pkg_resources').declare_namespace(__name__) + __path__ = __import__('pkgutil').extend_path(__path__, __name__) except ImportError: pass diff --git a/flask_admin/contrib/fileadmin/__init__.py b/flask_admin/contrib/fileadmin/__init__.py index f5c786adb..2bc9344a8 100644 --- a/flask_admin/contrib/fileadmin/__init__.py +++ b/flask_admin/contrib/fileadmin/__init__.py @@ -1,3 +1,4 @@ +import sys import warnings from datetime import datetime import os @@ -5,19 +6,28 @@ import platform import re import shutil +from functools import partial from operator import itemgetter +from urllib.parse import urljoin, quote from flask import flash, redirect, abort, request, send_file from werkzeug.utils import secure_filename from wtforms import fields, validators from flask_admin import form, helpers -from flask_admin._compat import urljoin, as_unicode, quote +from flask_admin._compat import as_unicode from flask_admin.base import BaseView, expose from flask_admin.actions import action, ActionsMixin from flask_admin.babel import gettext, lazy_gettext +if sys.version_info >= (3, 11): + from datetime import UTC + utc_fromtimestamp = partial(datetime.fromtimestamp, tz=UTC) +else: + utc_fromtimestamp = datetime.utcfromtimestamp + + class LocalFileStorage(object): def __init__(self, base_path): """ @@ -175,7 +185,7 @@ class MyAdmin(FileAdmin): allowed_extensions = ('swf', 'jpg', 'gif', 'png') """ - editable_extensions = tuple() + editable_extensions: tuple = tuple() """ List of editable extensions, in lower case. @@ -856,7 +866,7 @@ def index_view(self, path=None): items.sort(key=itemgetter(2), reverse=True) if not self._on_windows: # Sort by modified date - items.sort(key=lambda x: (x[0], x[1], x[2], x[3], datetime.utcfromtimestamp(x[4])), reverse=True) + items.sort(key=lambda x: (x[0], x[1], x[2], x[3], utc_fromtimestamp(x[4])), reverse=True) else: items.sort(key=itemgetter(column_index), reverse=sort_desc) diff --git a/flask_admin/contrib/fileadmin/s3.py b/flask_admin/contrib/fileadmin/s3.py index 1c9bdc719..55340167a 100644 --- a/flask_admin/contrib/fileadmin/s3.py +++ b/flask_admin/contrib/fileadmin/s3.py @@ -1,4 +1,8 @@ import time +from types import ModuleType +from typing import Optional + +s3: Optional[ModuleType] try: from boto import s3 diff --git a/flask_admin/contrib/geoa/typefmt.py b/flask_admin/contrib/geoa/typefmt.py index 4e1902247..e11314808 100644 --- a/flask_admin/contrib/geoa/typefmt.py +++ b/flask_admin/contrib/geoa/typefmt.py @@ -6,17 +6,24 @@ from sqlalchemy import func -def geom_formatter(view, value): - params = html_params(**{ +def geom_formatter(view, value, name) -> str: + kwargs = { "data-role": "leaflet", "disabled": "disabled", "data-width": 100, "data-height": 70, "data-geometry-type": to_shape(value).geom_type, "data-zoom": 15, - "data-tile-layer-url": view.tile_layer_url, - "data-tile-layer-attribution": view.tile_layer_attribution - }) + } + # html_params will serialize None as a string literal "None" so only put tile-layer-url + # and tile-layer-attribution in kwargs when they have a meaningful value. + # flask_admin/static/admin/js/form.js uses its default values when these are not passed + # as textarea attributes. + if view.tile_layer_url: + kwargs["data-tile-layer-url"] = view.tile_layer_url + if view.tile_layer_attribution: + kwargs["data-tile-layer-attribution"] = view.tile_layer_attribution + params = html_params(**kwargs) if value.srid == -1: value.srid = 4326 @@ -26,4 +33,4 @@ def geom_formatter(view, value): DEFAULT_FORMATTERS = BASE_FORMATTERS.copy() -DEFAULT_FORMATTERS[WKBElement] = geom_formatter +DEFAULT_FORMATTERS[WKBElement] = geom_formatter # type: ignore[assignment] diff --git a/flask_admin/contrib/geoa/view.py b/flask_admin/contrib/geoa/view.py index 3ad8da583..d10c7b401 100644 --- a/flask_admin/contrib/geoa/view.py +++ b/flask_admin/contrib/geoa/view.py @@ -5,5 +5,7 @@ class ModelView(SQLAModelView): model_form_converter = form.AdminModelConverter column_type_formatters = typefmt.DEFAULT_FORMATTERS + # tile_layer_url is prefixed with '//' in flask_admin/static/admin/js/form.js + # Leave it as None or set it to a string starting with a hostname, NOT "http". tile_layer_url = None tile_layer_attribution = None diff --git a/flask_admin/contrib/mongoengine/fields.py b/flask_admin/contrib/mongoengine/fields.py index 64418ecb0..23a0287fe 100644 --- a/flask_admin/contrib/mongoengine/fields.py +++ b/flask_admin/contrib/mongoengine/fields.py @@ -3,11 +3,7 @@ from werkzeug.datastructures import FileStorage from wtforms import fields - -try: - from wtforms.fields.core import _unset_value as unset_value -except ImportError: - from wtforms.utils import unset_value +from wtforms.utils import unset_value from . import widgets from flask_admin.model.fields import InlineFormField @@ -57,13 +53,13 @@ def __init__(self, label=None, validators=None, **kwargs): self._should_delete = False - def process(self, formdata, data=unset_value): + def process(self, formdata, data=unset_value, extra_filters=None): if formdata: marker = '_%s-delete' % self.name if marker in formdata: self._should_delete = True - return super(MongoFileField, self).process(formdata, data) + return super(MongoFileField, self).process(formdata, data, extra_filters) def populate_obj(self, obj, name): field = getattr(obj, name, None) @@ -89,4 +85,4 @@ class MongoImageField(MongoFileField): GridFS image field. """ - widget = widgets.MongoImageInput() + widget = widgets.MongoImageInput() # type: ignore[assignment] diff --git a/flask_admin/contrib/mongoengine/typefmt.py b/flask_admin/contrib/mongoengine/typefmt.py index 840526de3..a6b597329 100644 --- a/flask_admin/contrib/mongoengine/typefmt.py +++ b/flask_admin/contrib/mongoengine/typefmt.py @@ -1,3 +1,5 @@ +from typing import Union + from markupsafe import Markup, escape from mongoengine.base import BaseList @@ -8,7 +10,7 @@ from . import helpers -def grid_formatter(view, value): +def grid_formatter(view, value, name) -> Union[str, Markup]: if not value.grid_id: return '' @@ -26,7 +28,7 @@ def grid_formatter(view, value): }) -def grid_image_formatter(view, value): +def grid_image_formatter(view, value, name) -> Union[str, Markup]: if not value.grid_id: return '' diff --git a/flask_admin/contrib/mongoengine/view.py b/flask_admin/contrib/mongoengine/view.py index 10663164a..fc984182b 100644 --- a/flask_admin/contrib/mongoengine/view.py +++ b/flask_admin/contrib/mongoengine/view.py @@ -31,6 +31,7 @@ mongoengine.IntField, mongoengine.FloatField, mongoengine.BooleanField, + mongoengine.DateField, mongoengine.DateTimeField, mongoengine.ComplexDateTimeField, mongoengine.ObjectIdField, @@ -320,17 +321,13 @@ def scaffold_list_columns(self): columns = [] for n, f in self._get_model_fields(): - # Verify type - field_class = type(f) - - if (field_class == mongoengine.ListField and - isinstance(f.field, mongoengine.EmbeddedDocumentField)): + if isinstance(f, mongoengine.ListField) and isinstance(f.field, mongoengine.EmbeddedDocumentField): continue - if field_class == mongoengine.EmbeddedDocumentField: + if isinstance(f, mongoengine.EmbeddedDocumentField): continue - if self.column_display_pk or field_class != mongoengine.ObjectIdField: + if self.column_display_pk or not isinstance(f, mongoengine.ObjectIdField): columns.append(n) return columns @@ -343,7 +340,7 @@ def scaffold_sortable_columns(self): for n, f in self._get_model_fields(): if type(f) in SORTABLE_FIELDS: - if self.column_display_pk or type(f) != mongoengine.ObjectIdField: + if self.column_display_pk or not isinstance(f, mongoengine.ObjectIdField): columns[n] = f return columns @@ -467,7 +464,7 @@ def _search(self, query, search_term): criteria = None for field in self._search_fields: - if type(field) == mongoengine.ReferenceField: + if isinstance(field, mongoengine.ReferenceField): import re regex = re.compile('.*%s.*' % term) else: diff --git a/flask_admin/contrib/peewee/form.py b/flask_admin/contrib/peewee/form.py index e48390ffd..be27e9a4b 100644 --- a/flask_admin/contrib/peewee/form.py +++ b/flask_admin/contrib/peewee/form.py @@ -4,7 +4,7 @@ PrimaryKeyField, ForeignKeyField) try: - from peewee import BaseModel + from peewee import BaseModel # type: ignore[attr-defined] except ImportError: from peewee import ModelBase as BaseModel diff --git a/flask_admin/contrib/peewee/view.py b/flask_admin/contrib/peewee/view.py index ee77873a4..45f4c64c5 100644 --- a/flask_admin/contrib/peewee/view.py +++ b/flask_admin/contrib/peewee/view.py @@ -186,7 +186,7 @@ def scaffold_pk(self): def get_pk_value(self, model): if self.model._meta.composite_key: return tuple([ - model._data[field_name] + getattr(model, field_name) for field_name in self.model._meta.primary_key.field_names]) return getattr(model, self._primary_key) @@ -194,12 +194,9 @@ def scaffold_list_columns(self): columns = [] for n, f in self._get_model_fields(): - # Verify type - field_class = type(f) - - if field_class == ForeignKeyField: + if isinstance(f, ForeignKeyField): columns.append(n) - elif self.column_display_pk or field_class != PrimaryKeyField: + elif self.column_display_pk or not isinstance(f, PrimaryKeyField): columns.append(n) return columns @@ -208,7 +205,7 @@ def scaffold_sortable_columns(self): columns = dict() for n, f in self._get_model_fields(): - if self.column_display_pk or type(f) != PrimaryKeyField: + if self.column_display_pk or not isinstance(f, PrimaryKeyField): columns[n] = f return columns @@ -221,8 +218,7 @@ def init_search(self): # Check type if not isinstance(p, (CharField, TextField)): - raise Exception('Can only search on text columns. ' + - 'Failed to setup search for "%s"' % p) + raise Exception(f'Can only search on text columns. Failed to setup search for "{p}"') self._search_fields.append(p) diff --git a/flask_admin/contrib/sqla/fields.py b/flask_admin/contrib/sqla/fields.py index fcb22ceb0..441af0312 100644 --- a/flask_admin/contrib/sqla/fields.py +++ b/flask_admin/contrib/sqla/fields.py @@ -3,14 +3,12 @@ """ import operator +from sqlalchemy.orm.util import identity_key + from wtforms.fields import SelectFieldBase, StringField +from wtforms.utils import unset_value from wtforms.validators import ValidationError -try: - from wtforms.fields import _unset_value as unset_value -except ImportError: - from wtforms.utils import unset_value - from .tools import get_primary_key from flask_admin._compat import text_type, string_types, iteritems from flask_admin.contrib.sqla.widgets import CheckboxListInput @@ -18,12 +16,6 @@ from flask_admin.model.fields import InlineFieldList, InlineModelFormField from flask_admin.babel import lazy_gettext -try: - from sqlalchemy.orm.util import identity_key - has_identity_key = True -except ImportError: - has_identity_key = False - class QuerySelectField(SelectFieldBase): """ @@ -64,8 +56,6 @@ def __init__(self, label=None, validators=None, query_factory=None, self.query_factory = query_factory if get_pk is None: - if not has_identity_key: - raise Exception(u'The sqlalchemy identity_key function could not be imported.') self.get_pk = get_pk_from_identity else: self.get_pk = get_pk @@ -203,7 +193,7 @@ class MyView(ModelView): 'languages': CheckboxListField, } """ - widget = CheckboxListInput() + widget = CheckboxListInput() # type: ignore[assignment] class HstoreForm(BaseForm): @@ -223,13 +213,13 @@ def __init__(self, key=None, value=None): class InlineHstoreList(InlineFieldList): """ Version of InlineFieldList for use with Postgres HSTORE columns """ - def process(self, formdata, data=unset_value): + def process(self, formdata, data=unset_value, extra_filters=None): """ SQLAlchemy returns a dict for HSTORE columns, but WTForms cannot process a dict. This overrides `process` to convert the dict returned by SQLAlchemy to a list of classes before processing. """ if isinstance(data, dict): data = [KeyValue(k, v) for k, v in iteritems(data)] - super(InlineHstoreList, self).process(formdata, data) + super(InlineHstoreList, self).process(formdata, data, extra_filters) def populate_obj(self, obj, name): """ Combines each FormField key/value into a dictionary for storage """ diff --git a/flask_admin/contrib/sqla/form.py b/flask_admin/contrib/sqla/form.py index f9eeb139f..543342ae3 100644 --- a/flask_admin/contrib/sqla/form.py +++ b/flask_admin/contrib/sqla/form.py @@ -657,7 +657,7 @@ def _calculate_mapping_key_pair(self, model, info): :param info: The InlineFormAdmin instance :return: - A tuple of forward property key and reverse property key + A dict of forward property key and reverse property key """ mapper = model._sa_class_manager.mapper @@ -665,33 +665,37 @@ def _calculate_mapping_key_pair(self, model, info): # Use the base mapper to support inheritance target_mapper = info.model._sa_class_manager.mapper.base_mapper - reverse_prop = None - + reverse_props = [] + forward_reverse_props_keys = dict() for prop in target_mapper.iterate_properties: if hasattr(prop, 'direction') and prop.direction.name in ('MANYTOONE', 'MANYTOMANY'): if issubclass(model, prop.mapper.class_): - reverse_prop = prop - break - else: - raise Exception('Cannot find reverse relation for model %s' % info.model) + # store props in reverse_props list + reverse_props.append(prop) - # Find forward property - forward_prop = None + if not reverse_props: + raise Exception('Cannot find reverse relation for model %s' % info.model) - if prop.direction.name == 'MANYTOONE': - candidate = 'ONETOMANY' - else: - candidate = 'MANYTOMANY' + for reverse_prop in reverse_props: + # Find forward property - for prop in mapper.iterate_properties: - if hasattr(prop, 'direction') and prop.direction.name == candidate: - if prop.mapper.class_ == target_mapper.class_: - forward_prop = prop - break - else: - raise Exception('Cannot find forward relation for model %s' % info.model) + if reverse_prop.direction.name == 'MANYTOONE': + candidate = 'ONETOMANY' + else: + candidate = 'MANYTOMANY' + + for prop in mapper.iterate_properties: + if hasattr(prop, 'direction') and prop.direction.name == candidate: + # check if prop is not handled yet + # issubclass is more useful than equal comparator in the case of inheritance + if prop.key not in forward_reverse_props_keys.keys() and issubclass(target_mapper.class_, + prop.mapper.class_): + forward_reverse_props_keys[prop.key] = reverse_prop.key + break + else: + raise Exception('Cannot find forward relation for model %s' % info.model) - return forward_prop.key, reverse_prop.key + return forward_reverse_props_keys def contribute(self, model, form_class, inline_model): """ @@ -720,60 +724,61 @@ def contribute(self, model, form_class, inline_model): info = self.get_info(inline_model) - forward_prop_key, reverse_prop_key = self._calculate_mapping_key_pair(model, info) + forward_reverse_props_keys = self._calculate_mapping_key_pair(model, info) - # Remove reverse property from the list - ignore = [reverse_prop_key] + for forward_prop_key, reverse_prop_key in forward_reverse_props_keys.items(): + # Remove reverse property from the list + ignore = [reverse_prop_key] - if info.form_excluded_columns: - exclude = ignore + list(info.form_excluded_columns) - else: - exclude = ignore - - # Create converter - converter = self.model_converter(self.session, info) - - # Create form - child_form = info.get_form() - - if child_form is None: - child_form = get_form(info.model, - converter, - base_class=info.form_base_class or form.BaseForm, - only=info.form_columns, - exclude=exclude, - field_args=info.form_args, - hidden_pk=True, - extra_fields=info.form_extra_fields) - - # Post-process form - child_form = info.postprocess_form(child_form) - - kwargs = dict() - - label = self.get_label(info, forward_prop_key) - if label: - kwargs['label'] = label - - if self.view.form_args: - field_args = self.view.form_args.get(forward_prop_key, {}) - kwargs.update(**field_args) - - # Contribute field - setattr(form_class, - forward_prop_key, - self.inline_field_list_type(child_form, - self.session, - info.model, - reverse_prop_key, - info, - **kwargs)) + if info.form_excluded_columns: + exclude = ignore + list(info.form_excluded_columns) + else: + exclude = ignore + + # Create converter + converter = self.model_converter(self.session, info) + + # Create form + child_form = info.get_form() + + if child_form is None: + child_form = get_form(info.model, + converter, + base_class=info.form_base_class or form.BaseForm, + only=info.form_columns, + exclude=exclude, + field_args=info.form_args, + hidden_pk=True, + extra_fields=info.form_extra_fields) + + # Post-process form + child_form = info.postprocess_form(child_form) + + kwargs = dict() + + label = self.get_label(info, forward_prop_key) + if label: + kwargs['label'] = label + + if self.view.form_args: + field_args = self.view.form_args.get(forward_prop_key, {}) + kwargs.update(**field_args) + + # Contribute field + setattr(form_class, + forward_prop_key, + self.inline_field_list_type(child_form, + self.session, + info.model, + reverse_prop_key, + info, + **kwargs)) return form_class class InlineOneToOneModelConverter(InlineModelConverter): - inline_field_list_type = InlineModelOneToOneField + inline_field_list_type = InlineModelOneToOneField # type: ignore[assignment] def _calculate_mapping_key_pair(self, model, info): diff --git a/flask_admin/contrib/sqla/tools.py b/flask_admin/contrib/sqla/tools.py index 28390d479..4149d5214 100644 --- a/flask_admin/contrib/sqla/tools.py +++ b/flask_admin/contrib/sqla/tools.py @@ -6,10 +6,15 @@ from sqlalchemy.orm.clsregistry import _class_resolver except ImportError: # If 1.4/2.0 module import fails, fall back to <1.3.x architecture. - from sqlalchemy.ext.declarative.clsregistry import _class_resolver + from sqlalchemy.ext.declarative.clsregistry import _class_resolver # type: ignore[no-redef] from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.ext.associationproxy import ASSOCIATION_PROXY -from sqlalchemy.sql.operators import eq +try: + # Attempt ASSOCATION_PROXY import from pre-2.0 release + from sqlalchemy.ext.associationproxy import ASSOCIATION_PROXY +except ImportError: + from sqlalchemy.ext.associationproxy import AssociationProxyExtensionType # type: ignore[attr-defined] + ASSOCIATION_PROXY = AssociationProxyExtensionType.ASSOCIATION_PROXY +from sqlalchemy.sql.operators import eq # type: ignore[attr-defined] from sqlalchemy.exc import DBAPIError from sqlalchemy.orm.attributes import InstrumentedAttribute diff --git a/flask_admin/contrib/sqla/typefmt.py b/flask_admin/contrib/sqla/typefmt.py index 596b19921..6d0480c2e 100644 --- a/flask_admin/contrib/sqla/typefmt.py +++ b/flask_admin/contrib/sqla/typefmt.py @@ -5,7 +5,7 @@ from sqlalchemy.orm.collections import InstrumentedList -def choice_formatter(view, choice): +def choice_formatter(view, choice, name) -> str: """ Return label of selected choice see https://sqlalchemy-utils.readthedocs.io/ @@ -16,7 +16,7 @@ def choice_formatter(view, choice): return choice.value -def arrow_formatter(view, arrow_time): +def arrow_formatter(view, arrow_time, name) -> str: """ Return human-friendly string of the time relative to now. see https://arrow.readthedocs.io/ @@ -27,7 +27,7 @@ def arrow_formatter(view, arrow_time): return arrow_time.humanize() -def arrow_export_formatter(view, arrow_time): +def arrow_export_formatter(view, arrow_time, name) -> str: """ Return string representation of Arrow object see https://arrow.readthedocs.io/ diff --git a/flask_admin/contrib/sqla/validators.py b/flask_admin/contrib/sqla/validators.py index 19e126334..7f9809cdd 100644 --- a/flask_admin/contrib/sqla/validators.py +++ b/flask_admin/contrib/sqla/validators.py @@ -1,10 +1,7 @@ from sqlalchemy.orm.exc import NoResultFound from wtforms import ValidationError -try: - from wtforms.validators import InputRequired -except ImportError: - from wtforms.validators import Required as InputRequired +from wtforms.validators import InputRequired from flask_admin._compat import filter_list @@ -21,7 +18,7 @@ class Unique(object): :param message: The error message. """ - field_flags = ('unique', ) + field_flags = {'unique': True} def __init__(self, db_session, model, column, message=None): self.db_session = db_session diff --git a/flask_admin/contrib/sqla/view.py b/flask_admin/contrib/sqla/view.py index 3095e8d3f..9b4622d5b 100755 --- a/flask_admin/contrib/sqla/view.py +++ b/flask_admin/contrib/sqla/view.py @@ -1,6 +1,7 @@ import logging import warnings import inspect +from typing import Optional, Dict, List, Tuple, cast as t_cast from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.base import manager_of_class, instance_state @@ -72,16 +73,19 @@ class PostAdmin(ModelView): Please refer to the `subqueryload` on list of possible values. """ - column_display_all_relations = ObsoleteAttr('column_display_all_relations', - 'list_display_all_relations', - False) + column_display_all_relations = ObsoleteAttr( + 'column_display_all_relations', + 'list_display_all_relations', + False + ) """ Controls if list view should display all relations, not only many-to-one. """ - column_searchable_list = ObsoleteAttr('column_searchable_list', - 'searchable_columns', - None) + column_searchable_list = t_cast( + None, + ObsoleteAttr('column_searchable_list', 'searchable_columns', None), + ) """ Collection of the searchable columns. @@ -264,9 +268,9 @@ class MyModelView(ModelView): inline_models = (MyInlineModelForm(MyInlineModel),) """ - column_type_formatters = DEFAULT_FORMATTERS + column_type_formatters = DEFAULT_FORMATTERS # type: ignore[assignment] - form_choices = None + form_choices: Optional[Dict[str, List[Tuple[str, str]]]] = None """ Map choices to form fields @@ -624,10 +628,10 @@ class MyModelView(BaseModelView): for searchable in self.column_searchable_list: if isinstance(searchable, InstrumentedAttribute): placeholders.append( - self.column_labels.get(searchable.key, searchable.key)) + str(self.column_labels.get(searchable.key, searchable.key))) else: placeholders.append( - self.column_labels.get(searchable, searchable)) + str(self.column_labels.get(searchable, searchable))) return u', '.join(placeholders) @@ -999,7 +1003,7 @@ def _apply_filters(self, query, count_query, joins, count_joins, filters): try: query = flt.apply(query, clean_value, alias) except TypeError: - spec = inspect.getargspec(flt.apply) + spec = inspect.getfullargspec(flt.apply) if len(spec.args) == 3: warnings.warn('Please update your custom filter %s to ' @@ -1116,7 +1120,7 @@ def get_one(self, id): :param id: Model id """ - return self.session.query(self.model).get(tools.iterdecode(id)) + return self.session.get(self.model, tools.iterdecode(id)) # Error handler def handle_view_exception(self, exc): diff --git a/flask_admin/contrib/sqla/widgets.py b/flask_admin/contrib/sqla/widgets.py index ecf9d65bd..8bf760a99 100644 --- a/flask_admin/contrib/sqla/widgets.py +++ b/flask_admin/contrib/sqla/widgets.py @@ -1,4 +1,4 @@ -from wtforms.widgets.core import escape +from wtforms.widgets.core import escape # type: ignore[attr-defined] from flask_admin._backwards import Markup diff --git a/flask_admin/form/__init__.py b/flask_admin/form/__init__.py index 9db32c5a4..6fbbd1f60 100644 --- a/flask_admin/form/__init__.py +++ b/flask_admin/form/__init__.py @@ -1,5 +1,11 @@ -from wtforms import form, __version__ as wtforms_version +from os import urandom + +from flask import session, current_app +from wtforms import form +from wtforms.csrf.session import SessionCSRF from wtforms.fields.core import UnboundField + +from flask_admin._compat import text_type from flask_admin.babel import Translations from .fields import * # noqa: F403,F401 @@ -40,35 +46,24 @@ def recreate_field(unbound): return unbound.field_class(*unbound.args, **unbound.kwargs) -if int(wtforms_version[0]) > 1: - # only WTForms 2+ has built-in CSRF functionality - from os import urandom - from flask import session, current_app - from wtforms.csrf.session import SessionCSRF - from flask_admin._compat import text_type - - class SecureForm(BaseForm): - """ - BaseForm with CSRF token generation and validation support. - - Requires WTForms 2+ - """ - class Meta: - csrf = True - csrf_class = SessionCSRF - _csrf_secret = urandom(24) - - @property - def csrf_secret(self): - secret = current_app.secret_key or self._csrf_secret - if isinstance(secret, text_type): - secret = secret.encode('utf-8') - return secret - - @property - def csrf_context(self): - return session -else: - class SecureForm(BaseForm): - def __init__(self, *args, **kwargs): - raise Exception("SecureForm requires WTForms 2+") +class SecureForm(BaseForm): + """ + BaseForm with CSRF token generation and validation support. + + Requires WTForms 2+ + """ + class Meta: + csrf = True + csrf_class = SessionCSRF + _csrf_secret = urandom(24) + + @property + def csrf_secret(self): + secret = current_app.secret_key or self._csrf_secret + if isinstance(secret, text_type): + secret = secret.encode('utf-8') + return secret + + @property + def csrf_context(self): + return session diff --git a/flask_admin/form/rules.py b/flask_admin/form/rules.py index 4651a6632..305f596fe 100644 --- a/flask_admin/form/rules.py +++ b/flask_admin/form/rules.py @@ -106,7 +106,7 @@ def __call__(self, form, form_opts=None, field_args={}): result = [] for r in self.rules: - result.append(r(form, form_opts, field_args)) + result.append(str(r(form, form_opts, field_args))) return Markup(self.separator.join(result)) diff --git a/flask_admin/form/upload.py b/flask_admin/form/upload.py index 009a79f3e..7c702c64c 100644 --- a/flask_admin/form/upload.py +++ b/flask_admin/form/upload.py @@ -1,22 +1,24 @@ import os import os.path as op +from types import ModuleType +from typing import Optional +from urllib.parse import urljoin from werkzeug.utils import secure_filename from werkzeug.datastructures import FileStorage -from wtforms import ValidationError, fields, __version__ as wtforms_version +from wtforms import ValidationError, fields, __version__ as wtforms_version # type: ignore[attr-defined] +from wtforms.utils import unset_value from wtforms.widgets import html_params -try: - from wtforms.fields.core import _unset_value as unset_value -except ImportError: - from wtforms.utils import unset_value - from flask_admin.babel import gettext from flask_admin.helpers import get_url from flask_admin._backwards import Markup -from flask_admin._compat import string_types, urljoin +from flask_admin._compat import string_types + +Image: Optional[ModuleType] +ImageOps: Optional[ModuleType] try: @@ -302,7 +304,7 @@ class ImageUploadField(FileUploadField): Requires PIL (or Pillow) to be installed. """ - widget = ImageUploadInput() + widget = ImageUploadInput() # type: ignore[assignment] keep_image_formats = ('PNG',) """ @@ -464,10 +466,10 @@ def _resize(self, image, size): if image.size[0] > width or image.size[1] > height: if force: - return ImageOps.fit(self.image, (width, height), Image.ANTIALIAS) + return ImageOps.fit(self.image, (width, height), Image.Resampling.LANCZOS) else: thumb = self.image.copy() - thumb.thumbnail((width, height), Image.ANTIALIAS) + thumb.thumbnail((width, height), Image.Resampling.LANCZOS) return thumb return image diff --git a/flask_admin/form/validators.py b/flask_admin/form/validators.py index db856c368..3d7693f8b 100644 --- a/flask_admin/form/validators.py +++ b/flask_admin/form/validators.py @@ -8,7 +8,7 @@ class FieldListInputRequired(object): Validates that at least one item was provided for a FieldList """ - field_flags = ('required',) + field_flags = {'required': True} def __call__(self, form, field): if len(field.entries) == 0: diff --git a/flask_admin/helpers.py b/flask_admin/helpers.py index da6033715..cb22e100c 100644 --- a/flask_admin/helpers.py +++ b/flask_admin/helpers.py @@ -1,8 +1,10 @@ from re import sub, compile +from urllib.parse import urljoin, urlparse + from flask import g, request, url_for, flash from wtforms.validators import DataRequired, InputRequired -from flask_admin._compat import iteritems, pass_context, urljoin, urlparse +from flask_admin._compat import iteritems, pass_context from ._compat import string_types diff --git a/flask_admin/model/base.py b/flask_admin/model/base.py index c92b1dc96..f4452453c 100755 --- a/flask_admin/model/base.py +++ b/flask_admin/model/base.py @@ -3,17 +3,24 @@ import csv import mimetypes import time +from typing import Optional, cast, Type + from math import ceil import inspect +from collections import OrderedDict from werkzeug.utils import secure_filename from flask import (current_app, request, redirect, flash, abort, json, Response, get_flashed_messages, stream_with_context) + +from .._types import T_COLUMN_LIST, T_FORMATTERS + try: import tablib except ImportError: tablib = None +from wtforms.form import Form from wtforms.fields import HiddenField from wtforms.fields.core import UnboundField from wtforms.validators import ValidationError, InputRequired @@ -28,7 +35,7 @@ get_redirect_target, flash_errors) from flask_admin.tools import rec_getattr from flask_admin._backwards import ObsoleteAttr -from flask_admin._compat import (iteritems, itervalues, OrderedDict, +from flask_admin._compat import (iteritems, itervalues, as_unicode, csv_encode, text_type, pass_context) from .helpers import prettify_name, get_mdict_item_or_list from .ajax import AjaxModelLoader @@ -168,7 +175,10 @@ class BaseModelView(BaseView, ActionsMixin): """Setting this to true will display the details_view as a modal dialog.""" # Customizations - column_list = ObsoleteAttr('column_list', 'list_columns', None) + column_list: Optional[T_COLUMN_LIST] = cast( + None, + ObsoleteAttr('column_list', 'list_columns', None) + ) """ Collection of the model field names for the list view. If set to `None`, will get them from the model. @@ -188,8 +198,9 @@ class MyModelView(BaseModelView): column_list = ('.',) """ - column_exclude_list = ObsoleteAttr('column_exclude_list', - 'excluded_list_columns', None) + column_exclude_list: Optional[T_COLUMN_LIST] = cast( + None, + ObsoleteAttr('column_exclude_list', 'excluded_list_columns', None)) """ Collection of excluded list column names. @@ -273,7 +284,10 @@ def formatter(view, context, model, name): that macros are not supported. """ - column_type_formatters = ObsoleteAttr('column_type_formatters', 'list_type_formatters', None) + column_type_formatters: Optional[T_FORMATTERS] = cast( + None, + ObsoleteAttr('column_type_formatters', 'list_type_formatters', None) + ) """ Dictionary of value type formatters to be used in the list view. @@ -364,9 +378,10 @@ class MyModelView(BaseModelView): ) """ - column_sortable_list = ObsoleteAttr('column_sortable_list', - 'sortable_columns', - None) + column_sortable_list: Optional[T_COLUMN_LIST] = cast( + None, + ObsoleteAttr('column_sortable_list', 'sortable_columns', None), + ) """ Collection of the sortable columns for the list view. If set to `None`, will get them from the model. @@ -417,9 +432,10 @@ class MyModelView(BaseModelView): column_default_sort = [('name', True), ('last_name', True)] """ - column_searchable_list = ObsoleteAttr('column_searchable_list', - 'searchable_columns', - None) + column_searchable_list: Optional[T_COLUMN_LIST] = cast( + None, + ObsoleteAttr('column_searchable_list', 'searchable_columns', None), + ) """ A collection of the searchable columns. It is assumed that only text-only fields are searchable, but it is up to the model @@ -521,7 +537,7 @@ class MyModelView(BaseModelView): If enabled, model interface would not run count query and will only show prev/next pager buttons. """ - form = None + form: Optional[Type[Form]] = None """ Form class. Override if you want to use custom form for your model. Will completely disable form scaffolding functionality. @@ -964,8 +980,7 @@ def scaffold_list_columns(self): Return list of the model field names. Must be implemented in the child class. - Expected return format is list of tuples with field name and - display text. For example:: + Expected return format is list of strings of the field names. For example:: ['name', 'first_name', 'last_name'] """ @@ -1496,7 +1511,7 @@ def _get_default_order(self): def get_list(self, page, sort_field, sort_desc, search, filters, page_size=None): """ - Return a paginated and sorted list of models from the data source. + Return a tuple of a count of results and a paginated and sorted list of models from the data source. Must be implemented in the child class. diff --git a/flask_admin/model/fields.py b/flask_admin/model/fields.py index ed28c7e88..392a1b9fd 100644 --- a/flask_admin/model/fields.py +++ b/flask_admin/model/fields.py @@ -2,11 +2,7 @@ from wtforms.validators import ValidationError from wtforms.fields import FieldList, FormField, SelectFieldBase - -try: - from wtforms.fields import _unset_value as unset_value -except ImportError: - from wtforms.utils import unset_value +from wtforms.utils import unset_value from flask_admin._compat import iteritems from .widgets import (InlineFieldListWidget, InlineFormWidget, diff --git a/flask_admin/model/template.py b/flask_admin/model/template.py index 495e4199a..05132576f 100644 --- a/flask_admin/model/template.py +++ b/flask_admin/model/template.py @@ -1,4 +1,6 @@ -from flask_admin._compat import pass_context, string_types, reduce +from functools import reduce + +from flask_admin._compat import pass_context, string_types from flask_admin.babel import gettext diff --git a/flask_admin/model/typefmt.py b/flask_admin/model/typefmt.py index 981cdaff8..d59dfed85 100755 --- a/flask_admin/model/typefmt.py +++ b/flask_admin/model/typefmt.py @@ -1,11 +1,9 @@ +from enum import Enum import json from markupsafe import Markup from flask_admin._compat import text_type -try: - from enum import Enum -except ImportError: - Enum = None +from flask_admin._types import T_FORMATTERS def null_formatter(view, value, name): @@ -41,7 +39,7 @@ def bool_formatter(view, value, name): return Markup('' % (fa, glyph, glyph, label)) -def list_formatter(view, values, name): +def list_formatter(view, values, name) -> str: """ Return string with comma separated values @@ -51,7 +49,7 @@ def list_formatter(view, values, name): return u', '.join(text_type(v) for v in values) -def enum_formatter(view, value, name): +def enum_formatter(view, value, name) -> str: """ Return the name of the enumerated member. @@ -61,7 +59,7 @@ def enum_formatter(view, value, name): return value.name -def dict_formatter(view, value, name): +def dict_formatter(view, value, name) -> str: """ Removes unicode entities when displaying dict as string. Also unescapes non-ASCII characters stored in the JSON. @@ -72,26 +70,25 @@ def dict_formatter(view, value, name): return json.dumps(value, ensure_ascii=False) -BASE_FORMATTERS = { +BASE_FORMATTERS: T_FORMATTERS = { type(None): empty_formatter, bool: bool_formatter, list: list_formatter, dict: dict_formatter, } -EXPORT_FORMATTERS = { +EXPORT_FORMATTERS: T_FORMATTERS = { type(None): empty_formatter, list: list_formatter, dict: dict_formatter, } -DETAIL_FORMATTERS = { +DETAIL_FORMATTERS: T_FORMATTERS = { type(None): empty_formatter, list: list_formatter, dict: dict_formatter, } -if Enum is not None: - BASE_FORMATTERS[Enum] = enum_formatter - EXPORT_FORMATTERS[Enum] = enum_formatter - DETAIL_FORMATTERS[Enum] = enum_formatter +BASE_FORMATTERS[Enum] = enum_formatter +EXPORT_FORMATTERS[Enum] = enum_formatter +DETAIL_FORMATTERS[Enum] = enum_formatter diff --git a/flask_admin/model/widgets.py b/flask_admin/model/widgets.py index e906790dc..08012270a 100644 --- a/flask_admin/model/widgets.py +++ b/flask_admin/model/widgets.py @@ -52,6 +52,7 @@ def __call__(self, field, **kwargs): kwargs['value'] = separator.join(ids) kwargs['data-json'] = json.dumps(result) kwargs['data-multiple'] = u'1' + kwargs['data-separator'] = separator else: data = field.loader.format(field.data) @@ -65,6 +66,8 @@ def __call__(self, field, **kwargs): minimum_input_length = int(field.loader.options.get('minimum_input_length', 1)) kwargs.setdefault('data-minimum-input-length', minimum_input_length) + kwargs.setdefault('data-separator', ',') + return Markup('' % html_params(name=field.name, **kwargs)) diff --git a/flask_admin/static/admin/js/form.js b/flask_admin/static/admin/js/form.js index 53c0662ca..0f1176d6a 100644 --- a/flask_admin/static/admin/js/form.js +++ b/flask_admin/static/admin/js/form.js @@ -13,6 +13,7 @@ width: 'resolve', minimumInputLength: $el.attr('data-minimum-input-length'), placeholder: 'data-placeholder', + separator: $el.attr('data-separator'), ajax: { url: $el.attr('data-url'), data: function(term, page) { @@ -157,20 +158,16 @@ } // set up tiles - if($el.data('tile-layer-url')){ - var attribution = $el.data('tile-layer-attribution') || '' - L.tileLayer('//'+$el.data('tile-layer-url'), { - attribution: attribution, - maxZoom: 18 - }).addTo(map) - } else { - var mapboxUrl = 'https://api.mapbox.com/styles/v1/mapbox/'+window.MAPBOX_MAP_ID+'/tiles/{z}/{x}/{y}?access_token='+window.MAPBOX_ACCESS_TOKEN - L.tileLayer(mapboxUrl, { - attribution: 'Map data © OpenStreetMap contributors, CC-BY-SA, Imagery © Mapbox', - maxZoom: 18 - }).addTo(map); - } - + var mapboxHostnameAndPath = $el.data('tile-layer-url') || 'api.mapbox.com/styles/v1/mapbox/'+window.MAPBOX_MAP_ID+'/tiles/{z}/{x}/{y}?access_token={accessToken}'; + var attribution = $el.data('tile-layer-attribution') || 'Map data © OpenStreetMap contributors, CC-BY-SA, Imagery © Mapbox'; + L.tileLayer('//' + mapboxHostnameAndPath, { + // Attributes from https://docs.mapbox.com/help/troubleshooting/migrate-legacy-static-tiles-api/ + attribution: attribution, + maxZoom: 18, + tileSize: 512, + zoomOffset: -1, + accessToken: window.MAPBOX_ACCESS_TOKEN + }).addTo(map); // everything below here is to set up editing, so if we're not editable, // we can just return early. diff --git a/flask_admin/static/vendor/moment.min.js b/flask_admin/static/vendor/moment.min.js index 580a6a2dc..ec8bbaf44 100644 --- a/flask_admin/static/vendor/moment.min.js +++ b/flask_admin/static/vendor/moment.min.js @@ -1 +1 @@ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.moment=t()}(this,function(){"use strict";var e,i;function c(){return e.apply(null,arguments)}function o(e){return e instanceof Array||"[object Array]"===Object.prototype.toString.call(e)}function u(e){return null!=e&&"[object Object]"===Object.prototype.toString.call(e)}function l(e){return void 0===e}function d(e){return"number"==typeof e||"[object Number]"===Object.prototype.toString.call(e)}function h(e){return e instanceof Date||"[object Date]"===Object.prototype.toString.call(e)}function f(e,t){var n,s=[];for(n=0;n>>0,s=0;sDe(e)?(r=e+1,a=o-De(e)):(r=e,a=o),{year:r,dayOfYear:a}}function Ie(e,t,n){var s,i,r=Ve(e.year(),t,n),a=Math.floor((e.dayOfYear()-r-1)/7)+1;return a<1?s=a+Ae(i=e.year()-1,t,n):a>Ae(e.year(),t,n)?(s=a-Ae(e.year(),t,n),i=e.year()+1):(i=e.year(),s=a),{week:s,year:i}}function Ae(e,t,n){var s=Ve(e,t,n),i=Ve(e+1,t,n);return(De(e)-s+i)/7}I("w",["ww",2],"wo","week"),I("W",["WW",2],"Wo","isoWeek"),H("week","w"),H("isoWeek","W"),L("week",5),L("isoWeek",5),ue("w",B),ue("ww",B,z),ue("W",B),ue("WW",B,z),fe(["w","ww","W","WW"],function(e,t,n,s){t[s.substr(0,1)]=k(e)});I("d",0,"do","day"),I("dd",0,0,function(e){return this.localeData().weekdaysMin(this,e)}),I("ddd",0,0,function(e){return this.localeData().weekdaysShort(this,e)}),I("dddd",0,0,function(e){return this.localeData().weekdays(this,e)}),I("e",0,0,"weekday"),I("E",0,0,"isoWeekday"),H("day","d"),H("weekday","e"),H("isoWeekday","E"),L("day",11),L("weekday",11),L("isoWeekday",11),ue("d",B),ue("e",B),ue("E",B),ue("dd",function(e,t){return t.weekdaysMinRegex(e)}),ue("ddd",function(e,t){return t.weekdaysShortRegex(e)}),ue("dddd",function(e,t){return t.weekdaysRegex(e)}),fe(["dd","ddd","dddd"],function(e,t,n,s){var i=n._locale.weekdaysParse(e,s,n._strict);null!=i?t.d=i:g(n).invalidWeekday=e}),fe(["d","e","E"],function(e,t,n,s){t[s]=k(e)});var je="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_");var Ze="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_");var ze="Su_Mo_Tu_We_Th_Fr_Sa".split("_");var $e=ae;var qe=ae;var Je=ae;function Be(){function e(e,t){return t.length-e.length}var t,n,s,i,r,a=[],o=[],u=[],l=[];for(t=0;t<7;t++)n=y([2e3,1]).day(t),s=this.weekdaysMin(n,""),i=this.weekdaysShort(n,""),r=this.weekdays(n,""),a.push(s),o.push(i),u.push(r),l.push(s),l.push(i),l.push(r);for(a.sort(e),o.sort(e),u.sort(e),l.sort(e),t=0;t<7;t++)o[t]=de(o[t]),u[t]=de(u[t]),l[t]=de(l[t]);this._weekdaysRegex=new RegExp("^("+l.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+u.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+o.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+a.join("|")+")","i")}function Qe(){return this.hours()%12||12}function Xe(e,t){I(e,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),t)})}function Ke(e,t){return t._meridiemParse}I("H",["HH",2],0,"hour"),I("h",["hh",2],0,Qe),I("k",["kk",2],0,function(){return this.hours()||24}),I("hmm",0,0,function(){return""+Qe.apply(this)+U(this.minutes(),2)}),I("hmmss",0,0,function(){return""+Qe.apply(this)+U(this.minutes(),2)+U(this.seconds(),2)}),I("Hmm",0,0,function(){return""+this.hours()+U(this.minutes(),2)}),I("Hmmss",0,0,function(){return""+this.hours()+U(this.minutes(),2)+U(this.seconds(),2)}),Xe("a",!0),Xe("A",!1),H("hour","h"),L("hour",13),ue("a",Ke),ue("A",Ke),ue("H",B),ue("h",B),ue("k",B),ue("HH",B,z),ue("hh",B,z),ue("kk",B,z),ue("hmm",Q),ue("hmmss",X),ue("Hmm",Q),ue("Hmmss",X),ce(["H","HH"],ge),ce(["k","kk"],function(e,t,n){var s=k(e);t[ge]=24===s?0:s}),ce(["a","A"],function(e,t,n){n._isPm=n._locale.isPM(e),n._meridiem=e}),ce(["h","hh"],function(e,t,n){t[ge]=k(e),g(n).bigHour=!0}),ce("hmm",function(e,t,n){var s=e.length-2;t[ge]=k(e.substr(0,s)),t[pe]=k(e.substr(s)),g(n).bigHour=!0}),ce("hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=k(e.substr(0,s)),t[pe]=k(e.substr(s,2)),t[ve]=k(e.substr(i)),g(n).bigHour=!0}),ce("Hmm",function(e,t,n){var s=e.length-2;t[ge]=k(e.substr(0,s)),t[pe]=k(e.substr(s))}),ce("Hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=k(e.substr(0,s)),t[pe]=k(e.substr(s,2)),t[ve]=k(e.substr(i))});var et,tt=Te("Hours",!0),nt={calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},invalidDate:"Invalid date",ordinal:"%d",dayOfMonthOrdinalParse:/\d{1,2}/,relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},months:He,monthsShort:Re,week:{dow:0,doy:6},weekdays:je,weekdaysMin:ze,weekdaysShort:Ze,meridiemParse:/[ap]\.?m?\.?/i},st={},it={};function rt(e){return e?e.toLowerCase().replace("_","-"):e}function at(e){var t=null;if(!st[e]&&"undefined"!=typeof module&&module&&module.exports)try{t=et._abbr,require("./locale/"+e),ot(t)}catch(e){}return st[e]}function ot(e,t){var n;return e&&((n=l(t)?lt(e):ut(e,t))?et=n:"undefined"!=typeof console&&console.warn&&console.warn("Locale "+e+" not found. Did you forget to load it?")),et._abbr}function ut(e,t){if(null!==t){var n,s=nt;if(t.abbr=e,null!=st[e])T("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),s=st[e]._config;else if(null!=t.parentLocale)if(null!=st[t.parentLocale])s=st[t.parentLocale]._config;else{if(null==(n=at(t.parentLocale)))return it[t.parentLocale]||(it[t.parentLocale]=[]),it[t.parentLocale].push({name:e,config:t}),null;s=n._config}return st[e]=new P(b(s,t)),it[e]&&it[e].forEach(function(e){ut(e.name,e.config)}),ot(e),st[e]}return delete st[e],null}function lt(e){var t;if(e&&e._locale&&e._locale._abbr&&(e=e._locale._abbr),!e)return et;if(!o(e)){if(t=at(e))return t;e=[e]}return function(e){for(var t,n,s,i,r=0;r=t&&a(i,n,!0)>=t-1)break;t--}r++}return et}(e)}function dt(e){var t,n=e._a;return n&&-2===g(e).overflow&&(t=n[_e]<0||11Pe(n[me],n[_e])?ye:n[ge]<0||24Ae(n,r,a)?g(e)._overflowWeeks=!0:null!=u?g(e)._overflowWeekday=!0:(o=Ee(n,s,i,r,a),e._a[me]=o.year,e._dayOfYear=o.dayOfYear)}(e),null!=e._dayOfYear&&(r=ht(e._a[me],s[me]),(e._dayOfYear>De(r)||0===e._dayOfYear)&&(g(e)._overflowDayOfYear=!0),n=Ge(r,0,e._dayOfYear),e._a[_e]=n.getUTCMonth(),e._a[ye]=n.getUTCDate()),t=0;t<3&&null==e._a[t];++t)e._a[t]=a[t]=s[t];for(;t<7;t++)e._a[t]=a[t]=null==e._a[t]?2===t?1:0:e._a[t];24===e._a[ge]&&0===e._a[pe]&&0===e._a[ve]&&0===e._a[we]&&(e._nextDay=!0,e._a[ge]=0),e._d=(e._useUTC?Ge:function(e,t,n,s,i,r,a){var o=new Date(e,t,n,s,i,r,a);return e<100&&0<=e&&isFinite(o.getFullYear())&&o.setFullYear(e),o}).apply(null,a),i=e._useUTC?e._d.getUTCDay():e._d.getDay(),null!=e._tzm&&e._d.setUTCMinutes(e._d.getUTCMinutes()-e._tzm),e._nextDay&&(e._a[ge]=24),e._w&&void 0!==e._w.d&&e._w.d!==i&&(g(e).weekdayMismatch=!0)}}var ft=/^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,mt=/^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,_t=/Z|[+-]\d\d(?::?\d\d)?/,yt=[["YYYYYY-MM-DD",/[+-]\d{6}-\d\d-\d\d/],["YYYY-MM-DD",/\d{4}-\d\d-\d\d/],["GGGG-[W]WW-E",/\d{4}-W\d\d-\d/],["GGGG-[W]WW",/\d{4}-W\d\d/,!1],["YYYY-DDD",/\d{4}-\d{3}/],["YYYY-MM",/\d{4}-\d\d/,!1],["YYYYYYMMDD",/[+-]\d{10}/],["YYYYMMDD",/\d{8}/],["GGGG[W]WWE",/\d{4}W\d{3}/],["GGGG[W]WW",/\d{4}W\d{2}/,!1],["YYYYDDD",/\d{7}/]],gt=[["HH:mm:ss.SSSS",/\d\d:\d\d:\d\d\.\d+/],["HH:mm:ss,SSSS",/\d\d:\d\d:\d\d,\d+/],["HH:mm:ss",/\d\d:\d\d:\d\d/],["HH:mm",/\d\d:\d\d/],["HHmmss.SSSS",/\d\d\d\d\d\d\.\d+/],["HHmmss,SSSS",/\d\d\d\d\d\d,\d+/],["HHmmss",/\d\d\d\d\d\d/],["HHmm",/\d\d\d\d/],["HH",/\d\d/]],pt=/^\/?Date\((\-?\d+)/i;function vt(e){var t,n,s,i,r,a,o=e._i,u=ft.exec(o)||mt.exec(o);if(u){for(g(e).iso=!0,t=0,n=yt.length;tn.valueOf():n.valueOf()this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},ln.isLocal=function(){return!!this.isValid()&&!this._isUTC},ln.isUtcOffset=function(){return!!this.isValid()&&this._isUTC},ln.isUtc=Vt,ln.isUTC=Vt,ln.zoneAbbr=function(){return this._isUTC?"UTC":""},ln.zoneName=function(){return this._isUTC?"Coordinated Universal Time":""},ln.dates=n("dates accessor is deprecated. Use date instead.",nn),ln.months=n("months accessor is deprecated. Use month instead",Fe),ln.years=n("years accessor is deprecated. Use year instead",Oe),ln.zone=n("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",function(e,t){return null!=e?("string"!=typeof e&&(e=-e),this.utcOffset(e,t),this):-this.utcOffset()}),ln.isDSTShifted=n("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",function(){if(!l(this._isDSTShifted))return this._isDSTShifted;var e={};if(w(e,this),(e=Yt(e))._a){var t=e._isUTC?y(e._a):Tt(e._a);this._isDSTShifted=this.isValid()&&0>>0,s=0;sAe(e)?(r=e+1,t-Ae(e)):(r=e,t);return{year:r,dayOfYear:n}}function qe(e,t,n){var s,i,r=ze(e.year(),t,n),r=Math.floor((e.dayOfYear()-r-1)/7)+1;return r<1?s=r+P(i=e.year()-1,t,n):r>P(e.year(),t,n)?(s=r-P(e.year(),t,n),i=e.year()+1):(i=e.year(),s=r),{week:s,year:i}}function P(e,t,n){var s=ze(e,t,n),t=ze(e+1,t,n);return(Ae(e)-s+t)/7}s("w",["ww",2],"wo","week"),s("W",["WW",2],"Wo","isoWeek"),t("week","w"),t("isoWeek","W"),n("week",5),n("isoWeek",5),k("w",p),k("ww",p,w),k("W",p),k("WW",p,w),Te(["w","ww","W","WW"],function(e,t,n,s){t[s.substr(0,1)]=g(e)});function Be(e,t){return e.slice(t,7).concat(e.slice(0,t))}s("d",0,"do","day"),s("dd",0,0,function(e){return this.localeData().weekdaysMin(this,e)}),s("ddd",0,0,function(e){return this.localeData().weekdaysShort(this,e)}),s("dddd",0,0,function(e){return this.localeData().weekdays(this,e)}),s("e",0,0,"weekday"),s("E",0,0,"isoWeekday"),t("day","d"),t("weekday","e"),t("isoWeekday","E"),n("day",11),n("weekday",11),n("isoWeekday",11),k("d",p),k("e",p),k("E",p),k("dd",function(e,t){return t.weekdaysMinRegex(e)}),k("ddd",function(e,t){return t.weekdaysShortRegex(e)}),k("dddd",function(e,t){return t.weekdaysRegex(e)}),Te(["dd","ddd","dddd"],function(e,t,n,s){s=n._locale.weekdaysParse(e,s,n._strict);null!=s?t.d=s:m(n).invalidWeekday=e}),Te(["d","e","E"],function(e,t,n,s){t[s]=g(e)});var Je="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),Qe="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),Xe="Su_Mo_Tu_We_Th_Fr_Sa".split("_"),Ke=v,et=v,tt=v;function nt(){function e(e,t){return t.length-e.length}for(var t,n,s,i=[],r=[],a=[],o=[],u=0;u<7;u++)s=l([2e3,1]).day(u),t=M(this.weekdaysMin(s,"")),n=M(this.weekdaysShort(s,"")),s=M(this.weekdays(s,"")),i.push(t),r.push(n),a.push(s),o.push(t),o.push(n),o.push(s);i.sort(e),r.sort(e),a.sort(e),o.sort(e),this._weekdaysRegex=new RegExp("^("+o.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+a.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+r.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+i.join("|")+")","i")}function st(){return this.hours()%12||12}function it(e,t){s(e,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),t)})}function rt(e,t){return t._meridiemParse}s("H",["HH",2],0,"hour"),s("h",["hh",2],0,st),s("k",["kk",2],0,function(){return this.hours()||24}),s("hmm",0,0,function(){return""+st.apply(this)+r(this.minutes(),2)}),s("hmmss",0,0,function(){return""+st.apply(this)+r(this.minutes(),2)+r(this.seconds(),2)}),s("Hmm",0,0,function(){return""+this.hours()+r(this.minutes(),2)}),s("Hmmss",0,0,function(){return""+this.hours()+r(this.minutes(),2)+r(this.seconds(),2)}),it("a",!0),it("A",!1),t("hour","h"),n("hour",13),k("a",rt),k("A",rt),k("H",p),k("h",p),k("k",p),k("HH",p,w),k("hh",p,w),k("kk",p,w),k("hmm",ge),k("hmmss",we),k("Hmm",ge),k("Hmmss",we),D(["H","HH"],x),D(["k","kk"],function(e,t,n){e=g(e);t[x]=24===e?0:e}),D(["a","A"],function(e,t,n){n._isPm=n._locale.isPM(e),n._meridiem=e}),D(["h","hh"],function(e,t,n){t[x]=g(e),m(n).bigHour=!0}),D("hmm",function(e,t,n){var s=e.length-2;t[x]=g(e.substr(0,s)),t[T]=g(e.substr(s)),m(n).bigHour=!0}),D("hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[x]=g(e.substr(0,s)),t[T]=g(e.substr(s,2)),t[N]=g(e.substr(i)),m(n).bigHour=!0}),D("Hmm",function(e,t,n){var s=e.length-2;t[x]=g(e.substr(0,s)),t[T]=g(e.substr(s))}),D("Hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[x]=g(e.substr(0,s)),t[T]=g(e.substr(s,2)),t[N]=g(e.substr(i))});v=de("Hours",!0);var at,ot={calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},invalidDate:"Invalid date",ordinal:"%d",dayOfMonthOrdinalParse:/\d{1,2}/,relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",w:"a week",ww:"%d weeks",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},months:Ce,monthsShort:Ue,week:{dow:0,doy:6},weekdays:Je,weekdaysMin:Xe,weekdaysShort:Qe,meridiemParse:/[ap]\.?m?\.?/i},R={},ut={};function lt(e){return e&&e.toLowerCase().replace("_","-")}function ht(e){for(var t,n,s,i,r=0;r=t&&function(e,t){for(var n=Math.min(e.length,t.length),s=0;s=t-1)break;t--}r++}return at}function dt(t){var e;if(void 0===R[t]&&"undefined"!=typeof module&&module&&module.exports&&null!=t.match("^[^/\\\\]*$"))try{e=at._abbr,require("./locale/"+t),ct(e)}catch(e){R[t]=null}return R[t]}function ct(e,t){return e&&((t=o(t)?mt(e):ft(e,t))?at=t:"undefined"!=typeof console&&console.warn&&console.warn("Locale "+e+" not found. Did you forget to load it?")),at._abbr}function ft(e,t){if(null===t)return delete R[e],null;var n,s=ot;if(t.abbr=e,null!=R[e])Q("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),s=R[e]._config;else if(null!=t.parentLocale)if(null!=R[t.parentLocale])s=R[t.parentLocale]._config;else{if(null==(n=dt(t.parentLocale)))return ut[t.parentLocale]||(ut[t.parentLocale]=[]),ut[t.parentLocale].push({name:e,config:t}),null;s=n._config}return R[e]=new K(X(s,t)),ut[e]&&ut[e].forEach(function(e){ft(e.name,e.config)}),ct(e),R[e]}function mt(e){var t;if(!(e=e&&e._locale&&e._locale._abbr?e._locale._abbr:e))return at;if(!a(e)){if(t=dt(e))return t;e=[e]}return ht(e)}function _t(e){var t=e._a;return t&&-2===m(e).overflow&&(t=t[O]<0||11We(t[Y],t[O])?b:t[x]<0||24P(r,u,l)?m(s)._overflowWeeks=!0:null!=h?m(s)._overflowWeekday=!0:(d=$e(r,a,o,u,l),s._a[Y]=d.year,s._dayOfYear=d.dayOfYear)),null!=e._dayOfYear&&(i=bt(e._a[Y],n[Y]),(e._dayOfYear>Ae(i)||0===e._dayOfYear)&&(m(e)._overflowDayOfYear=!0),h=Ze(i,0,e._dayOfYear),e._a[O]=h.getUTCMonth(),e._a[b]=h.getUTCDate()),t=0;t<3&&null==e._a[t];++t)e._a[t]=c[t]=n[t];for(;t<7;t++)e._a[t]=c[t]=null==e._a[t]?2===t?1:0:e._a[t];24===e._a[x]&&0===e._a[T]&&0===e._a[N]&&0===e._a[Ne]&&(e._nextDay=!0,e._a[x]=0),e._d=(e._useUTC?Ze:je).apply(null,c),r=e._useUTC?e._d.getUTCDay():e._d.getDay(),null!=e._tzm&&e._d.setUTCMinutes(e._d.getUTCMinutes()-e._tzm),e._nextDay&&(e._a[x]=24),e._w&&void 0!==e._w.d&&e._w.d!==r&&(m(e).weekdayMismatch=!0)}}function Tt(e){if(e._f===f.ISO_8601)St(e);else if(e._f===f.RFC_2822)Ot(e);else{e._a=[],m(e).empty=!0;for(var t,n,s,i,r,a=""+e._i,o=a.length,u=0,l=ae(e._f,e._locale).match(te)||[],h=l.length,d=0;de.valueOf():e.valueOf()"}),i.toJSON=function(){return this.isValid()?this.toISOString():null},i.toString=function(){return this.clone().locale("en").format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ")},i.unix=function(){return Math.floor(this.valueOf()/1e3)},i.valueOf=function(){return this._d.valueOf()-6e4*(this._offset||0)},i.creationData=function(){return{input:this._i,format:this._f,locale:this._locale,isUTC:this._isUTC,strict:this._strict}},i.eraName=function(){for(var e,t=this.localeData().eras(),n=0,s=t.length;nthis.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},i.isLocal=function(){return!!this.isValid()&&!this._isUTC},i.isUtcOffset=function(){return!!this.isValid()&&this._isUTC},i.isUtc=At,i.isUTC=At,i.zoneAbbr=function(){return this._isUTC?"UTC":""},i.zoneName=function(){return this._isUTC?"Coordinated Universal Time":""},i.dates=e("dates accessor is deprecated. Use date instead.",ve),i.months=e("months accessor is deprecated. Use month instead",Ge),i.years=e("years accessor is deprecated. Use year instead",Ie),i.zone=e("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",function(e,t){return null!=e?(this.utcOffset(e="string"!=typeof e?-e:e,t),this):-this.utcOffset()}),i.isDSTShifted=e("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",function(){if(!o(this._isDSTShifted))return this._isDSTShifted;var e,t={};return $(t,this),(t=Nt(t))._a?(e=(t._isUTC?l:W)(t._a),this._isDSTShifted=this.isValid()&&0 - + {% if admin_view.extra_js %} {% for js_url in admin_view.extra_js %} diff --git a/flask_admin/templates/bootstrap2/admin/model/layout.html b/flask_admin/templates/bootstrap2/admin/model/layout.html index 396313988..44086dac7 100644 --- a/flask_admin/templates/bootstrap2/admin/model/layout.html +++ b/flask_admin/templates/bootstrap2/admin/model/layout.html @@ -41,7 +41,7 @@ {% endif %} {% if sort_desc %} - + {% endif %} {% if search %} @@ -76,7 +76,7 @@ {% endif %} {% if sort_desc %} - + {% endif %} {%- set full_search_placeholder = _gettext('Search') %} {%- if search_placeholder %}{% set full_search_placeholder = [full_search_placeholder, search_placeholder] | join(": ") %}{% endif %} diff --git a/flask_admin/templates/bootstrap3/admin/base.html b/flask_admin/templates/bootstrap3/admin/base.html index 9aefc8ba9..8a841d931 100644 --- a/flask_admin/templates/bootstrap3/admin/base.html +++ b/flask_admin/templates/bootstrap3/admin/base.html @@ -82,7 +82,7 @@ {% block tail_js %} - + {% if admin_view.extra_js %} diff --git a/flask_admin/templates/bootstrap3/admin/model/layout.html b/flask_admin/templates/bootstrap3/admin/model/layout.html index 792eb5b83..d31a39c74 100644 --- a/flask_admin/templates/bootstrap3/admin/model/layout.html +++ b/flask_admin/templates/bootstrap3/admin/model/layout.html @@ -41,7 +41,7 @@ {% endif %} {% if sort_desc %} - + {% endif %} {% if search %} @@ -76,7 +76,7 @@ {% endif %} {% if sort_desc %} - + {% endif %} {%- set full_search_placeholder = _gettext('Search') %} {%- set max_size = config.get('FLASK_ADMIN_SEARCH_SIZE_MAX', 100) %} diff --git a/flask_admin/templates/bootstrap4/admin/base.html b/flask_admin/templates/bootstrap4/admin/base.html index d2ee49a35..cccb75fdb 100644 --- a/flask_admin/templates/bootstrap4/admin/base.html +++ b/flask_admin/templates/bootstrap4/admin/base.html @@ -81,7 +81,7 @@ - + -{% endblock %} diff --git a/flask_admin/templates/bootstrap4/admin/model/modals/details.html b/flask_admin/templates/bootstrap4/admin/model/modals/details.html index 793b4d00e..0e8a52cdf 100755 --- a/flask_admin/templates/bootstrap4/admin/model/modals/details.html +++ b/flask_admin/templates/bootstrap4/admin/model/modals/details.html @@ -36,5 +36,4 @@

{{ _gettext('View Record') + ' #' + request.args.get('id') }}

{% block tail %} - -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/flask_admin/templates/bootstrap4/admin/model/modals/edit.html b/flask_admin/templates/bootstrap4/admin/model/modals/edit.html index dd9e777a4..a39f7e8b2 100644 --- a/flask_admin/templates/bootstrap4/admin/model/modals/edit.html +++ b/flask_admin/templates/bootstrap4/admin/model/modals/edit.html @@ -25,7 +25,3 @@

hello

' in data + view = CustomModelView(Model1, db.session, + form_create_rules=(rules.Header('hello'),)) + admin.add_view(view) + client = app.test_client() -def test_rule_field_set(): - app, db, admin = setup() + rv = client.get('/admin/model1/new/') + assert rv.status_code == 200 - Model1, _ = create_models(db) - db.create_all() + data = rv.data.decode('utf-8') + assert '

hello

' in data - view = CustomModelView(Model1, db.session, - form_create_rules=(rules.FieldSet(['test2', 'test1', 'test4'], 'header'),)) - admin.add_view(view) - client = app.test_client() +@pytest.mark.filterwarnings("ignore:Fields missing:UserWarning") +def test_rule_field_set(app, db, admin): + with app.app_context(): + Model1, _ = create_models(db) + db.create_all() - rv = client.get('/admin/model1/new/') - assert rv.status_code == 200 + view = CustomModelView(Model1, db.session, + form_create_rules=(rules.FieldSet(['test2', 'test1', 'test4'], 'header'),)) + admin.add_view(view) - data = rv.data.decode('utf-8') - assert '

header

' in data - pos1 = data.find('Test1') - pos2 = data.find('Test2') - pos3 = data.find('Test3') - pos4 = data.find('Test4') - assert pos1 > pos2 - assert pos4 > pos1 - assert pos3 == -1 + client = app.test_client() + rv = client.get('/admin/model1/new/') + assert rv.status_code == 200 -def test_rule_inlinefieldlist(): - app, db, admin = setup() + data = rv.data.decode('utf-8') + assert '

header

' in data + pos1 = data.find('Test1') + pos2 = data.find('Test2') + pos3 = data.find('Test3') + pos4 = data.find('Test4') + assert pos1 > pos2 + assert pos4 > pos1 + assert pos3 == -1 - Model1, Model2 = create_models(db) - db.create_all() - view = CustomModelView(Model1, db.session, - inline_models=(Model2,), - form_create_rules=('test1', 'model2')) - admin.add_view(view) +@pytest.mark.filterwarnings("ignore:Fields missing:UserWarning") +def test_rule_inlinefieldlist(app, db, admin): + with app.app_context(): + Model1, Model2 = create_models(db) + db.create_all() - client = app.test_client() + view = CustomModelView(Model1, db.session, + inline_models=(Model2,), + form_create_rules=('test1', 'model2')) + admin.add_view(view) - rv = client.get('/admin/model1/new/') - assert rv.status_code == 200 + client = app.test_client() + rv = client.get('/admin/model1/new/') + assert rv.status_code == 200 -def test_inline_model_rules(): - app, db, admin = setup() - Model1, Model2 = create_models(db) - db.create_all() +def test_inline_model_rules(app, db, admin): + with app.app_context(): + Model1, Model2 = create_models(db) + db.create_all() - view = CustomModelView(Model1, db.session, - inline_models=[(Model2, dict(form_rules=('string_field', 'bool_field')))]) - admin.add_view(view) + view = CustomModelView(Model1, db.session, + inline_models=[(Model2, dict(form_rules=('string_field', 'bool_field')))]) + admin.add_view(view) - client = app.test_client() + client = app.test_client() - rv = client.get('/admin/model1/new/') - assert rv.status_code == 200 + rv = client.get('/admin/model1/new/') + assert rv.status_code == 200 - data = rv.data.decode('utf-8') - assert 'int_field' not in data + data = rv.data.decode('utf-8') + assert 'int_field' not in data diff --git a/flask_admin/tests/sqla/test_inlineform.py b/flask_admin/tests/sqla/test_inlineform.py index 520d28310..d28f89078 100644 --- a/flask_admin/tests/sqla/test_inlineform.py +++ b/flask_admin/tests/sqla/test_inlineform.py @@ -6,295 +6,291 @@ from flask_admin.contrib.sqla.fields import InlineModelFormList from flask_admin.contrib.sqla.validators import ItemsRequired -from . import setup +def test_inline_form(app, db, admin): + with app.app_context(): + client = app.test_client() + + # Set up models and database + class User(db.Model): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String, unique=True) + + def __init__(self, name=None): + self.name = name + + class UserInfo(db.Model): + __tablename__ = 'user_info' + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.String, nullable=False) + val = db.Column(db.String) + user_id = db.Column(db.Integer, db.ForeignKey(User.id)) + user = db.relationship(User, backref=db.backref('info', cascade="all, delete-orphan", single_parent=True)) + + db.create_all() + + # Set up Admin + class UserModelView(ModelView): + inline_models = (UserInfo,) + + view = UserModelView(User, db.session) + admin.add_view(view) + + # Basic tests + assert view._create_form_class is not None + assert view._edit_form_class is not None + assert view.endpoint == 'user' + + # Verify form + assert view._create_form_class.name.field_class == fields.StringField + assert view._create_form_class.info.field_class == InlineModelFormList + + rv = client.get('/admin/user/') + assert rv.status_code == 200 + + rv = client.get('/admin/user/new/') + assert rv.status_code == 200 + + # Create + rv = client.post('/admin/user/new/', data=dict(name=u'äõüxyz')) + assert rv.status_code == 302 + assert User.query.count() == 1 + assert UserInfo.query.count() == 0 + + data = {'name': u'fbar', 'info-0-key': 'foo', 'info-0-val': 'bar'} + rv = client.post('/admin/user/new/', data=data) + assert rv.status_code == 302 + assert User.query.count() == 2 + assert UserInfo.query.count() == 1 + + # Edit + rv = client.get('/admin/user/edit/?id=2') + assert rv.status_code == 200 + # Edit - update + data = { + 'name': u'barfoo', + 'info-0-id': 1, + 'info-0-key': u'xxx', + 'info-0-val': u'yyy', + } + rv = client.post('/admin/user/edit/?id=2', data=data) + assert UserInfo.query.count() == 1 + assert UserInfo.query.one().key == u'xxx' + + # Edit - add & delete + data = { + 'name': u'barf', + 'del-info-0': 'on', + 'info-0-id': '1', + 'info-0-key': 'yyy', + 'info-0-val': 'xxx', + 'info-1-id': None, + 'info-1-key': u'bar', + 'info-1-val': u'foo', + } + rv = client.post('/admin/user/edit/?id=2', data=data) + assert rv.status_code == 302 + assert User.query.count() == 2 + assert User.query.get(2).name == u'barf' + assert UserInfo.query.count() == 1 + assert UserInfo.query.one().key == u'bar' + + # Delete + rv = client.post('/admin/user/delete/?id=2') + assert rv.status_code == 302 + assert User.query.count() == 1 + rv = client.post('/admin/user/delete/?id=1') + assert rv.status_code == 302 + assert User.query.count() == 0 + assert UserInfo.query.count() == 0 + + +def test_inline_form_required(app, db, admin): + with app.app_context(): + client = app.test_client() + + # Set up models and database + class User(db.Model): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String, unique=True) + + def __init__(self, name=None): + self.name = name + + class UserEmail(db.Model): + __tablename__ = 'user_info' + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String, nullable=False, unique=True) + verified_at = db.Column(db.DateTime) + user_id = db.Column(db.Integer, db.ForeignKey(User.id)) + user = db.relationship(User, backref=db.backref('emails', cascade="all, delete-orphan", single_parent=True)) + + db.create_all() + + # Set up Admin + class UserModelView(ModelView): + inline_models = (UserEmail,) + form_args = { + "emails": {"validators": [ItemsRequired()]} + } -def test_inline_form(): - app, db, admin = setup() - client = app.test_client() + view = UserModelView(User, db.session) + admin.add_view(view) - # Set up models and database - class User(db.Model): - __tablename__ = 'users' - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String, unique=True) - - def __init__(self, name=None): - self.name = name - - class UserInfo(db.Model): - __tablename__ = 'user_info' - id = db.Column(db.Integer, primary_key=True) - key = db.Column(db.String, nullable=False) - val = db.Column(db.String) - user_id = db.Column(db.Integer, db.ForeignKey(User.id)) - user = db.relationship(User, backref=db.backref('info', cascade="all, delete-orphan", single_parent=True)) - - db.create_all() - - # Set up Admin - class UserModelView(ModelView): - inline_models = (UserInfo,) - - view = UserModelView(User, db.session) - admin.add_view(view) - - # Basic tests - assert view._create_form_class is not None - assert view._edit_form_class is not None - assert view.endpoint == 'user' - - # Verify form - assert view._create_form_class.name.field_class == fields.StringField - assert view._create_form_class.info.field_class == InlineModelFormList - - rv = client.get('/admin/user/') - assert rv.status_code == 200 - - rv = client.get('/admin/user/new/') - assert rv.status_code == 200 - - # Create - rv = client.post('/admin/user/new/', data=dict(name=u'äõüxyz')) - assert rv.status_code == 302 - assert User.query.count() == 1 - assert UserInfo.query.count() == 0 - - data = {'name': u'fbar', 'info-0-key': 'foo', 'info-0-val': 'bar'} - rv = client.post('/admin/user/new/', data=data) - assert rv.status_code == 302 - assert User.query.count() == 2 - assert UserInfo.query.count() == 1 - - # Edit - rv = client.get('/admin/user/edit/?id=2') - assert rv.status_code == 200 - # Edit - update - data = { - 'name': u'barfoo', - 'info-0-id': 1, - 'info-0-key': u'xxx', - 'info-0-val': u'yyy', - } - rv = client.post('/admin/user/edit/?id=2', data=data) - assert UserInfo.query.count() == 1 - assert UserInfo.query.one().key == u'xxx' - - # Edit - add & delete - data = { - 'name': u'barf', - 'del-info-0': 'on', - 'info-0-id': '1', - 'info-0-key': 'yyy', - 'info-0-val': 'xxx', - 'info-1-id': None, - 'info-1-key': u'bar', - 'info-1-val': u'foo', - } - rv = client.post('/admin/user/edit/?id=2', data=data) - assert rv.status_code == 302 - assert User.query.count() == 2 - assert User.query.get(2).name == u'barf' - assert UserInfo.query.count() == 1 - assert UserInfo.query.one().key == u'bar' - - # Delete - rv = client.post('/admin/user/delete/?id=2') - assert rv.status_code == 302 - assert User.query.count() == 1 - rv = client.post('/admin/user/delete/?id=1') - assert rv.status_code == 302 - assert User.query.count() == 0 - assert UserInfo.query.count() == 0 - - -def test_inline_form_required(): - app, db, admin = setup() - client = app.test_client() + # Create + rv = client.post('/admin/user/new/', data=dict(name=u'no-email')) + assert rv.status_code == 200 + assert User.query.count() == 0 - # Set up models and database - class User(db.Model): - __tablename__ = 'users' - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String, unique=True) - - def __init__(self, name=None): - self.name = name - - class UserEmail(db.Model): - __tablename__ = 'user_info' - id = db.Column(db.Integer, primary_key=True) - email = db.Column(db.String, nullable=False, unique=True) - verified_at = db.Column(db.DateTime) - user_id = db.Column(db.Integer, db.ForeignKey(User.id)) - user = db.relationship(User, backref=db.backref('emails', cascade="all, delete-orphan", single_parent=True)) - - db.create_all() - - # Set up Admin - class UserModelView(ModelView): - inline_models = (UserEmail,) - form_args = { - "emails": {"validators": [ItemsRequired()]} + data = { + 'name': 'hasEmail', + 'emails-0-email': 'foo@bar.com', } - - view = UserModelView(User, db.session) - admin.add_view(view) - - # Create - rv = client.post('/admin/user/new/', data=dict(name=u'no-email')) - assert rv.status_code == 200 - assert User.query.count() == 0 - - data = { - 'name': 'hasEmail', - 'emails-0-email': 'foo@bar.com', - } - rv = client.post('/admin/user/new/', data=data) - assert rv.status_code == 302 - assert User.query.count() == 1 - assert UserEmail.query.count() == 1 - - # Attempted delete, prevented by ItemsRequired - data = { - 'name': 'hasEmail', - 'del-emails-0': 'on', - 'emails-0-email': 'foo@bar.com', - } - rv = client.post('/admin/user/edit/?id=1', data=data) - assert rv.status_code == 200 - assert User.query.count() == 1 - assert UserEmail.query.count() == 1 - - -def test_inline_form_ajax_fk(): - app, db, admin = setup() - - # Set up models and database - class User(db.Model): - __tablename__ = 'users' - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String, unique=True) - - def __init__(self, name=None): - self.name = name - - class Tag(db.Model): - __tablename__ = 'tags' - - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String, unique=True) - - class UserInfo(db.Model): - __tablename__ = 'user_info' - id = db.Column(db.Integer, primary_key=True) - key = db.Column(db.String, nullable=False) - val = db.Column(db.String) - - user_id = db.Column(db.Integer, db.ForeignKey(User.id)) - user = db.relationship(User, backref=db.backref('info', cascade="all, delete-orphan", single_parent=True)) - - tag_id = db.Column(db.Integer, db.ForeignKey(Tag.id)) - tag = db.relationship(Tag, backref='user_info') - - db.create_all() - - # Set up Admin - class UserModelView(ModelView): - opts = { - 'form_ajax_refs': { - 'tag': { - 'fields': ['name'] + rv = client.post('/admin/user/new/', data=data) + assert rv.status_code == 302 + assert User.query.count() == 1 + assert UserEmail.query.count() == 1 + + # Attempted delete, prevented by ItemsRequired + data = { + 'name': 'hasEmail', + 'del-emails-0': 'on', + 'emails-0-email': 'foo@bar.com', + } + rv = client.post('/admin/user/edit/?id=1', data=data) + assert rv.status_code == 200 + assert User.query.count() == 1 + assert UserEmail.query.count() == 1 + + +def test_inline_form_ajax_fk(app, db, admin): + with app.app_context(): + # Set up models and database + class User(db.Model): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String, unique=True) + + def __init__(self, name=None): + self.name = name + + class Tag(db.Model): + __tablename__ = 'tags' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String, unique=True) + + class UserInfo(db.Model): + __tablename__ = 'user_info' + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.String, nullable=False) + val = db.Column(db.String) + + user_id = db.Column(db.Integer, db.ForeignKey(User.id)) + user = db.relationship(User, backref=db.backref('info', cascade="all, delete-orphan", single_parent=True)) + + tag_id = db.Column(db.Integer, db.ForeignKey(Tag.id)) + tag = db.relationship(Tag, backref='user_info') + + db.create_all() + + # Set up Admin + class UserModelView(ModelView): + opts = { + 'form_ajax_refs': { + 'tag': { + 'fields': ['name'] + } } } - } - - inline_models = [(UserInfo, opts)] - view = UserModelView(User, db.session) - admin.add_view(view) + inline_models = [(UserInfo, opts)] - form = view.create_form() - user_info_form = form.info.unbound_field.args[0] - loader = user_info_form.tag.args[0] - assert loader.name == 'userinfo-tag' - assert loader.model == Tag + view = UserModelView(User, db.session) + admin.add_view(view) - assert 'userinfo-tag' in view._form_ajax_refs + form = view.create_form() + user_info_form = form.info.unbound_field.args[0] + loader = user_info_form.tag.args[0] + assert loader.name == 'userinfo-tag' + assert loader.model == Tag + assert 'userinfo-tag' in view._form_ajax_refs -def test_inline_form_self(): - app, db, admin = setup() - class Tree(db.Model): - id = db.Column(db.Integer, primary_key=True) - parent_id = db.Column(db.Integer, db.ForeignKey('tree.id')) - parent = db.relationship('Tree', remote_side=[id], backref='children') +def test_inline_form_self(app, db, admin): + with app.app_context(): + class Tree(db.Model): + id = db.Column(db.Integer, primary_key=True) + parent_id = db.Column(db.Integer, db.ForeignKey('tree.id')) + parent = db.relationship('Tree', remote_side=[id], backref='children') - db.create_all() + db.create_all() - class TreeView(ModelView): - inline_models = (Tree,) + class TreeView(ModelView): + inline_models = (Tree,) - view = TreeView(Tree, db.session) + view = TreeView(Tree, db.session) - parent = Tree() - child = Tree(parent=parent) - form = view.edit_form(child) - assert form.parent.data == parent + parent = Tree() + child = Tree(parent=parent) + form = view.edit_form(child) + assert form.parent.data == parent -def test_inline_form_base_class(): - app, db, admin = setup() +def test_inline_form_base_class(app, db, admin): client = app.test_client() - # Set up models and database - class User(db.Model): - __tablename__ = 'users' - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String, unique=True) - - def __init__(self, name=None): - self.name = name - - class UserEmail(db.Model): - __tablename__ = 'user_info' - id = db.Column(db.Integer, primary_key=True) - email = db.Column(db.String, nullable=False, unique=True) - verified_at = db.Column(db.DateTime) - user_id = db.Column(db.Integer, db.ForeignKey(User.id)) - user = db.relationship(User, backref=db.backref('emails', cascade="all, delete-orphan", single_parent=True)) - - db.create_all() - - # Customize error message - class StubTranslation(object): - def gettext(self, *args): - return 'success!' - - def ngettext(self, *args): - return 'success!' - - class StubBaseForm(form.BaseForm): - def _get_translations(self): - return StubTranslation() - - # Set up Admin - class UserModelView(ModelView): - inline_models = ((UserEmail, {"form_base_class": StubBaseForm}),) - form_args = { - "emails": {"validators": [ItemsRequired()]} - } + with app.app_context(): + # Set up models and database + class User(db.Model): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String, unique=True) + + def __init__(self, name=None): + self.name = name + + class UserEmail(db.Model): + __tablename__ = 'user_info' + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String, nullable=False, unique=True) + verified_at = db.Column(db.DateTime) + user_id = db.Column(db.Integer, db.ForeignKey(User.id)) + user = db.relationship(User, backref=db.backref('emails', cascade="all, delete-orphan", single_parent=True)) + + db.create_all() + + # Customize error message + class StubTranslation(object): + def gettext(self, *args): + return 'success!' + + def ngettext(self, *args): + return 'success!' + + class StubBaseForm(form.BaseForm): + def _get_translations(self): + return StubTranslation() + + # Set up Admin + class UserModelView(ModelView): + inline_models = ((UserEmail, {"form_base_class": StubBaseForm}),) + form_args = { + "emails": {"validators": [ItemsRequired()]} + } + + view = UserModelView(User, db.session) + admin.add_view(view) - view = UserModelView(User, db.session) - admin.add_view(view) - - # Create - data = { - 'name': 'emptyEmail', - 'emails-0-email': '', - } - rv = client.post('/admin/user/new/', data=data) - assert rv.status_code == 200 - assert User.query.count() == 0 - assert b'success!' in rv.data, rv.data + # Create + data = { + 'name': 'emptyEmail', + 'emails-0-email': '', + } + rv = client.post('/admin/user/new/', data=data) + assert rv.status_code == 200 + assert User.query.count() == 0 + assert b'success!' in rv.data, rv.data diff --git a/flask_admin/tests/sqla/test_multi_pk.py b/flask_admin/tests/sqla/test_multi_pk.py index 1261a218b..59bfa9bee 100644 --- a/flask_admin/tests/sqla/test_multi_pk.py +++ b/flask_admin/tests/sqla/test_multi_pk.py @@ -1,193 +1,187 @@ -from . import setup from .test_basic import CustomModelView from flask_sqlalchemy import Model from sqlalchemy.ext.declarative import declarative_base -def test_multiple_pk(): +def test_multiple_pk(app, db, admin): # Test multiple primary keys - mix int and string together - app, db, admin = setup() + with app.app_context(): + class Model(db.Model): + id = db.Column(db.Integer, primary_key=True) + id2 = db.Column(db.String(20), primary_key=True) + test = db.Column(db.String) - class Model(db.Model): - id = db.Column(db.Integer, primary_key=True) - id2 = db.Column(db.String(20), primary_key=True) - test = db.Column(db.String) + db.create_all() - db.create_all() + view = CustomModelView(Model, db.session, form_columns=['id', 'id2', 'test']) + admin.add_view(view) - view = CustomModelView(Model, db.session, form_columns=['id', 'id2', 'test']) - admin.add_view(view) + client = app.test_client() - client = app.test_client() + rv = client.get('/admin/model/') + assert rv.status_code == 200 - rv = client.get('/admin/model/') - assert rv.status_code == 200 + rv = client.post('/admin/model/new/', + data=dict(id=1, id2='two', test='test3')) + assert rv.status_code == 302 - rv = client.post('/admin/model/new/', - data=dict(id=1, id2='two', test='test3')) - assert rv.status_code == 302 + rv = client.get('/admin/model/') + assert rv.status_code == 200 + data = rv.data.decode('utf-8') + assert 'test3' in data - rv = client.get('/admin/model/') - assert rv.status_code == 200 - data = rv.data.decode('utf-8') - assert 'test3' in data + rv = client.get('/admin/model/edit/?id=1,two') + assert rv.status_code == 200 + data = rv.data.decode('utf-8') + assert 'test3' in data - rv = client.get('/admin/model/edit/?id=1,two') - assert rv.status_code == 200 - data = rv.data.decode('utf-8') - assert 'test3' in data + # Correct order is mandatory -> fail here + rv = client.get('/admin/model/edit/?id=two,1') + assert rv.status_code == 302 - # Correct order is mandatory -> fail here - rv = client.get('/admin/model/edit/?id=two,1') - assert rv.status_code == 302 - -def test_joined_inheritance(): +def test_joined_inheritance(app, db, admin): # Test multiple primary keys - mix int and string together - app, db, admin = setup() - - class Parent(db.Model): - id = db.Column(db.Integer, primary_key=True) - test = db.Column(db.String) + with app.app_context(): + class Parent(db.Model): + id = db.Column(db.Integer, primary_key=True) + test = db.Column(db.String) - discriminator = db.Column('type', db.String(50)) - __mapper_args__ = {'polymorphic_on': discriminator} + discriminator = db.Column('type', db.String(50)) + __mapper_args__ = {'polymorphic_on': discriminator} - class Child(Parent): - __tablename__ = 'children' - __mapper_args__ = {'polymorphic_identity': 'child'} + class Child(Parent): + __tablename__ = 'children' + __mapper_args__ = {'polymorphic_identity': 'child'} - id = db.Column(db.ForeignKey(Parent.id), primary_key=True) - name = db.Column(db.String(100)) + id = db.Column(db.ForeignKey(Parent.id), primary_key=True) + name = db.Column(db.String(100)) - db.create_all() + db.create_all() - view = CustomModelView(Child, db.session, form_columns=['id', 'test', 'name']) - admin.add_view(view) + view = CustomModelView(Child, db.session, form_columns=['id', 'test', 'name']) + admin.add_view(view) - client = app.test_client() + client = app.test_client() - rv = client.get('/admin/child/') - assert rv.status_code == 200 + rv = client.get('/admin/child/') + assert rv.status_code == 200 - rv = client.post('/admin/child/new/', - data=dict(id=1, test='foo', name='bar')) - assert rv.status_code == 302 + rv = client.post('/admin/child/new/', + data=dict(id=1, test='foo', name='bar')) + assert rv.status_code == 302 - rv = client.get('/admin/child/edit/?id=1') - assert rv.status_code == 200 - data = rv.data.decode('utf-8') - assert 'foo' in data - assert 'bar' in data + rv = client.get('/admin/child/edit/?id=1') + assert rv.status_code == 200 + data = rv.data.decode('utf-8') + assert 'foo' in data + assert 'bar' in data -def test_single_table_inheritance(): +def test_single_table_inheritance(app, db, admin): # Test multiple primary keys - mix int and string together - app, db, admin = setup() - - CustomModel = declarative_base(Model, name='Model') + with app.app_context(): + CustomModel = declarative_base(cls=Model, name='Model') - class Parent(CustomModel): - __tablename__ = 'parent' + class Parent(CustomModel): + __tablename__ = 'parent' - id = db.Column(db.Integer, primary_key=True) - test = db.Column(db.String) + id = db.Column(db.Integer, primary_key=True) + test = db.Column(db.String) - discriminator = db.Column('type', db.String(50)) - __mapper_args__ = {'polymorphic_on': discriminator} + discriminator = db.Column('type', db.String(50)) + __mapper_args__ = {'polymorphic_on': discriminator} - class Child(Parent): - __mapper_args__ = {'polymorphic_identity': 'child'} - name = db.Column(db.String(100)) + class Child(Parent): + __mapper_args__ = {'polymorphic_identity': 'child'} + name = db.Column(db.String(100)) - CustomModel.metadata.create_all(db.engine) + CustomModel.metadata.create_all(db.engine) - view = CustomModelView(Child, db.session, form_columns=['id', 'test', 'name']) - admin.add_view(view) + view = CustomModelView(Child, db.session, form_columns=['id', 'test', 'name']) + admin.add_view(view) - client = app.test_client() + client = app.test_client() - rv = client.get('/admin/child/') - assert rv.status_code == 200 + rv = client.get('/admin/child/') + assert rv.status_code == 200 - rv = client.post('/admin/child/new/', - data=dict(id=1, test='foo', name='bar')) - assert rv.status_code == 302 + rv = client.post('/admin/child/new/', + data=dict(id=1, test='foo', name='bar')) + assert rv.status_code == 302 - rv = client.get('/admin/child/edit/?id=1') - assert rv.status_code == 200 - data = rv.data.decode('utf-8') - assert 'foo' in data - assert 'bar' in data + rv = client.get('/admin/child/edit/?id=1') + assert rv.status_code == 200 + data = rv.data.decode('utf-8') + assert 'foo' in data + assert 'bar' in data -def test_concrete_table_inheritance(): +def test_concrete_table_inheritance(app, db, admin): # Test multiple primary keys - mix int and string together - app, db, admin = setup() + with app.app_context(): + class Parent(db.Model): + id = db.Column(db.Integer, primary_key=True) + test = db.Column(db.String) - class Parent(db.Model): - id = db.Column(db.Integer, primary_key=True) - test = db.Column(db.String) + class Child(Parent): + __mapper_args__ = {'concrete': True} + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100)) + test = db.Column(db.String) - class Child(Parent): - __mapper_args__ = {'concrete': True} - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(100)) - test = db.Column(db.String) + db.create_all() - db.create_all() + view = CustomModelView(Child, db.session, form_columns=['id', 'test', 'name']) + admin.add_view(view) - view = CustomModelView(Child, db.session, form_columns=['id', 'test', 'name']) - admin.add_view(view) + client = app.test_client() - client = app.test_client() + rv = client.get('/admin/child/') + assert rv.status_code == 200 - rv = client.get('/admin/child/') - assert rv.status_code == 200 + rv = client.post('/admin/child/new/', + data=dict(id=1, test='foo', name='bar')) + assert rv.status_code == 302 - rv = client.post('/admin/child/new/', - data=dict(id=1, test='foo', name='bar')) - assert rv.status_code == 302 + rv = client.get('/admin/child/edit/?id=1') + assert rv.status_code == 200 + data = rv.data.decode('utf-8') + assert 'foo' in data + assert 'bar' in data - rv = client.get('/admin/child/edit/?id=1') - assert rv.status_code == 200 - data = rv.data.decode('utf-8') - assert 'foo' in data - assert 'bar' in data - -def test_concrete_multipk_inheritance(): +def test_concrete_multipk_inheritance(app, db, admin): # Test multiple primary keys - mix int and string together - app, db, admin = setup() - - class Parent(db.Model): - id = db.Column(db.Integer, primary_key=True) - test = db.Column(db.String) + with app.app_context(): + class Parent(db.Model): + id = db.Column(db.Integer, primary_key=True) + test = db.Column(db.String) - class Child(Parent): - __mapper_args__ = {'concrete': True} - id = db.Column(db.Integer, primary_key=True) - id2 = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(100)) - test = db.Column(db.String) + class Child(Parent): + __mapper_args__ = {'concrete': True} + id = db.Column(db.Integer, primary_key=True) + id2 = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100)) + test = db.Column(db.String) - db.create_all() + db.create_all() - view = CustomModelView(Child, db.session, form_columns=['id', 'id2', 'test', 'name']) - admin.add_view(view) + view = CustomModelView(Child, db.session, form_columns=['id', 'id2', 'test', 'name']) + admin.add_view(view) - client = app.test_client() + client = app.test_client() - rv = client.get('/admin/child/') - assert rv.status_code == 200 + rv = client.get('/admin/child/') + assert rv.status_code == 200 - rv = client.post('/admin/child/new/', - data=dict(id=1, id2=2, test='foo', name='bar')) - assert rv.status_code == 302 + rv = client.post('/admin/child/new/', + data=dict(id=1, id2=2, test='foo', name='bar')) + assert rv.status_code == 302 - rv = client.get('/admin/child/edit/?id=1,2') - assert rv.status_code == 200 - data = rv.data.decode('utf-8') - assert 'foo' in data - assert 'bar' in data + rv = client.get('/admin/child/edit/?id=1,2') + assert rv.status_code == 200 + data = rv.data.decode('utf-8') + assert 'foo' in data + assert 'bar' in data diff --git a/flask_admin/tests/sqla/test_postgres.py b/flask_admin/tests/sqla/test_postgres.py index 0e97cd38f..c374b6c43 100644 --- a/flask_admin/tests/sqla/test_postgres.py +++ b/flask_admin/tests/sqla/test_postgres.py @@ -1,115 +1,111 @@ -from . import setup_postgres from .test_basic import CustomModelView from sqlalchemy.dialects.postgresql import HSTORE, JSON from citext import CIText -def test_hstore(): - app, db, admin = setup_postgres() +def test_hstore(app, postgres_db, postgres_admin): + with app.app_context(): + class Model(postgres_db.Model): + id = postgres_db.Column(postgres_db.Integer, primary_key=True, autoincrement=True) + hstore_test = postgres_db.Column(HSTORE) - class Model(db.Model): - id = db.Column(db.Integer, primary_key=True, autoincrement=True) - hstore_test = db.Column(HSTORE) + postgres_db.create_all() - db.create_all() + view = CustomModelView(Model, postgres_db.session) + postgres_admin.add_view(view) - view = CustomModelView(Model, db.session) - admin.add_view(view) + client = app.test_client() - client = app.test_client() + rv = client.get('/admin/model/') + assert rv.status_code == 200 - rv = client.get('/admin/model/') - assert rv.status_code == 200 + rv = client.post('/admin/model/new/', data={ + 'hstore_test-0-key': 'test_val1', + 'hstore_test-0-value': 'test_val2' + }) + assert rv.status_code == 302 - rv = client.post('/admin/model/new/', data={ - 'hstore_test-0-key': 'test_val1', - 'hstore_test-0-value': 'test_val2' - }) - assert rv.status_code == 302 + rv = client.get('/admin/model/') + assert rv.status_code == 200 + data = rv.data.decode('utf-8') + assert 'test_val1' in data + assert 'test_val2' in data - rv = client.get('/admin/model/') - assert rv.status_code == 200 - data = rv.data.decode('utf-8') - assert 'test_val1' in data - assert 'test_val2' in data + rv = client.get('/admin/model/edit/?id=1') + assert rv.status_code == 200 + data = rv.data.decode('utf-8') + assert 'test_val1' in data + assert 'test_val2' in data - rv = client.get('/admin/model/edit/?id=1') - assert rv.status_code == 200 - data = rv.data.decode('utf-8') - assert 'test_val1' in data - assert 'test_val2' in data +def test_json(app, postgres_db, postgres_admin): + with app.app_context(): + class JSONModel(postgres_db.Model): + id = postgres_db.Column(postgres_db.Integer, primary_key=True, autoincrement=True) + json_test = postgres_db.Column(JSON) -def test_json(): - app, db, admin = setup_postgres() + postgres_db.create_all() - class JSONModel(db.Model): - id = db.Column(db.Integer, primary_key=True, autoincrement=True) - json_test = db.Column(JSON) + view = CustomModelView(JSONModel, postgres_db.session) + postgres_admin.add_view(view) - db.create_all() + client = app.test_client() - view = CustomModelView(JSONModel, db.session) - admin.add_view(view) + rv = client.get('/admin/jsonmodel/') + assert rv.status_code == 200 - client = app.test_client() + rv = client.post('/admin/jsonmodel/new/', data={ + 'json_test': '{"test_key1": "test_value1"}', + }) + assert rv.status_code == 302 - rv = client.get('/admin/jsonmodel/') - assert rv.status_code == 200 + rv = client.get('/admin/jsonmodel/') + assert rv.status_code == 200 + data = rv.data.decode('utf-8') + assert 'json_test' in data + assert '{"test_key1": "test_value1"}' in data - rv = client.post('/admin/jsonmodel/new/', data={ - 'json_test': '{"test_key1": "test_value1"}', - }) - assert rv.status_code == 302 + rv = client.get('/admin/jsonmodel/edit/?id=1') + assert rv.status_code == 200 + data = rv.data.decode('utf-8') + assert 'json_test' in data + assert ('>{"test_key1": "test_value1"}<' in data or + '{"test_key1": "test_value1"}<' in data) - rv = client.get('/admin/jsonmodel/') - assert rv.status_code == 200 - data = rv.data.decode('utf-8') - assert 'json_test' in data - assert '{"test_key1": "test_value1"}' in data - rv = client.get('/admin/jsonmodel/edit/?id=1') - assert rv.status_code == 200 - data = rv.data.decode('utf-8') - assert 'json_test' in data - assert ('>{"test_key1": "test_value1"}<' in data or - '{"test_key1": "test_value1"}<' in data) +def test_citext(app, postgres_db, postgres_admin): + with app.app_context(): + class CITextModel(postgres_db.Model): + id = postgres_db.Column(postgres_db.Integer, primary_key=True, autoincrement=True) + citext_test = postgres_db.Column(CIText) + postgres_db.engine.execute('CREATE EXTENSION IF NOT EXISTS citext') + postgres_db.create_all() -def test_citext(): - app, db, admin = setup_postgres() + view = CustomModelView(CITextModel, postgres_db.session) + postgres_admin.add_view(view) - class CITextModel(db.Model): - id = db.Column(db.Integer, primary_key=True, autoincrement=True) - citext_test = db.Column(CIText) + client = app.test_client() - db.engine.execute('CREATE EXTENSION IF NOT EXISTS citext') - db.create_all() + rv = client.get('/admin/citextmodel/') + assert rv.status_code == 200 - view = CustomModelView(CITextModel, db.session) - admin.add_view(view) + rv = client.post('/admin/citextmodel/new/', data={ + 'citext_test': 'Foo', + }) + assert rv.status_code == 302 - client = app.test_client() + rv = client.get('/admin/citextmodel/') + assert rv.status_code == 200 + data = rv.data.decode('utf-8') + assert 'citext_test' in data + assert 'Foo' in data - rv = client.get('/admin/citextmodel/') - assert rv.status_code == 200 - - rv = client.post('/admin/citextmodel/new/', data={ - 'citext_test': 'Foo', - }) - assert rv.status_code == 302 - - rv = client.get('/admin/citextmodel/') - assert rv.status_code == 200 - data = rv.data.decode('utf-8') - assert 'citext_test' in data - assert 'Foo' in data - - rv = client.get('/admin/citextmodel/edit/?id=1') - assert rv.status_code == 200 - data = rv.data.decode('utf-8') - assert 'name="citext_test"' in data - assert ('>Foo\nFoo\r\nFooFoo\nFoo\r\nFoo`_ to do " -"the heavy lifting. It's a fork of the `Flask-Babel " -"`_ package::" +"Install `Flask-Babel `_ to do " +"the heavy lifting." msgstr "" -"安装 `Flask-BabelEx `_ 扩展,它是 " -"`Flask-Babel `_ 的分支::" +"安装 `Flask-Babel `_ 扩展" #: ../../doc/advanced.rst:35 -msgid "Initialize Flask-BabelEx by creating instance of `Babel` class::" -msgstr "通过创建 `Babel` 类实例,初始化 Flask-BabelEx 扩展::" +msgid "Initialize Flask-Babel by creating instance of `Babel` class::" +msgstr "通过创建 `Babel` 类实例,初始化 Flask-Babel 扩展::" #: ../../doc/advanced.rst:43 msgid "Create a locale selector function::" @@ -91,12 +89,12 @@ msgstr "" #: ../../doc/advanced.rst:57 msgid "" -"If the built-in translations are not enough, look at the `Flask-BabelEx " -"documentation `_ to see how you " +"If the built-in translations are not enough, look at the `Flask-Babel " +"documentation `_ to see how you " "can add your own." msgstr "" -"如果内置的翻译不够,请查看 `Flask-BabelEx 文档 `_ 了解如何添加翻译。" +"如果内置的翻译不够,请查看 `Flask-Babel 文档 `_ 了解如何添加翻译。" #: ../../doc/advanced.rst:63 msgid "Managing Files & Folders" @@ -996,4 +994,3 @@ msgstr "" #~ "为了使其工作,还需要创建一个模板,通过在 `create` 和 `edit` 页面包含必要的" #~ " CKEditor javascript 来扩展默认功能。将其保存在 " #~ "`templates/ckeditor.html` 中::" - diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..ac2e3bd8d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,160 @@ +[project] +name = "Flask-Admin" +version = "1.6.1" +description = "Simple and extensible admin interface framework for Flask" +readme = "README.md" +license = { file = "LICENSE.txt" } +author = [{ name = "Flask-Admin team" }] +maintainers = [{ name = "Pallets Ecosystem", email = "contact@palletsprojects.com" }] +classifiers = [ + 'Development Status :: 4 - Beta', + 'Environment :: Web Environment', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', +] +requires-python = ">=3.6" +dependencies = [ + "flask>=0.7", + "wtforms" +] + +[project.urls] +Documentation = "https://flask-admin.readthedocs.io" +Changes = "https://github.com/pallets-eco/flask-admin/releases/" +Source = "https://github.com/pallets-eco/flask-admin/" +Chat = "https://discord.gg/pallets" + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "flask_admin" + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "flask_babel: requires Flask-Babel to be installed" +] +filterwarnings = [ + "error", + # TODO: remove the ignored deprecation warning when support for WTForms 3 has been added. + "ignore:Flags should be stored in dicts and not in tuples. The next version of WTForms will abandon support for flags in tuples.:DeprecationWarning", + "ignore:'HTMLString' will be removed in WTForms 3.0. Use 'markupsafe.Markup' instead.:DeprecationWarning", + + # flask-mongoengine is responsible for the below deprecation warning, and hasn't been updated recently + "default:No uuidRepresentation is specified:DeprecationWarning", + + # Werkzeug is responsible for the below deprecation warning; remove when they have updated their code. + "default:ast\\.Str is deprecated and will be removed in Python 3\\.14:DeprecationWarning", + "default:Attribute s is deprecated and will be removed in Python 3\\.14:DeprecationWarning", + + # Flask is responsible for the below deprecation warning; remove when they have updated their code. + "default:'pkgutil\\.get_loader' is deprecated and slated for removal in Python 3\\.14:DeprecationWarning", + "default:'pkgutil\\.find_loader' is deprecated and slated for removal in Python 3\\.14:DeprecationWarning", + + "default:datetime\\.datetime\\.utcnow\\(\\) is deprecated and scheduled for removal in a future version:DeprecationWarning" +] + +[tool.coverage.run] +branch = true +source = ["flask_admin", "tests"] + +[tool.coverage.paths] +source = ["flask_admin", "*/site-packages"] + +[tool.mypy] +python_version = "3.8" +files = ["flask_admin"] +show_error_codes = true +pretty = true +strict = true + +# Start off with these +warn_unused_configs = false +warn_redundant_casts = false +warn_unused_ignores = false + +# Getting these passing should be easy +strict_equality = false +strict_concatenate = false + +# Strongly recommend enabling this one as soon as you can +check_untyped_defs = false + +# These shouldn't be too much additional work, but may be tricky to +# get passing if you use a lot of untyped libraries +disallow_subclassing_any = false +disallow_untyped_decorators = false +disallow_any_generics = false + +# These next few are various gradations of forcing use of type annotations +disallow_untyped_calls = false +disallow_incomplete_defs = false +disallow_untyped_defs = false + +# This one isn't too hard to get passing, but return on investment is lower +no_implicit_reexport = false + +# This one can be tricky to get passing if you use a lot of untyped libraries +warn_return_any = false + +[[tool.mypy.overrides]] +module = [ + "arrow", + "azure.*", + "bson.*", + "citext", + "colour", + "flask_babel", + "flask_mongoengine.*", + "flask_wtf", + "google.appengine.ext", + "gridfs", + "marker", + "mongoengine.*", + "playhouse.*", + "pymongo", + "sqlalchemy.*", + "sqlalchemy_enum34", + "sqlalchemy_utils", + "tablib", + "wtforms.*", + "wtforms_appengine.*", + "wtfpeewee.*", +] +ignore_missing_imports = true + +[tool.pyright] +pythonVersion = "3.8" +include = ["flask_admin", "tests"] +typeCheckingMode = "basic" + +[tool.ruff] +src = ["flask_admin"] +fix = true +show-fixes = true +output-format = "full" + +[tool.ruff.lint] +select = [ + "B", # flake8-bugbear + "E", # pycodestyle error + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "W", # pycodestyle warning +] +ignore-init-module-imports = true + +[tool.ruff.lint.isort] +force-single-line = true +order-by-type = false diff --git a/requirements/build.in b/requirements/build.in new file mode 100644 index 000000000..378eac25d --- /dev/null +++ b/requirements/build.in @@ -0,0 +1 @@ +build diff --git a/requirements/build.txt b/requirements/build.txt new file mode 100644 index 000000000..d3db1d5f6 --- /dev/null +++ b/requirements/build.txt @@ -0,0 +1,18 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile build.in +# +build==1.1.1 + # via -r build.in +importlib-metadata==6.7.0 + # via build +packaging==24.0 + # via build +pyproject-hooks==1.1.0 + # via build +tomli==2.0.1 + # via build +zipp==3.15.0 + # via importlib-metadata diff --git a/requirements/dev.in b/requirements/dev.in new file mode 100644 index 000000000..6d176eff4 --- /dev/null +++ b/requirements/dev.in @@ -0,0 +1,5 @@ +-r docs.txt +-r tests.in +-r typing.txt +pre-commit +tox diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 000000000..696b821d6 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,464 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile dev.in +# +alabaster==0.7.13 + # via + # -r docs.txt + # sphinx +arrow==0.13.2 + # via + # -r tests.in + # -r typing.txt +astroid==2.15.8 + # via + # -r typing.txt + # pylint +azure-common==1.1.28 + # via + # -r typing.txt + # azure-storage-blob + # azure-storage-common +azure-storage-blob==2.1.0 + # via + # -r tests.in + # -r typing.txt +azure-storage-common==2.1.0 + # via + # -r typing.txt + # azure-storage-blob +babel==2.9.1 + # via + # -r docs.txt + # -r tests.in + # -r typing.txt + # flask-babel + # sphinx +cachetools==5.3.3 + # via tox +certifi==2024.7.4 + # via + # -r docs.txt + # -r typing.txt + # requests +cffi==1.15.1 + # via + # -r typing.txt + # cryptography +cfgv==3.3.1 + # via pre-commit +chardet==5.2.0 + # via tox +charset-normalizer==3.3.2 + # via + # -r docs.txt + # -r typing.txt + # requests +click==8.1.7 + # via + # -r typing.txt + # flask +colorama==0.4.6 + # via tox +colour==0.1.5 + # via + # -r tests.in + # -r typing.txt +coverage[toml]==6.5.0 + # via + # -r typing.txt + # coveralls + # pytest-cov +coveralls==3.3.1 + # via + # -r tests.in + # -r typing.txt +cryptography==42.0.8 + # via + # -r typing.txt + # azure-storage-common +dill==0.3.7 + # via + # -r typing.txt + # pylint +distlib==0.3.8 + # via virtualenv +dnspython==2.3.0 + # via + # -r typing.txt + # email-validator +docopt==0.6.2 + # via + # -r typing.txt + # coveralls +docutils==0.19 + # via + # -r docs.txt + # sphinx +email-validator==2.0.0.post2 + # via + # -r tests.in + # -r typing.txt +exceptiongroup==1.2.1 + # via + # -r typing.txt + # pytest +filelock==3.12.2 + # via + # tox + # virtualenv +flake8==3.9.2 + # via + # -r tests.in + # -r typing.txt +flask==2.1.3 + # via + # -r typing.txt + # flask-babel + # flask-mongoengine + # flask-sqlalchemy + # flask-wtf +flask-babel==2.0.0 + # via + # -r tests.in + # -r typing.txt +flask-mongoengine==0.8.2 + # via + # -r tests.in + # -r typing.txt +flask-sqlalchemy==2.5.1 + # via + # -r tests.in + # -r typing.txt +flask-wtf==1.1.1 + # via + # -r typing.txt + # flask-mongoengine +geoalchemy2==0.15.2 + # via + # -r tests.in + # -r typing.txt +identify==2.5.24 + # via pre-commit +idna==3.7 + # via + # -r docs.txt + # -r typing.txt + # email-validator + # requests +imagesize==1.4.1 + # via + # -r docs.txt + # sphinx +importlib-metadata==6.7.0 + # via + # -r docs.txt + # -r typing.txt + # flask + # sphinx +iniconfig==2.0.0 + # via + # -r typing.txt + # pytest +isort==5.11.5 + # via + # -r typing.txt + # pylint +itsdangerous==2.0.1 + # via + # -r tests.in + # -r typing.txt + # flask + # flask-wtf +jinja2==3.0.0 + # via + # -r docs.txt + # -r tests.in + # -r typing.txt + # flask + # flask-babel + # sphinx +lazy-object-proxy==1.9.0 + # via + # -r typing.txt + # astroid +markupsafe==2.0.1 + # via + # -r docs.txt + # -r tests.in + # -r typing.txt + # jinja2 + # types-wtforms + # wtforms +mccabe==0.6.1 + # via + # -r typing.txt + # flake8 + # pylint +mongoengine==0.21.0 + # via + # -r tests.in + # -r typing.txt + # flask-mongoengine +mypy==1.4.1 + # via -r typing.txt +mypy-extensions==1.0.0 + # via + # -r typing.txt + # mypy +nodeenv==1.9.1 + # via + # -r typing.txt + # pre-commit + # pyright +numpy==1.24.4 + # via + # -r typing.txt + # shapely + # types-shapely +packaging==24.0 + # via + # -r docs.txt + # -r typing.txt + # geoalchemy2 + # pallets-sphinx-themes + # pyproject-api + # pytest + # sphinx + # tox +pallets-sphinx-themes==2.0.3 + # via -r docs.txt +peewee==3.17.6 + # via + # -r tests.in + # -r typing.txt + # wtf-peewee +pillow==9.5.0 + # via + # -r tests.in + # -r typing.txt +platformdirs==4.0.0 + # via + # -r typing.txt + # pylint + # tox + # virtualenv +pluggy==1.2.0 + # via + # -r typing.txt + # pytest + # tox +pre-commit==2.21.0 + # via -r dev.in +psycopg2==2.9.9 + # via + # -r tests.in + # -r typing.txt +pycodestyle==2.7.0 + # via + # -r typing.txt + # flake8 +pycparser==2.21 + # via + # -r typing.txt + # cffi +pyflakes==2.3.1 + # via + # -r typing.txt + # flake8 +pygments==2.17.2 + # via + # -r docs.txt + # sphinx +pylint==2.17.7 + # via + # -r tests.in + # -r typing.txt +pymongo==3.13.0 + # via + # -r tests.in + # -r typing.txt + # mongoengine +pyproject-api==1.5.3 + # via tox +pyright==1.1.370 + # via -r typing.txt +pytest==7.4.4 + # via + # -r tests.in + # -r typing.txt + # pytest-cov +pytest-cov==4.1.0 + # via + # -r tests.in + # -r typing.txt +python-dateutil==2.9.0.post0 + # via + # -r typing.txt + # arrow + # azure-storage-common +pytz==2024.1 + # via + # -r docs.txt + # -r typing.txt + # babel + # flask-babel +pyyaml==6.0.1 + # via pre-commit +requests==2.31.0 + # via + # -r docs.txt + # -r typing.txt + # azure-storage-common + # coveralls + # sphinx +shapely==2.0.5 + # via + # -r tests.in + # -r typing.txt +six==1.16.0 + # via + # -r typing.txt + # flask-mongoengine + # python-dateutil +snowballstemmer==2.2.0 + # via + # -r docs.txt + # sphinx +sphinx==5.3.0 + # via + # -r docs.txt + # pallets-sphinx-themes + # sphinxcontrib-log-cabinet +sphinxcontrib-applehelp==1.0.2 + # via + # -r docs.txt + # sphinx +sphinxcontrib-devhelp==1.0.2 + # via + # -r docs.txt + # sphinx +sphinxcontrib-htmlhelp==2.0.0 + # via + # -r docs.txt + # sphinx +sphinxcontrib-jsmath==1.0.1 + # via + # -r docs.txt + # sphinx +sphinxcontrib-log-cabinet==1.0.1 + # via -r docs.txt +sphinxcontrib-qthelp==1.0.3 + # via + # -r docs.txt + # sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via + # -r docs.txt + # sphinx +sqlalchemy==1.4.52 + # via + # -r tests.in + # -r typing.txt + # flask-sqlalchemy + # geoalchemy2 + # sqlalchemy-citext + # sqlalchemy-utils +sqlalchemy-citext==1.8.0 + # via + # -r tests.in + # -r typing.txt +sqlalchemy-utils==0.41.2 + # via + # -r tests.in + # -r typing.txt +tomli==2.0.1 + # via + # -r typing.txt + # coverage + # mypy + # pylint + # pyproject-api + # pytest + # tox +tomlkit==0.12.5 + # via + # -r typing.txt + # pylint +tox==4.8.0 + # via -r dev.in +types-boto==2.49.18.9 + # via -r typing.txt +types-click==7.1.8 + # via + # -r typing.txt + # types-flask +types-flask==1.1.6 + # via -r typing.txt +types-flask-sqlalchemy==2.5.9.4 + # via -r typing.txt +types-jinja2==2.11.9 + # via + # -r typing.txt + # types-flask +types-markupsafe==1.1.10 + # via + # -r typing.txt + # types-jinja2 +types-peewee==3.17.0.0 + # via -r typing.txt +types-pillow==10.1.0.2 + # via -r typing.txt +types-shapely==2.0.0.20240714 + # via -r typing.txt +types-sqlalchemy==1.4.53.38 + # via + # -r typing.txt + # types-flask-sqlalchemy +types-werkzeug==1.0.9 + # via + # -r typing.txt + # types-flask +types-wtforms==3.1.0.2 + # via -r typing.txt +typing-extensions==4.7.1 + # via + # -r typing.txt + # astroid + # mypy + # pylint +urllib3==2.0.7 + # via + # -r docs.txt + # -r typing.txt + # requests +virtualenv==20.26.3 + # via + # pre-commit + # tox +werkzeug==2.1.2 + # via + # -r tests.in + # -r typing.txt + # flask +wrapt==1.16.0 + # via + # -r typing.txt + # astroid +wtf-peewee==3.0.5 + # via + # -r tests.in + # -r typing.txt +wtforms==3.0.1 + # via + # -r tests.in + # -r typing.txt + # flask-wtf + # wtf-peewee +zipp==3.15.0 + # via + # -r docs.txt + # -r typing.txt + # importlib-metadata diff --git a/requirements/docs.in b/requirements/docs.in new file mode 100644 index 000000000..8fa5a39c6 --- /dev/null +++ b/requirements/docs.in @@ -0,0 +1,5 @@ +-c tests.in + +pallets-sphinx-themes +sphinx +sphinxcontrib-log-cabinet diff --git a/requirements/docs.txt b/requirements/docs.txt new file mode 100644 index 000000000..32366b0e5 --- /dev/null +++ b/requirements/docs.txt @@ -0,0 +1,69 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile docs.in +# +alabaster==0.7.13 + # via sphinx +babel==2.9.1 + # via + # -c tests.in + # sphinx +certifi==2024.7.4 + # via requests +charset-normalizer==3.3.2 + # via requests +docutils==0.19 + # via sphinx +idna==3.7 + # via requests +imagesize==1.4.1 + # via sphinx +importlib-metadata==6.7.0 + # via sphinx +jinja2==3.0.0 + # via + # -c tests.in + # sphinx +markupsafe==2.0.1 + # via + # -c tests.in + # jinja2 +packaging==24.0 + # via + # pallets-sphinx-themes + # sphinx +pallets-sphinx-themes==2.0.3 + # via -r docs.in +pygments==2.17.2 + # via sphinx +pytz==2024.1 + # via babel +requests==2.31.0 + # via sphinx +snowballstemmer==2.2.0 + # via sphinx +sphinx==5.3.0 + # via + # -r docs.in + # pallets-sphinx-themes + # sphinxcontrib-log-cabinet +sphinxcontrib-applehelp==1.0.2 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==2.0.0 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-log-cabinet==1.0.1 + # via -r docs.in +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via sphinx +urllib3==2.0.7 + # via requests +zipp==3.15.0 + # via importlib-metadata diff --git a/requirements-dev.txt b/requirements/tests.in similarity index 54% rename from requirements-dev.txt rename to requirements/tests.in index 5b398673d..f6df5de83 100644 --- a/requirements-dev.txt +++ b/requirements/tests.in @@ -1,15 +1,19 @@ flake8 -Flask>=0.7 -Flask-SQLAlchemy>=0.15 +werkzeug +sqlalchemy<2.0 +itsdangerous<2.1.0 +MarkupSafe<2.1.0 +jinja2<=3.0.0 +Flask-SQLAlchemy<3.0.0 peewee wtf-peewee -mongoengine<=0.21.0 -pymongo +mongoengine +pymongo>=3.7.0 flask-mongoengine==0.8.2 pillow>=3.3.2 Babel<=2.9.1 -flask-babelex -shapely==1.5.9 +flask-babel +shapely>=2 geoalchemy2 psycopg2 pytest @@ -18,8 +22,8 @@ coveralls pylint sqlalchemy-citext sqlalchemy-utils>=0.36.6 -azure-storage-blob +azure-storage-blob<=3 arrow<0.14.0 colour email-validator -wtforms==2.3.3 +wtforms diff --git a/requirements/typing.in b/requirements/typing.in new file mode 100644 index 000000000..982982adf --- /dev/null +++ b/requirements/typing.in @@ -0,0 +1,12 @@ +-r tests.in + +mypy +pyright +pytest +types-Flask-SQLAlchemy +types-Pillow +types-boto +types-peewee +types-Flask +types-WTForms +types-shapely diff --git a/requirements/typing.txt b/requirements/typing.txt new file mode 100644 index 000000000..7d6448173 --- /dev/null +++ b/requirements/typing.txt @@ -0,0 +1,234 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile typing.in +# +arrow==0.13.2 + # via -r tests.in +astroid==2.15.8 + # via pylint +azure-common==1.1.28 + # via + # azure-storage-blob + # azure-storage-common +azure-storage-blob==2.1.0 + # via -r tests.in +azure-storage-common==2.1.0 + # via azure-storage-blob +babel==2.9.1 + # via + # -r tests.in + # flask-babel +certifi==2024.7.4 + # via requests +cffi==1.15.1 + # via cryptography +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via flask +colour==0.1.5 + # via -r tests.in +coverage[toml]==6.5.0 + # via + # coveralls + # pytest-cov +coveralls==3.3.1 + # via -r tests.in +cryptography==42.0.8 + # via azure-storage-common +dill==0.3.7 + # via pylint +dnspython==2.3.0 + # via email-validator +docopt==0.6.2 + # via coveralls +email-validator==2.0.0.post2 + # via -r tests.in +exceptiongroup==1.2.1 + # via pytest +flake8==3.9.2 + # via -r tests.in +flask==2.1.3 + # via + # flask-babel + # flask-mongoengine + # flask-sqlalchemy + # flask-wtf +flask-babel==2.0.0 + # via -r tests.in +flask-mongoengine==0.8.2 + # via -r tests.in +flask-sqlalchemy==2.5.1 + # via -r tests.in +flask-wtf==1.1.1 + # via flask-mongoengine +geoalchemy2==0.15.2 + # via -r tests.in +idna==3.7 + # via + # email-validator + # requests +importlib-metadata==6.7.0 + # via flask +iniconfig==2.0.0 + # via pytest +isort==5.11.5 + # via pylint +itsdangerous==2.0.1 + # via + # -r tests.in + # flask + # flask-wtf +jinja2==3.0.0 + # via + # -r tests.in + # flask + # flask-babel +lazy-object-proxy==1.9.0 + # via astroid +markupsafe==2.0.1 + # via + # -r tests.in + # jinja2 + # types-wtforms + # wtforms +mccabe==0.6.1 + # via + # flake8 + # pylint +mongoengine==0.21.0 + # via + # -r tests.in + # flask-mongoengine +mypy==1.4.1 + # via -r typing.in +mypy-extensions==1.0.0 + # via mypy +nodeenv==1.9.1 + # via pyright +numpy==1.24.4 + # via + # shapely + # types-shapely +packaging==24.0 + # via + # geoalchemy2 + # pytest +peewee==3.17.6 + # via + # -r tests.in + # wtf-peewee +pillow==9.5.0 + # via -r tests.in +platformdirs==4.0.0 + # via pylint +pluggy==1.2.0 + # via pytest +psycopg2==2.9.9 + # via -r tests.in +pycodestyle==2.7.0 + # via flake8 +pycparser==2.21 + # via cffi +pyflakes==2.3.1 + # via flake8 +pylint==2.17.7 + # via -r tests.in +pymongo==3.13.0 + # via + # -r tests.in + # mongoengine +pyright==1.1.370 + # via -r typing.in +pytest==7.4.4 + # via + # -r tests.in + # -r typing.in + # pytest-cov +pytest-cov==4.1.0 + # via -r tests.in +python-dateutil==2.9.0.post0 + # via + # arrow + # azure-storage-common +pytz==2024.1 + # via + # babel + # flask-babel +requests==2.31.0 + # via + # azure-storage-common + # coveralls +shapely==2.0.5 + # via -r tests.in +six==1.16.0 + # via + # flask-mongoengine + # python-dateutil +sqlalchemy==1.4.52 + # via + # -r tests.in + # flask-sqlalchemy + # geoalchemy2 + # sqlalchemy-citext + # sqlalchemy-utils +sqlalchemy-citext==1.8.0 + # via -r tests.in +sqlalchemy-utils==0.41.2 + # via -r tests.in +tomli==2.0.1 + # via + # coverage + # mypy + # pylint + # pytest +tomlkit==0.12.5 + # via pylint +types-boto==2.49.18.9 + # via -r typing.in +types-click==7.1.8 + # via types-flask +types-flask==1.1.6 + # via -r typing.in +types-flask-sqlalchemy==2.5.9.4 + # via -r typing.in +types-jinja2==2.11.9 + # via types-flask +types-markupsafe==1.1.10 + # via types-jinja2 +types-peewee==3.17.0.0 + # via -r typing.in +types-pillow==10.1.0.2 + # via -r typing.in +types-shapely==2.0.0.20240714 + # via -r typing.in +types-sqlalchemy==1.4.53.38 + # via types-flask-sqlalchemy +types-werkzeug==1.0.9 + # via types-flask +types-wtforms==3.1.0.2 + # via -r typing.in +typing-extensions==4.7.1 + # via + # astroid + # mypy + # pylint +urllib3==2.0.7 + # via requests +werkzeug==2.1.2 + # via + # -r tests.in + # flask +wrapt==1.16.0 + # via astroid +wtf-peewee==3.0.5 + # via -r tests.in +wtforms==3.0.1 + # via + # -r tests.in + # flask-wtf + # wtf-peewee +zipp==3.15.0 + # via importlib-metadata diff --git a/setup.py b/setup.py deleted file mode 100644 index 5f4d40f96..000000000 --- a/setup.py +++ /dev/null @@ -1,91 +0,0 @@ -# Fix for older setuptools -import re -import os -import sys - -from setuptools import setup, find_packages - - -def fpath(name): - return os.path.join(os.path.dirname(__file__), name) - - -def read(fname): - return open(fpath(fname)).read() - - -def desc(): - info = read('README.rst') - try: - return info + '\n\n' + read('doc/changelog.rst') - except IOError: - return info - -# grep flask_admin/__init__.py since python 3.x cannot import it before using 2to3 -file_text = read(fpath('flask_admin/__init__.py')) - - -def grep(attrname): - pattern = r"{0}\W*=\W*'([^']+)'".format(attrname) - strval, = re.findall(pattern, file_text) - return strval - - -extras_require = { - 'aws': ['boto'], - 'azure': ['azure-storage-blob'] -} - - -install_requires = [ - 'Flask>=0.7', - 'wtforms' -] - - -setup( - name='Flask-Admin', - version=grep('__version__'), - url='https://github.com/flask-admin/flask-admin/', - license='BSD', - python_requires='>=3.6', - author=grep('__author__'), - author_email=grep('__email__'), - description='Simple and extensible admin interface framework for Flask', - long_description=desc(), - packages=find_packages(), - include_package_data=True, - zip_safe=False, - platforms='any', - extras_require=extras_require, - install_requires=install_requires, - tests_require=[ - 'pytest', - 'pillow>=3.3.2', - 'mongoengine', - 'pymongo', - 'wtf-peewee', - 'sqlalchemy', - 'flask-mongoengine<=0.21.0', - 'flask-sqlalchemy', - 'flask-babelex', - 'shapely', - 'geoalchemy2', - 'psycopg2', - ], - classifiers=[ - 'Development Status :: 4 - Beta', - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - ], - test_suite='flask_admin.tests' -) diff --git a/tox.ini b/tox.ini index d9bb926b4..8ea0728d5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,33 +1,68 @@ [tox] envlist = - WTForms{1,2} - py38-WTForms2 - flake8 - docs-html -skipsdist = true + py3{8,9,10,11,12}-flask{2}-wtforms{2} + py312-flask2-wtforms2-no-flask-babel # only tested against latest of all configurations, sans flask-babel +# style + typing + docs skip_missing_interpreters = true -[flake8] -max_line_length = 120 -ignore = E402,E722,W504 - [testenv] +package = wheel +wheel_build_env = .pkg +constrain_package_deps = true +use_frozen_constraints = true +passenv = DYLD_LIBRARY_PATH +# TODO: Remove SQLALCHEMY_SILENCE_UBER_WARNINGwhen Flask-Admin is compatible with SQLAlchemy>=2.0.0 setenv = + SQLALCHEMY_SILENCE_UBER_WARNING = 1 AZURE_STORAGE_CONNECTION_STRING = DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1; -usedevelop = true deps = - WTForms1: WTForms==1.0.5 - WTForms2: WTForms>=2.0 - -Ur{toxinidir}/requirements-dev.txt + -r requirements/tests.in + flask2: Flask>=2.0.0,<3 + wtforms1: WTForms==1.0.5 + wtforms2: WTForms>=2.0,<3 +commands = pytest -v --tb=short --basetemp={envtmpdir} flask_admin/tests {posargs} + +[testenv:py312-flask2-wtforms2-no-flask-babel] commands = - pytest -v flask_admin/tests --cov=flask_admin --cov-report=html + pip uninstall -y flask-babel + pytest -v --tb=short --basetemp={envtmpdir} flask_admin/tests {posargs} -[testenv:flake8] -deps = flake8 -commands = flake8 flask_admin +[testenv:style] +deps = pre-commit +skip_install = true +commands = pre-commit run --all-files -[testenv:docs-html] -deps = - sphinx - sphinx-intl +[testenv:typing] +deps = -r requirements/typing.txt +commands = + mypy --python-version 3.8 + mypy --python-version 3.12 + +[testenv:docs] +deps = -r requirements/docs.txt +# commands = sphinx-build -E -W -b dirhtml doc doc/_build/dirhtml +# TODO: Switch to the above command when docs have been migrated to use the Pallets theme. commands = sphinx-build -b html -d build/doctrees doc build/html + +[testenv:update-pre_commit] +labels = update +deps = pre-commit +skip_install = true +commands = pre-commit autoupdate -j4 + +[testenv:update-requirements] +labels = update +deps = pip-tools +skip_install = true +change_dir = requirements +commands = + pip-compile build.in -q {posargs:-U} + pip-compile docs.in -q {posargs:-U} +; pip-compile tests.in -q {posargs:-U} +; # TODO: remove? There are a lot of test +; dependencies; unsure if we can compile these in a way that would work for all +; python versions supported? + pip-compile typing.in -q {posargs:-U} + pip-compile dev.in -q {posargs:-U}