diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..5e39fc2b62 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,8 @@ +// For format details, see https://aka.ms/devcontainer.json +{ + "name": "flask-admin (Python 3.12)", + "image": "mcr.microsoft.com/devcontainers/python:3.12-bullseye", + + // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode" +} diff --git a/.devcontainer/tests/Dockerfile b/.devcontainer/tests/Dockerfile new file mode 100644 index 0000000000..b1a5b5cfc1 --- /dev/null +++ b/.devcontainer/tests/Dockerfile @@ -0,0 +1,19 @@ +ARG IMAGE=python:3.10-slim +FROM ${IMAGE} + +ENV UV_PROJECT_ENVIRONMENT=/venv +ENV UV_PYTHON=python3.10 +ENV UV_CACHE_DIR=/root/.cache/uv + +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends postgresql-client \ + && apt-get clean -y && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /workspace +COPY pyproject.toml uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --extra all --no-install-project + +COPY . . diff --git a/.devcontainer/tests/devcontainer.json b/.devcontainer/tests/devcontainer.json new file mode 100644 index 0000000000..f65e7f1370 --- /dev/null +++ b/.devcontainer/tests/devcontainer.json @@ -0,0 +1,21 @@ +// For format details, see https://aka.ms/devcontainer.json. +{ + "name": "flask-admin tests (Postgres + Azurite + Mongo)", + "dockerComposeFile": "docker-compose.yaml", + "service": "app", + "workspaceFolder": "/workspace", + "forwardPorts": [10000, 10001, 5432, 27017], + "portsAttributes": { + "10000": {"label": "Azurite Blob Storage Emulator", "onAutoForward": "silent"}, + "10001": {"label": "Azurite Blob Storage Emulator HTTPS", "onAutoForward": "silent"}, + "5432": {"label": "PostgreSQL port", "onAutoForward": "silent"}, + "27017": {"label": "MongoDB port", "onAutoForward": "silent"}, + }, + "features": { + // For authenticating to a production Azure account + "ghcr.io/devcontainers/features/azure-cli:1": {} + }, + // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "root", + "postAttachCommand": "uv sync --extra all && PGPASSWORD=postgres psql -U postgres -h postgres -c 'CREATE EXTENSION IF NOT EXISTS hstore;' flask_admin_test" +} diff --git a/.devcontainer/tests/docker-compose.yaml b/.devcontainer/tests/docker-compose.yaml new file mode 100644 index 0000000000..26cb4ae7ba --- /dev/null +++ b/.devcontainer/tests/docker-compose.yaml @@ -0,0 +1,48 @@ +services: + app: + build: + context: ../.. + dockerfile: .devcontainer/tests/Dockerfile + args: + IMAGE: python:3.10-slim + + volumes: + - ../..:/workspace + - uv-cache:/root/.cache/uv + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + environment: + AZURE_STORAGE_CONNECTION_STRING: DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1; + SQLALCHEMY_DATABASE_URI: postgresql://postgres:postgres@postgres/flask_admin_test + MONGOCLIENT_HOST: mongo + depends_on: + - postgres + - azurite + - mongo + + postgres: + image: postgis/postgis:16-3.4 + restart: unless-stopped + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: flask_admin_test + volumes: + - postgres-data:/var/lib/postgresql/data + - ./init-hstore.sql:/docker-entrypoint-initdb.d/init-hstore.sql + + azurite: + image: mcr.microsoft.com/azure-storage/azurite:latest + restart: unless-stopped + command: azurite --skipApiVersionCheck --blobHost 0.0.0.0 + volumes: + - azurite-data:/data + + mongo: + image: mongo:5.0.14-focal + restart: unless-stopped + +volumes: + postgres-data: + azurite-data: + uv-cache: diff --git a/.devcontainer/tests/init-hstore.sql b/.devcontainer/tests/init-hstore.sql new file mode 100644 index 0000000000..5ed9f15780 --- /dev/null +++ b/.devcontainer/tests/init-hstore.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS hstore; diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..64ece2cdfe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.ruff_cache +.tox +.mypy_cache +.pytest_cache +.vscode +.idea + +.git +__pycache__ +**/*.pyc +**/*.pyo +*.egg-info +.coverage +.venv diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..2ff985a67a --- /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 0000000000..f1377a89c6 --- /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 0000000000..4e64f3d224 --- /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 0000000000..39c8f08758 --- /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 0000000000..5e6c4aae9f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + github-actions: + patterns: + - '*' + - package-ecosystem: uv + directory: / + schedule: + interval: monthly + exclude-paths: + - "LICENSE.txt" + groups: + python-requirements: + patterns: + - '*' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..0552e7c1ac --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ + + + + + diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000000..05bf88c5f9 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,24 @@ +# .github/release.yml + +changelog: + exclude: + labels: + - ignore-for-release + authors: + - octocat + categories: + - title: Breaking changes 🛠 + labels: + - Semver-Major + - breaking-change + - title: Exciting new features 🎉 + labels: + - Semver-Minor + - enhancement + - title: Bug fixes 🐛 + labels: + - Semver-Patch + - bug + - title: Other changes + labels: + - "*" diff --git a/.github/workflows/lock.yaml b/.github/workflows/lock.yaml new file mode 100644 index 0000000000..1150eb3fd0 --- /dev/null +++ b/.github/workflows/lock.yaml @@ -0,0 +1,24 @@ +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 + discussions: write +concurrency: + group: lock +jobs: + lock: + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2 + 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 0000000000..5bdcd81ae0 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,58 @@ +name: Publish +on: + push: + tags: ['*'] +jobs: + build: + runs-on: ubuntu-latest + outputs: + artifact-id: ${{ steps.upload-artifact.outputs.artifact-id }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + prune-cache: false + cache-dependency-glob: | + **/uv.lock + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: pyproject.toml + - run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV + - run: uv build + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + id: upload-artifact + with: + name: dist + path: dist/ + if-no-files-found: error + create-release: + needs: [build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.build.outputs.artifact-id }} + path: dist/ + - name: create release + run: gh release create --draft --repo ${{ github.repository }} ${{ github.ref_name }} dist/* + env: + GH_TOKEN: ${{ github.token }} + publish-pypi: + needs: [build] + 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.build.outputs.artifact-id }} + path: dist/ + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: dist/ diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000000..536fccacb3 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,119 @@ +name: Tests +on: + push: + branches: + - master + - '*.x' + paths-ignore: + - 'docs/**' + - '*.md' + - '*.rst' + pull_request: + paths-ignore: + - 'docs/**' + - '*.md' + - '*.rst' + schedule: + - cron: '0 3 * * 1' +jobs: + tests: + name: ${{ matrix.tox == 'normal' && format('py{0}', matrix.python) || matrix.tox }} + runs-on: ${{ matrix.os || 'ubuntu-latest' }} + strategy: + fail-fast: false + matrix: + python: ['3.10', '3.11', '3.12', '3.13', '3.14'] + tox: ['normal'] + include: + - python: '3.10' + tox: 'py310-min' + - python: '3.14' + tox: 'py314-noflaskbabel' + - python: '3.10' + tox: 'py310-sqlalchemy1' + - python: '3.14' + tox: 'py314-sqlalchemy1' + services: + # Label used to access the service container + postgres: + # Docker Hub image + image: postgis/postgis:16-3.4 # 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 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + prune-cache: false + cache-dependency-glob: | + **/uv.lock + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python }} + allow-prereleases: true + - name: Install Ubuntu packages + run: | + sudo apt-get update + sudo apt-get install -y libgeos-c1v5 + - name: Check out repository code + uses: actions/checkout@v7 + - name: Start Azurite service + run: | + docker run -d \ + --name azurite \ + --network host \ + -p 10000:10000 \ + mcr.microsoft.com/azure-storage/azurite:latest \ + azurite --skipApiVersionCheck --blobHost 0.0.0.0 + - name: Set up PostgreSQL hstore module + env: + PGPASSWORD: postgres + run: psql -U postgres -h localhost -c 'CREATE EXTENSION hstore;' flask_admin_test + - name: Set tox env name + id: tox_env + run: | + if [ "${{ matrix.tox }}" = "normal" ]; then + echo "name=py$(echo '${{ matrix.python }}' | tr -d '.')" >> $GITHUB_OUTPUT + else + echo "name=${{ matrix.tox }}" >> $GITHUB_OUTPUT + fi + - run: uv run --locked tox run -e ${{ steps.tox_env.outputs.name }} + not_tests: + name: ${{ matrix.tox }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + tox: ['docs', 'typing', 'style'] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + prune-cache: false + cache-dependency-glob: | + **/uv.lock + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: pyproject.toml + - name: cache mypy + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ./.mypy_cache + key: mypy|${{ hashFiles('pyproject.toml') }} + - run: uv run --locked tox run -e ${{ matrix.tox }} diff --git a/.gitignore b/.gitignore index 1e41ae5ae4..526309ec73 100644 --- a/.gitignore +++ b/.gitignore @@ -13,13 +13,14 @@ flask_admin/tests/tmp dist/* make.bat venv +.venv *.sublime-* .coverage __pycache__ examples/sqla-inline/static examples/file/files examples/forms/files -examples/appengine/lib +examples/**/uv.lock .DS_Store .idea/ *.sqlite @@ -27,3 +28,13 @@ env *.egg .eggs .tox/ +.env +doc/_build +.vscode + + +# Testing only +flask_admin/tests/fileadmin/files/* +f.html +/instance/file:mem +/instance/file:test_different_bind_joins\[with_session_deprecated-SQLALiteProvider\] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..8efac8ab86 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +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: v5.0.0 + hooks: + - id: check-merge-conflict + - id: debug-statements + - id: fix-byte-order-marker + - id: trailing-whitespace + exclude: ^flask_admin/static/ + - id: end-of-file-fixer + exclude: ^flask_admin/static/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000000..404fd0eba1 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,10 @@ +version: 2 +build: + os: ubuntu-24.04 + tools: + python: '3.13' + commands: + - asdf plugin add uv + - asdf install uv latest + - asdf global uv latest + - uv run --group docs sphinx-build -W -b dirhtml doc $READTHEDOCS_OUTPUT/html diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 635a8543f0..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,52 +0,0 @@ -sudo: false -language: python -matrix: - include: - - python: 2.7 - env: TOX_ENV=py27-WTForms1 - - python: 2.7 - env: TOX_ENV=py27-WTForms2 - - python: 2.7 - env: TOX_ENV=flake8 - - python: 2.7 - env: TOX_ENV=docs-html - - python: 3.5 - env: TOX_ENV=py35-WTForms1 - - python: 3.5 - env: TOX_ENV=py35-WTForms2 - - python: 3.6 - env: TOX_ENV=py36-WTForms1 - - python: 3.6 - env: TOX_ENV=py36-WTForms2 - - python: 3.7 - env: TOX_ENV=py37-WTForms1 - - python: 3.7 - env: TOX_ENV=py37-WTForms2 - - python: 3.8 - env: TOX_ENV=py38-WTForms2 - -addons: - postgresql: "9.4" - apt: - packages: - - postgresql-9.4-postgis-2.4 - - postgresql-9.4-postgis-2.4-scripts - -services: - - postgresql - - mongodb - - docker - -before_script: - - psql -U postgres -c 'CREATE DATABASE flask_admin_test;' - - psql -U postgres -c 'CREATE EXTENSION postgis;' flask_admin_test - - psql -U postgres -c 'CREATE EXTENSION hstore;' flask_admin_test - - docker run --restart always -d -e executable=blob -p 10000:10000 --tmpfs /opt/azurite/folder arafato/azurite:2.6.5 - -install: - - pip install tox - -script: tox -e $TOX_ENV - -after_success: - - coveralls diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000000..c76f9036cd --- /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 2e4bf21db4..7be8f6c7ab 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/Makefile b/Makefile index 1fa58746c0..9a9472ce83 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # You can set these variables from the command line. SPHINXOPTS = -SPHINXBUILD = sphinx-build +SPHINXBUILD = uv run sphinx-build PAPER = BUILDDIR = build @@ -151,3 +151,12 @@ doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." + +.PHONY: test-in-docker +test-in-docker: + docker compose -f .devcontainer/tests/docker-compose.yaml build app + docker compose -f .devcontainer/tests/docker-compose.yaml run --remove-orphans app uv run --no-sync pytest + +.PHONY: tox-in-docker +tox-in-docker: + docker compose -f .devcontainer/tests/docker-compose.yaml run --remove-orphans app uv run tox diff --git a/NOTICE b/NOTICE index 8c2eecf8a7..1440bd1a82 100644 --- a/NOTICE +++ b/NOTICE @@ -3,10 +3,10 @@ Flask-Admin includes some bundled software to ease installation. Select2 ======= -Distributed under `APLv2 `_. +Distributed under `APLv2 `_. Bootstrap ================= -v3.1.0 and subsequent versions distributed under `MIT `_. -Versions prior to v3.1.0 distributed under `APLv2 `_. +v3.1.0 and subsequent versions distributed under `MIT `_. +Versions prior to v3.1.0 distributed under `APLv2 `_. diff --git a/README.md b/README.md new file mode 100644 index 0000000000..c9408cd1fb --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +# Flask-Admin + +Flask-Admin is now part of Pallets-Eco, an open source organization managed by the +Pallets team to facilitate community maintenance of Flask extensions. Please update +your references to `https://github.com/pallets-eco/flask-admin.git`. + +[![image](https://github.com/pallets-eco/flask-admin/actions/workflows/tests.yaml/badge.svg?branch=master)](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]. + +## Introduction + +Flask-Admin is a batteries-included, simple-to-use +[Flask](https://flask.palletsprojects.com/) 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. + +image + + +Out-of-the-box, Flask-Admin plays nicely with various ORM\'s, including + +- [SQLAlchemy](https://www.sqlalchemy.org/) (via either [Flask-SQLAlchemy](https://flask-sqlalchemy.palletsprojects.com/) or + [Flask-SQLAlchemy-Lite](https://flask-sqlalchemy-lite.readthedocs.io/)) +- [pymongo](https://pymongo.readthedocs.io/) +- [MongoEngine](https://mongoengine.org/) +- and [Peewee](https://github.com/coleifer/peewee). + +It also boasts a simple file management interface and a [Redis client](https://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*. + +### How to run an example + +Clone the repository and navigate to an example (for this example we are using SQLAlchemy Example): + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/sqla +``` + +> All examples use [`uv`](https://docs.astral.sh/uv/) to manage their dependencies and the developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` + +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: + +```shell +tox -e docs +``` + +## Installation + +To install Flask-Admin using pip, simply: + +```shell +pip install flask-admin +``` + +## Contributing + +If you are a developer working on and maintaining Flask-Admin, checkout the repo by doing: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin +``` + +Flask-Admin uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. With +the repository checked out, to install the minimum version of Python that Flask-Admin supports, create your +virtual environment, and install the required dependencies, run: + +```shell +uv sync +``` + +This will install Flask-Admin but without any of the optional extra dependencies, such as those for sqlalchemy +or mongoengine support. To install all extras, run: + +```shell +uv sync --extra all +``` + +Finally, enable pre-commit hooks: + +```shell +pre-commit 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/). + +### Running tests inside the devcontainer (eg when using VS Code) + +If you are developing with the devcontainer configuration, then you can run tests directly using either of the following commands. + +To just run the test suite with the default python installation, use: + +```shell +uv run pytest +``` + +To run the test suite against all supported python versions, and also run other checks performed by CI, use: + +```shell +uv run tox +``` + +### Running tests as a one-off via docker-compose run / `make test` + +If you don't use devcontainers then you can run the tests using docker (you will need to install and setup docker yourself). Then you can use: + +```shell +make test-in-docker +``` + +This will use the devcontainer docker-compose configuration to start up postgres, azurite and mongo. + +You can also run the full test suite including CI checks with: + +```shell +make tox-in-docker +``` + +## 3rd Party Stuff + +Flask-Admin is built with the help of +[Bootstrap](https://getbootstrap.com/), +[Select2](https://github.com/ivaynberg/select2) and +[Bootswatch](https://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 by opening a PR. +## As a developer who's changed some text in Flask-Admin +```bash +uv sync --group docs +cd babel +./babel.sh --update +``` + +## As a translator who's updated some `.po`/`.mo` files +Run `cd babel` +Run `./babel.sh` + + +[discord]: https://discord.gg/pallets diff --git a/README.rst b/README.rst deleted file mode 100644 index 801e53f136..0000000000 --- a/README.rst +++ /dev/null @@ -1,126 +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://travis-ci.org/flask-admin/flask-admin.svg?branch=master - :target: https://travis-ci.org/flask-admin/flask-admin - -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 *nose*. 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 - nosetests - -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; - -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/TODO.txt b/TODO.txt deleted file mode 100644 index 313fe2deac..0000000000 --- a/TODO.txt +++ /dev/null @@ -1,6 +0,0 @@ -- Python 3 - - Test for raw wtforms form -- Model backends - - model_changed callback to accept 3rd parameter -- MongoEngine - - ImageField support diff --git a/babel/README.md b/babel/README.md new file mode 100644 index 0000000000..749f7ef8dc --- /dev/null +++ b/babel/README.md @@ -0,0 +1,14 @@ +# Working with Babel translations +```bash +uv sync --group docs +``` +## As a developer who's changed some text in Flask-Admin + +Run `./babel.sh --update` + +## As a translator who wants to find missing translations +Run `awk '/^msgid / {msgid=substr($0, 8, length($0)-8)} /^msgstr ""$/ {print msgid}' file.po` + +## As a translator who's updated some `.po`/`.mo` files + +Run `./babel.sh` diff --git a/babel/admin.pot b/babel/admin.pot index 355ea2f97c..13939b54c3 100644 --- a/babel/admin.pot +++ b/babel/admin.pot @@ -1,662 +1,620 @@ # Translations template for Flask-Admin. -# Copyright (C) 2017 ORGANIZATION +# Copyright (C) 2025 ORGANIZATION # This file is distributed under the same license as the Flask-Admin # project. -# FIRST AUTHOR , 2017. +# FIRST AUTHOR , 2025. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: Flask-Admin VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2017-02-07 00:19-0600\n" +"POT-Creation-Date: 2025-12-30 13:22+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.1.1\n" +"Generated-By: Babel 2.17.0\n" -#: ../flask_admin/base.py:440 +#: ../flask_admin/base.py:519 msgid "Home" msgstr "" -#: ../flask_admin/contrib/rediscli.py:127 +#: ../flask_admin/contrib/rediscli.py:118 msgid "Cli: Invalid command." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:352 +#: ../flask_admin/contrib/fileadmin/__init__.py:445 msgid "File to upload" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:360 +#: ../flask_admin/contrib/fileadmin/__init__.py:453 msgid "File required." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:365 +#: ../flask_admin/contrib/fileadmin/__init__.py:458 msgid "Invalid file type." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:376 +#: ../flask_admin/contrib/fileadmin/__init__.py:471 msgid "Content" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:390 +#: ../flask_admin/contrib/fileadmin/__init__.py:488 msgid "Invalid name" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:398 -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:106 -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:112 -#: ../flask_admin/tests/sqla/test_translation.py:17 +#: ../flask_admin/contrib/fileadmin/__init__.py:498 +#: ../flask_admin/tests/sqla/test_translation.py:22 msgid "Name" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:757 +#: ../flask_admin/contrib/fileadmin/__init__.py:871 #, python-format msgid "File \"%(name)s\" already exists." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:802 -#: ../flask_admin/contrib/fileadmin/__init__.py:885 -#: ../flask_admin/contrib/fileadmin/__init__.py:947 -#: ../flask_admin/contrib/fileadmin/__init__.py:1000 -#: ../flask_admin/contrib/fileadmin/__init__.py:1047 -#: ../flask_admin/contrib/fileadmin/__init__.py:1099 -#: ../flask_admin/model/base.py:2168 +#: ../flask_admin/contrib/fileadmin/__init__.py:922 +#: ../flask_admin/contrib/fileadmin/__init__.py:1029 +#: ../flask_admin/contrib/fileadmin/__init__.py:1107 +#: ../flask_admin/contrib/fileadmin/__init__.py:1181 +#: ../flask_admin/contrib/fileadmin/__init__.py:1252 +#: ../flask_admin/contrib/fileadmin/__init__.py:1323 +#: ../flask_admin/model/base.py:2565 msgid "Permission denied." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:881 +#: ../flask_admin/contrib/fileadmin/__init__.py:1025 msgid "File uploading is disabled." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:892 +#: ../flask_admin/contrib/fileadmin/__init__.py:1038 #, python-format msgid "Successfully saved file: %(name)s" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:896 +#: ../flask_admin/contrib/fileadmin/__init__.py:1047 #, python-format msgid "Failed to save file: %(error)s" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:904 -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:148 -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:150 -#: ../flask_admin/templates/bootstrap3/admin/file/list.html:148 -#: ../flask_admin/templates/bootstrap3/admin/file/list.html:150 +#: ../flask_admin/contrib/fileadmin/__init__.py:1063 +#: ../flask_admin/templates/bootstrap4/admin/file/list.html:154 +#: ../flask_admin/templates/bootstrap4/admin/file/list.html:156 msgid "Upload File" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:943 +#: ../flask_admin/contrib/fileadmin/__init__.py:1103 msgid "Directory creation is disabled." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:956 +#: ../flask_admin/contrib/fileadmin/__init__.py:1124 #, python-format msgid "Successfully created directory: %(directory)s" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:960 +#: ../flask_admin/contrib/fileadmin/__init__.py:1133 #, python-format msgid "Failed to create directory: %(error)s" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:970 -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:159 -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:161 -#: ../flask_admin/templates/bootstrap3/admin/file/list.html:159 -#: ../flask_admin/templates/bootstrap3/admin/file/list.html:161 +#: ../flask_admin/contrib/fileadmin/__init__.py:1150 +#: ../flask_admin/templates/bootstrap4/admin/file/list.html:165 +#: ../flask_admin/templates/bootstrap4/admin/file/list.html:167 msgid "Create Directory" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:996 +#: ../flask_admin/contrib/fileadmin/__init__.py:1177 msgid "Deletion is disabled." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1005 +#: ../flask_admin/contrib/fileadmin/__init__.py:1186 msgid "Directory deletion is disabled." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1011 +#: ../flask_admin/contrib/fileadmin/__init__.py:1194 #, python-format msgid "Directory \"%(path)s\" was successfully deleted." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1013 +#: ../flask_admin/contrib/fileadmin/__init__.py:1201 #, python-format msgid "Failed to delete directory: %(error)s" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1019 -#: ../flask_admin/contrib/fileadmin/__init__.py:1176 +#: ../flask_admin/contrib/fileadmin/__init__.py:1212 +#: ../flask_admin/contrib/fileadmin/__init__.py:1421 #, python-format msgid "File \"%(name)s\" was successfully deleted." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1021 -#: ../flask_admin/contrib/fileadmin/__init__.py:1178 +#: ../flask_admin/contrib/fileadmin/__init__.py:1218 +#: ../flask_admin/contrib/fileadmin/__init__.py:1427 #, python-format msgid "Failed to delete file: %(name)s" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1043 +#: ../flask_admin/contrib/fileadmin/__init__.py:1248 msgid "Renaming is disabled." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1051 +#: ../flask_admin/contrib/fileadmin/__init__.py:1256 msgid "Path does not exist." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1061 +#: ../flask_admin/contrib/fileadmin/__init__.py:1269 #, python-format msgid "Successfully renamed \"%(src)s\" to \"%(dst)s\"" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1064 +#: ../flask_admin/contrib/fileadmin/__init__.py:1278 #, python-format msgid "Failed to rename: %(error)s" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1078 +#: ../flask_admin/contrib/fileadmin/__init__.py:1301 #, python-format msgid "Rename %(name)s" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1115 +#: ../flask_admin/contrib/fileadmin/__init__.py:1341 #, python-format msgid "Error saving changes to %(name)s." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1119 +#: ../flask_admin/contrib/fileadmin/__init__.py:1347 #, python-format msgid "Changes to %(name)s saved successfully." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1128 +#: ../flask_admin/contrib/fileadmin/__init__.py:1357 #, python-format msgid "Error reading %(name)s." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1131 -#: ../flask_admin/contrib/fileadmin/__init__.py:1140 +#: ../flask_admin/contrib/fileadmin/__init__.py:1361 +#: ../flask_admin/contrib/fileadmin/__init__.py:1374 #, python-format msgid "Unexpected error while reading from %(name)s" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1137 +#: ../flask_admin/contrib/fileadmin/__init__.py:1369 #, python-format msgid "Cannot edit %(name)s." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1155 +#: ../flask_admin/contrib/fileadmin/__init__.py:1396 #, python-format msgid "Editing %(path)s" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1163 -#: ../flask_admin/contrib/mongoengine/view.py:658 -#: ../flask_admin/contrib/peewee/view.py:487 -#: ../flask_admin/contrib/pymongo/view.py:384 -#: ../flask_admin/contrib/sqla/view.py:1149 +#: ../flask_admin/contrib/fileadmin/__init__.py:1406 +#: ../flask_admin/contrib/mongoengine/view.py:727 +#: ../flask_admin/contrib/peewee/view.py:606 +#: ../flask_admin/contrib/pymongo/view.py:418 +#: ../flask_admin/contrib/sqla/view.py:1453 msgid "Delete" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1164 +#: ../flask_admin/contrib/fileadmin/__init__.py:1407 msgid "Are you sure you want to delete these files?" msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1167 +#: ../flask_admin/contrib/fileadmin/__init__.py:1411 msgid "File deletion is disabled." msgstr "" -#: ../flask_admin/contrib/fileadmin/__init__.py:1180 -#: ../flask_admin/templates/bootstrap2/admin/model/details.html:17 -#: ../flask_admin/templates/bootstrap2/admin/model/edit.html:22 -#: ../flask_admin/templates/bootstrap3/admin/model/details.html:17 -#: ../flask_admin/templates/bootstrap3/admin/model/edit.html:22 +#: ../flask_admin/contrib/fileadmin/__init__.py:1433 +#: ../flask_admin/templates/bootstrap4/admin/model/details.html:17 +#: ../flask_admin/templates/bootstrap4/admin/model/edit.html:22 msgid "Edit" msgstr "" -#: ../flask_admin/contrib/fileadmin/s3.py:153 +#: ../flask_admin/contrib/fileadmin/s3.py:238 msgid "Cannot operate on non empty directories" msgstr "" -#: ../flask_admin/contrib/mongoengine/filters.py:39 -#: ../flask_admin/contrib/peewee/filters.py:35 -#: ../flask_admin/contrib/pymongo/filters.py:38 -#: ../flask_admin/contrib/sqla/filters.py:41 +#: ../flask_admin/contrib/mongoengine/filters.py:41 +#: ../flask_admin/contrib/peewee/filters.py:47 +#: ../flask_admin/contrib/pymongo/filters.py:49 +#: ../flask_admin/contrib/sqla/filters.py:62 msgid "equals" msgstr "" -#: ../flask_admin/contrib/mongoengine/filters.py:48 -#: ../flask_admin/contrib/peewee/filters.py:43 -#: ../flask_admin/contrib/pymongo/filters.py:47 -#: ../flask_admin/contrib/sqla/filters.py:49 +#: ../flask_admin/contrib/mongoengine/filters.py:50 +#: ../flask_admin/contrib/peewee/filters.py:55 +#: ../flask_admin/contrib/pymongo/filters.py:58 +#: ../flask_admin/contrib/sqla/filters.py:72 msgid "not equal" msgstr "" -#: ../flask_admin/contrib/mongoengine/filters.py:58 -#: ../flask_admin/contrib/peewee/filters.py:52 -#: ../flask_admin/contrib/pymongo/filters.py:57 -#: ../flask_admin/contrib/sqla/filters.py:58 +#: ../flask_admin/contrib/mongoengine/filters.py:60 +#: ../flask_admin/contrib/peewee/filters.py:64 +#: ../flask_admin/contrib/pymongo/filters.py:68 +#: ../flask_admin/contrib/sqla/filters.py:83 msgid "contains" msgstr "" -#: ../flask_admin/contrib/mongoengine/filters.py:68 -#: ../flask_admin/contrib/peewee/filters.py:61 -#: ../flask_admin/contrib/pymongo/filters.py:67 -#: ../flask_admin/contrib/sqla/filters.py:67 +#: ../flask_admin/contrib/mongoengine/filters.py:70 +#: ../flask_admin/contrib/peewee/filters.py:73 +#: ../flask_admin/contrib/pymongo/filters.py:78 +#: ../flask_admin/contrib/sqla/filters.py:94 msgid "not contains" msgstr "" -#: ../flask_admin/contrib/mongoengine/filters.py:77 -#: ../flask_admin/contrib/peewee/filters.py:69 -#: ../flask_admin/contrib/pymongo/filters.py:80 -#: ../flask_admin/contrib/sqla/filters.py:75 +#: ../flask_admin/contrib/mongoengine/filters.py:79 +#: ../flask_admin/contrib/peewee/filters.py:81 +#: ../flask_admin/contrib/pymongo/filters.py:91 +#: ../flask_admin/contrib/sqla/filters.py:104 msgid "greater than" msgstr "" -#: ../flask_admin/contrib/mongoengine/filters.py:86 -#: ../flask_admin/contrib/peewee/filters.py:77 -#: ../flask_admin/contrib/pymongo/filters.py:93 -#: ../flask_admin/contrib/sqla/filters.py:83 +#: ../flask_admin/contrib/mongoengine/filters.py:88 +#: ../flask_admin/contrib/peewee/filters.py:89 +#: ../flask_admin/contrib/pymongo/filters.py:104 +#: ../flask_admin/contrib/sqla/filters.py:114 msgid "smaller than" msgstr "" -#: ../flask_admin/contrib/mongoengine/filters.py:98 -#: ../flask_admin/contrib/peewee/filters.py:88 -#: ../flask_admin/contrib/sqla/filters.py:94 +#: ../flask_admin/contrib/mongoengine/filters.py:100 +#: ../flask_admin/contrib/peewee/filters.py:100 +#: ../flask_admin/contrib/sqla/filters.py:127 msgid "empty" msgstr "" -#: ../flask_admin/contrib/mongoengine/filters.py:113 -#: ../flask_admin/contrib/peewee/filters.py:102 -#: ../flask_admin/contrib/sqla/filters.py:108 +#: ../flask_admin/contrib/mongoengine/filters.py:115 +#: ../flask_admin/contrib/peewee/filters.py:120 +#: ../flask_admin/contrib/sqla/filters.py:149 msgid "in list" msgstr "" -#: ../flask_admin/contrib/mongoengine/filters.py:122 -#: ../flask_admin/contrib/peewee/filters.py:111 -#: ../flask_admin/contrib/sqla/filters.py:118 +#: ../flask_admin/contrib/mongoengine/filters.py:124 +#: ../flask_admin/contrib/peewee/filters.py:129 +#: ../flask_admin/contrib/sqla/filters.py:161 msgid "not in list" msgstr "" -#: ../flask_admin/contrib/mongoengine/filters.py:222 -#: ../flask_admin/contrib/peewee/filters.py:207 -#: ../flask_admin/contrib/peewee/filters.py:244 -#: ../flask_admin/contrib/peewee/filters.py:281 -#: ../flask_admin/contrib/sqla/filters.py:213 -#: ../flask_admin/contrib/sqla/filters.py:250 -#: ../flask_admin/contrib/sqla/filters.py:287 +#: ../flask_admin/contrib/mongoengine/filters.py:223 +#: ../flask_admin/contrib/peewee/filters.py:228 +#: ../flask_admin/contrib/peewee/filters.py:268 +#: ../flask_admin/contrib/peewee/filters.py:308 +#: ../flask_admin/contrib/sqla/filters.py:272 +#: ../flask_admin/contrib/sqla/filters.py:316 +#: ../flask_admin/contrib/sqla/filters.py:360 msgid "not between" msgstr "" -#: ../flask_admin/contrib/mongoengine/filters.py:247 +#: ../flask_admin/contrib/mongoengine/filters.py:248 msgid "ObjectId equals" msgstr "" -#: ../flask_admin/contrib/mongoengine/view.py:551 +#: ../flask_admin/contrib/mongoengine/form.py:121 +#: ../flask_admin/contrib/sqla/fields.py:151 +#: ../flask_admin/contrib/sqla/fields.py:210 +#: ../flask_admin/contrib/sqla/fields.py:215 ../flask_admin/model/fields.py:222 +#: ../flask_admin/model/fields.py:282 +msgid "Not a valid choice" +msgstr "" + +#: ../flask_admin/contrib/mongoengine/view.py:607 #, python-format msgid "Failed to get model. %(error)s" msgstr "" -#: ../flask_admin/contrib/mongoengine/view.py:570 -#: ../flask_admin/contrib/peewee/view.py:435 -#: ../flask_admin/contrib/pymongo/view.py:316 -#: ../flask_admin/contrib/sqla/view.py:1078 +#: ../flask_admin/contrib/mongoengine/view.py:628 +#: ../flask_admin/contrib/peewee/view.py:545 +#: ../flask_admin/contrib/pymongo/view.py:352 +#: ../flask_admin/contrib/sqla/view.py:1373 #, python-format msgid "Failed to create record. %(error)s" msgstr "" -#: ../flask_admin/contrib/mongoengine/view.py:596 -#: ../flask_admin/contrib/peewee/view.py:454 -#: ../flask_admin/contrib/pymongo/view.py:341 -#: ../flask_admin/contrib/sqla/view.py:1104 ../flask_admin/model/base.py:2305 -#: ../flask_admin/model/base.py:2313 ../flask_admin/model/base.py:2315 +#: ../flask_admin/contrib/mongoengine/view.py:657 +#: ../flask_admin/contrib/peewee/view.py:567 +#: ../flask_admin/contrib/pymongo/view.py:376 +#: ../flask_admin/contrib/sqla/view.py:1402 ../flask_admin/model/base.py:2720 +#: ../flask_admin/model/base.py:2727 ../flask_admin/model/base.py:2731 #, python-format msgid "Failed to update record. %(error)s" msgstr "" -#: ../flask_admin/contrib/mongoengine/view.py:619 -#: ../flask_admin/contrib/peewee/view.py:469 -#: ../flask_admin/contrib/pymongo/view.py:366 -#: ../flask_admin/contrib/sqla/view.py:1129 +#: ../flask_admin/contrib/mongoengine/view.py:683 +#: ../flask_admin/contrib/peewee/view.py:585 +#: ../flask_admin/contrib/pymongo/view.py:400 +#: ../flask_admin/contrib/sqla/view.py:1430 #, python-format msgid "Failed to delete record. %(error)s" msgstr "" -#: ../flask_admin/contrib/mongoengine/view.py:659 -#: ../flask_admin/contrib/peewee/view.py:488 -#: ../flask_admin/contrib/pymongo/view.py:385 -#: ../flask_admin/contrib/sqla/view.py:1150 +#: ../flask_admin/contrib/mongoengine/view.py:728 +#: ../flask_admin/contrib/peewee/view.py:607 +#: ../flask_admin/contrib/pymongo/view.py:419 +#: ../flask_admin/contrib/sqla/view.py:1454 msgid "Are you sure you want to delete selected records?" msgstr "" -#: ../flask_admin/contrib/mongoengine/view.py:668 -#: ../flask_admin/contrib/peewee/view.py:505 -#: ../flask_admin/contrib/pymongo/view.py:395 -#: ../flask_admin/contrib/sqla/view.py:1166 ../flask_admin/model/base.py:2118 +#: ../flask_admin/contrib/mongoengine/view.py:740 +#: ../flask_admin/contrib/peewee/view.py:627 +#: ../flask_admin/contrib/pymongo/view.py:432 +#: ../flask_admin/contrib/sqla/view.py:1477 ../flask_admin/model/base.py:2500 #, python-format msgid "Record was successfully deleted." msgid_plural "%(count)s records were successfully deleted." msgstr[0] "" msgstr[1] "" -#: ../flask_admin/contrib/mongoengine/view.py:674 -#: ../flask_admin/contrib/peewee/view.py:511 -#: ../flask_admin/contrib/pymongo/view.py:400 -#: ../flask_admin/contrib/sqla/view.py:1174 +#: ../flask_admin/contrib/mongoengine/view.py:750 +#: ../flask_admin/contrib/peewee/view.py:637 +#: ../flask_admin/contrib/pymongo/view.py:441 +#: ../flask_admin/contrib/sqla/view.py:1489 #, python-format msgid "Failed to delete records. %(error)s" msgstr "" -#: ../flask_admin/contrib/sqla/fields.py:126 -#: ../flask_admin/contrib/sqla/fields.py:176 -#: ../flask_admin/contrib/sqla/fields.py:181 ../flask_admin/model/fields.py:173 -#: ../flask_admin/model/fields.py:222 -msgid "Not a valid choice" -msgstr "" - -#: ../flask_admin/contrib/sqla/fields.py:186 +#: ../flask_admin/contrib/sqla/fields.py:246 msgid "Key" msgstr "" -#: ../flask_admin/contrib/sqla/fields.py:187 +#: ../flask_admin/contrib/sqla/fields.py:247 msgid "Value" msgstr "" -#: ../flask_admin/contrib/sqla/validators.py:42 +#: ../flask_admin/contrib/sqla/validators.py:43 msgid "Already exists." msgstr "" -#: ../flask_admin/contrib/sqla/validators.py:60 +#: ../flask_admin/contrib/sqla/validators.py:81 #, python-format msgid "At least %(num)d item is required" msgid_plural "At least %(num)d items are required" msgstr[0] "" msgstr[1] "" -#: ../flask_admin/contrib/sqla/view.py:1057 +#: ../flask_admin/contrib/sqla/validators.py:98 +msgid "Not a valid ISO currency code (e.g. USD, EUR, CNY)." +msgstr "" + +#: ../flask_admin/contrib/sqla/validators.py:109 +msgid "Not a valid color (e.g. \"red\", \"#f00\", \"#ff0000\")." +msgstr "" + +#: ../flask_admin/contrib/sqla/view.py:1334 #, python-format msgid "Integrity error. %(message)s" msgstr "" -#: ../flask_admin/form/fields.py:98 +#: ../flask_admin/form/fields.py:131 msgid "Invalid time format" msgstr "" -#: ../flask_admin/form/fields.py:144 +#: ../flask_admin/form/fields.py:202 msgid "Invalid Choice: could not coerce" msgstr "" -#: ../flask_admin/form/fields.py:208 +#: ../flask_admin/form/fields.py:291 msgid "Invalid JSON" msgstr "" -#: ../flask_admin/form/upload.py:207 +#: ../flask_admin/form/upload.py:241 msgid "Invalid file extension" msgstr "" -#: ../flask_admin/form/upload.py:214 ../flask_admin/form/upload.py:281 -#, python-format -msgid "File \"%s\" already exists." +#: ../flask_admin/form/validators.py:18 +msgid "This field requires at least one item." msgstr "" -#: ../flask_admin/model/base.py:1649 +#: ../flask_admin/model/base.py:1922 msgid "There are no items in the table." msgstr "" -#: ../flask_admin/model/base.py:1673 +#: ../flask_admin/model/base.py:1931 #, python-format msgid "Invalid Filter Value: %(value)s" msgstr "" -#: ../flask_admin/model/base.py:1984 +#: ../flask_admin/model/base.py:2354 msgid "Record was successfully created." msgstr "" -#: ../flask_admin/model/base.py:2028 ../flask_admin/model/base.py:2080 -#: ../flask_admin/model/base.py:2113 ../flask_admin/model/base.py:2297 +#: ../flask_admin/model/base.py:2371 +msgid "Failed to create record." +msgstr "" + +#: ../flask_admin/model/base.py:2403 ../flask_admin/model/base.py:2459 +#: ../flask_admin/model/base.py:2493 ../flask_admin/model/base.py:2711 msgid "Record does not exist." msgstr "" -#: ../flask_admin/model/base.py:2037 ../flask_admin/model/base.py:2301 +#: ../flask_admin/model/base.py:2411 ../flask_admin/model/base.py:2716 msgid "Record was successfully saved." msgstr "" -#: ../flask_admin/model/base.py:2222 -msgid "Tablib dependency not installed." +#: ../flask_admin/model/base.py:2424 +msgid "Failed to save record." msgstr "" -#: ../flask_admin/model/base.py:2249 +#: ../flask_admin/model/base.py:2655 #, python-format -msgid "Export type \"%(type)s not supported." +msgid "Export type \"%(type)s\" is not supported." msgstr "" -#: ../flask_admin/model/filters.py:103 ../flask_admin/model/widgets.py:111 +#: ../flask_admin/model/filters.py:121 ../flask_admin/model/widgets.py:128 msgid "Yes" msgstr "" -#: ../flask_admin/model/filters.py:104 ../flask_admin/model/widgets.py:110 +#: ../flask_admin/model/filters.py:121 ../flask_admin/model/widgets.py:127 msgid "No" msgstr "" -#: ../flask_admin/model/filters.py:172 ../flask_admin/model/filters.py:212 -#: ../flask_admin/model/filters.py:257 +#: ../flask_admin/model/filters.py:197 ../flask_admin/model/filters.py:244 +#: ../flask_admin/model/filters.py:291 msgid "between" msgstr "" -#: ../flask_admin/model/template.py:81 ../flask_admin/model/template.py:88 -#: ../flask_admin/templates/bootstrap2/admin/model/modals/details.html:37 -#: ../flask_admin/templates/bootstrap3/admin/model/modals/details.html:8 +#: ../flask_admin/model/template.py:97 ../flask_admin/model/template.py:102 +#: ../flask_admin/templates/bootstrap4/admin/model/modals/details.html:7 msgid "View Record" msgstr "" -#: ../flask_admin/model/template.py:95 ../flask_admin/model/template.py:102 -#: ../flask_admin/model/template.py:109 -#: ../flask_admin/templates/bootstrap2/admin/model/modals/edit.html:22 -#: ../flask_admin/templates/bootstrap3/admin/model/modals/edit.html:11 +#: ../flask_admin/model/template.py:107 ../flask_admin/model/template.py:112 +#: ../flask_admin/templates/bootstrap4/admin/model/modals/edit.html:10 msgid "Edit Record" msgstr "" -#: ../flask_admin/model/widgets.py:61 +#: ../flask_admin/model/template.py:117 +#: ../flask_admin/templates/bootstrap4/admin/model/row_actions.html:34 +msgid "Delete Record" +msgstr "" + +#: ../flask_admin/model/widgets.py:71 msgid "Please select model" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/actions.html:4 -#: ../flask_admin/templates/bootstrap3/admin/actions.html:4 +#: ../flask_admin/templates/bootstrap4/admin/actions.html:5 msgid "With selected" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/lib.html:200 -#: ../flask_admin/templates/bootstrap3/admin/lib.html:190 +#: ../flask_admin/templates/bootstrap4/admin/lib.html:216 +#: ../flask_admin/templates/bootstrap4/admin/lib.html:227 msgid "Save" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/lib.html:205 -#: ../flask_admin/templates/bootstrap3/admin/lib.html:195 +#: ../flask_admin/templates/bootstrap4/admin/lib.html:221 +#: ../flask_admin/templates/bootstrap4/admin/lib.html:232 msgid "Cancel" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/lib.html:256 -#: ../flask_admin/templates/bootstrap3/admin/lib.html:247 +#: ../flask_admin/templates/bootstrap4/admin/lib.html:290 msgid "Save and Add Another" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/lib.html:259 -#: ../flask_admin/templates/bootstrap3/admin/lib.html:250 +#: ../flask_admin/templates/bootstrap4/admin/lib.html:293 msgid "Save and Continue Editing" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:9 -#: ../flask_admin/templates/bootstrap3/admin/file/list.html:9 +#: ../flask_admin/templates/bootstrap4/admin/file/list.html:10 msgid "Root" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:40 -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:49 -#: ../flask_admin/templates/bootstrap2/admin/model/list.html:90 -#: ../flask_admin/templates/bootstrap2/admin/model/list.html:99 -#: ../flask_admin/templates/bootstrap3/admin/file/list.html:40 -#: ../flask_admin/templates/bootstrap3/admin/file/list.html:49 -#: ../flask_admin/templates/bootstrap3/admin/model/list.html:89 -#: ../flask_admin/templates/bootstrap3/admin/model/list.html:98 +#: ../flask_admin/templates/bootstrap4/admin/file/list.html:42 +#: ../flask_admin/templates/bootstrap4/admin/file/list.html:51 +#: ../flask_admin/templates/bootstrap4/admin/model/list.html:89 +#: ../flask_admin/templates/bootstrap4/admin/model/list.html:98 #, python-format msgid "Sort by %(name)s" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:74 -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:77 -#: ../flask_admin/templates/bootstrap3/admin/file/list.html:74 -#: ../flask_admin/templates/bootstrap3/admin/file/list.html:77 +#: ../flask_admin/templates/bootstrap4/admin/file/list.html:76 +#: ../flask_admin/templates/bootstrap4/admin/file/list.html:79 msgid "Rename File" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:88 -#: ../flask_admin/templates/bootstrap3/admin/file/list.html:88 +#: ../flask_admin/templates/bootstrap4/admin/file/list.html:92 #, python-format msgid "Are you sure you want to delete \\'%(name)s\\' recursively?" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:97 -#: ../flask_admin/templates/bootstrap3/admin/file/list.html:97 +#: ../flask_admin/templates/bootstrap4/admin/file/list.html:103 #, python-format msgid "Are you sure you want to delete \\'%(name)s\\'?" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:125 -msgid "Size" -msgstr "" - -#: ../flask_admin/templates/bootstrap2/admin/file/list.html:185 -#: ../flask_admin/templates/bootstrap3/admin/file/list.html:185 +#: ../flask_admin/templates/bootstrap4/admin/file/list.html:191 msgid "Please select at least one file." msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/create.html:14 -#: ../flask_admin/templates/bootstrap2/admin/model/details.html:8 -#: ../flask_admin/templates/bootstrap2/admin/model/edit.html:14 -#: ../flask_admin/templates/bootstrap2/admin/model/list.html:17 -#: ../flask_admin/templates/bootstrap3/admin/model/create.html:14 -#: ../flask_admin/templates/bootstrap3/admin/model/details.html:8 -#: ../flask_admin/templates/bootstrap3/admin/model/edit.html:14 -#: ../flask_admin/templates/bootstrap3/admin/model/list.html:17 +#: ../flask_admin/templates/bootstrap4/admin/model/create.html:14 +#: ../flask_admin/templates/bootstrap4/admin/model/details.html:8 +#: ../flask_admin/templates/bootstrap4/admin/model/edit.html:14 +#: ../flask_admin/templates/bootstrap4/admin/model/list.html:17 msgid "List" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/create.html:17 -#: ../flask_admin/templates/bootstrap2/admin/model/details.html:12 -#: ../flask_admin/templates/bootstrap2/admin/model/edit.html:18 -#: ../flask_admin/templates/bootstrap2/admin/model/list.html:23 -#: ../flask_admin/templates/bootstrap2/admin/model/list.html:25 -#: ../flask_admin/templates/bootstrap3/admin/model/create.html:17 -#: ../flask_admin/templates/bootstrap3/admin/model/details.html:12 -#: ../flask_admin/templates/bootstrap3/admin/model/edit.html:18 -#: ../flask_admin/templates/bootstrap3/admin/model/list.html:23 -#: ../flask_admin/templates/bootstrap3/admin/model/list.html:25 +#: ../flask_admin/templates/bootstrap4/admin/model/create.html:17 +#: ../flask_admin/templates/bootstrap4/admin/model/details.html:12 +#: ../flask_admin/templates/bootstrap4/admin/model/edit.html:18 +#: ../flask_admin/templates/bootstrap4/admin/model/list.html:23 +#: ../flask_admin/templates/bootstrap4/admin/model/list.html:25 msgid "Create" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/details.html:21 -#: ../flask_admin/templates/bootstrap2/admin/model/edit.html:26 -#: ../flask_admin/templates/bootstrap3/admin/model/details.html:21 -#: ../flask_admin/templates/bootstrap3/admin/model/edit.html:26 +#: ../flask_admin/templates/bootstrap4/admin/model/details.html:21 +#: ../flask_admin/templates/bootstrap4/admin/model/edit.html:26 msgid "Details" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/details.html:29 -#: ../flask_admin/templates/bootstrap2/admin/model/modals/details.html:8 -#: ../flask_admin/templates/bootstrap3/admin/model/details.html:28 -#: ../flask_admin/templates/bootstrap3/admin/model/modals/details.html:15 +#: ../flask_admin/templates/bootstrap4/admin/model/details.html:28 +#: ../flask_admin/templates/bootstrap4/admin/model/modals/details.html:15 msgid "Filter" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/inline_list_base.html:13 -#: ../flask_admin/templates/bootstrap3/admin/model/inline_list_base.html:14 +#: ../flask_admin/templates/bootstrap4/admin/model/inline_list_base.html:14 msgid "Delete?" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/inline_list_base.html:30 -#: ../flask_admin/templates/bootstrap3/admin/model/inline_list_base.html:33 +#: ../flask_admin/templates/bootstrap4/admin/model/inline_list_base.html:16 +#: ../flask_admin/templates/bootstrap4/admin/model/inline_list_base.html:35 +#: ../flask_admin/templates/bootstrap4/admin/model/row_actions.html:34 +msgid "Are you sure you want to delete this record?" +msgstr "" + +#: ../flask_admin/templates/bootstrap4/admin/model/inline_list_base.html:33 msgid "New" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/inline_list_base.html:40 -#: ../flask_admin/templates/bootstrap3/admin/model/inline_list_base.html:43 +#: ../flask_admin/templates/bootstrap4/admin/model/inline_list_base.html:43 msgid "Add" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/layout.html:3 -#: ../flask_admin/templates/bootstrap3/admin/model/layout.html:3 +#: ../flask_admin/templates/bootstrap4/admin/model/layout.html:2 msgid "Add Filter" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/layout.html:18 -#: ../flask_admin/templates/bootstrap2/admin/model/layout.html:23 -#: ../flask_admin/templates/bootstrap2/admin/model/layout.html:30 -#: ../flask_admin/templates/bootstrap3/admin/model/layout.html:18 -#: ../flask_admin/templates/bootstrap3/admin/model/layout.html:23 -#: ../flask_admin/templates/bootstrap3/admin/model/layout.html:30 +#: ../flask_admin/templates/bootstrap4/admin/model/layout.html:14 +#: ../flask_admin/templates/bootstrap4/admin/model/layout.html:19 +#: ../flask_admin/templates/bootstrap4/admin/model/layout.html:26 msgid "Export" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/layout.html:38 -#: ../flask_admin/templates/bootstrap3/admin/model/layout.html:38 +#: ../flask_admin/templates/bootstrap4/admin/model/layout.html:49 msgid "Apply" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/layout.html:40 -#: ../flask_admin/templates/bootstrap3/admin/model/layout.html:40 +#: ../flask_admin/templates/bootstrap4/admin/model/layout.html:51 msgid "Reset Filters" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/layout.html:59 -#: ../flask_admin/templates/bootstrap2/admin/model/layout.html:66 -#: ../flask_admin/templates/bootstrap3/admin/model/layout.html:59 -#: ../flask_admin/templates/bootstrap3/admin/model/layout.html:64 +#: ../flask_admin/templates/bootstrap4/admin/model/layout.html:80 +#: ../flask_admin/templates/bootstrap4/admin/model/layout.html:93 +#, python-format +msgid "%(placeholder)s" +msgstr "" + +#: ../flask_admin/templates/bootstrap4/admin/model/layout.html:88 +#: ../flask_admin/templates/bootstrap4/admin/model/layout.html:94 msgid "Search" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/layout.html:74 -#: ../flask_admin/templates/bootstrap2/admin/model/layout.html:77 -#: ../flask_admin/templates/bootstrap2/admin/model/layout.html:78 -#: ../flask_admin/templates/bootstrap2/admin/model/layout.html:79 -#: ../flask_admin/templates/bootstrap3/admin/model/layout.html:72 -#: ../flask_admin/templates/bootstrap3/admin/model/layout.html:75 -#: ../flask_admin/templates/bootstrap3/admin/model/layout.html:76 -#: ../flask_admin/templates/bootstrap3/admin/model/layout.html:77 +#: ../flask_admin/templates/bootstrap4/admin/model/layout.html:102 msgid "items" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/list.html:23 -#: ../flask_admin/templates/bootstrap2/admin/model/list.html:25 -#: ../flask_admin/templates/bootstrap2/admin/model/modals/create.html:22 -#: ../flask_admin/templates/bootstrap3/admin/model/list.html:23 -#: ../flask_admin/templates/bootstrap3/admin/model/list.html:25 -#: ../flask_admin/templates/bootstrap3/admin/model/modals/create.html:10 +#: ../flask_admin/templates/bootstrap4/admin/model/list.html:23 +#: ../flask_admin/templates/bootstrap4/admin/model/list.html:25 +#: ../flask_admin/templates/bootstrap4/admin/model/modals/create.html:10 msgid "Create New Record" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/list.html:77 -#: ../flask_admin/templates/bootstrap3/admin/model/list.html:76 +#: ../flask_admin/templates/bootstrap4/admin/model/list.html:76 msgid "Select all records" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/list.html:120 -#: ../flask_admin/templates/bootstrap3/admin/model/list.html:119 +#: ../flask_admin/templates/bootstrap4/admin/model/list.html:119 msgid "Select record" msgstr "" -#: ../flask_admin/templates/bootstrap2/admin/model/list.html:185 -#: ../flask_admin/templates/bootstrap3/admin/model/list.html:186 +#: ../flask_admin/templates/bootstrap4/admin/model/list.html:195 msgid "Please select at least one record." msgstr "" - -#: ../flask_admin/templates/bootstrap2/admin/model/row_actions.html:34 -#: ../flask_admin/templates/bootstrap3/admin/model/row_actions.html:34 -msgid "Are you sure you want to delete this record?" -msgstr "" - diff --git a/babel/babel.sh b/babel/babel.sh index b7764fdf51..982ef3602e 100755 --- a/babel/babel.sh +++ b/babel/babel.sh @@ -1,9 +1,16 @@ #!/bin/sh -pybabel extract -F babel.ini -k _gettext -k _ngettext -k lazy_gettext -o admin.pot --project Flask-Admin ../flask_admin -pybabel compile -f -D admin -d ../flask_admin/translations/ - -# docs -cd .. -make gettext -cp build/locale/*.pot babel/ -sphinx-intl update -p build/locale/ -d flask_admin/translations/ +uv run pybabel extract -F babel.ini -k _gettext -k _ngettext -k lazy_gettext -o admin.pot --project Flask-Admin ../flask_admin + +if [ "$1" = '--update' ]; then + uv run pybabel update -i admin.pot -d ../flask_admin/translations -D admin -N +fi + +uv run pybabel compile -f -D admin -d ../flask_admin/translations/ + + +## Commenting out temporarily: we don't have any of our docs translated right now and we don't have support for doing it. +## We can uncomment this intentionally when we want to start supporting having our docs translated. +# cd .. +# make gettext +# cp build/locale/*.pot babel/ +# uv run sphinx-intl update -p build/locale/ -d flask_admin/translations/ diff --git a/babel/crowdin_pull.sh b/babel/crowdin_pull.sh deleted file mode 100755 index 1168bc4efa..0000000000 --- a/babel/crowdin_pull.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/sh - -# get newest translations from Crowdin -cd ../flask_admin/translations/ -curl http://api.crowdin.net/api/project/flask-admin/export?key=`cat ~/.crowdin.flaskadmin.key` -wget http://api.crowdin.net/api/project/flask-admin/download/all.zip?key=`cat ~/.crowdin.flaskadmin.key` -O all.zip - -# unzip and move .po files in subfolders called LC_MESSAGES -unzip -o all.zip -find . -maxdepth 2 -name "*.po" -exec bash -c 'mkdir -p $(dirname {})/LC_MESSAGES; mv {} $(dirname {})/LC_MESSAGES/admin.po' \; -rm all.zip -mv es-ES/LC_MESSAGES/* es/LC_MESSAGES/ -rm -r es-ES/ -mv ca/LC_MESSAGES/* ca_ES/LC_MESSAGES/ -rm -r ca/ -mv zh-CN/LC_MESSAGES/* zh_Hans_CN/LC_MESSAGES/ -rm -r zh-CN/ -mv zh-TW/LC_MESSAGES/* zh_Hant_TW/LC_MESSAGES/ -rm -r zh-TW/ -mv pt-PT/LC_MESSAGES/* pt/LC_MESSAGES/ -rm -r pt-PT/ -mv pt-BR/LC_MESSAGES/* pt_BR/LC_MESSAGES/ -rm -r pt-BR/ -mv sv-SE/LC_MESSAGES/* sv/LC_MESSAGES/ -rm -r sv-SE/ -mv pa-IN/LC_MESSAGES/* pa/LC_MESSAGES/ -rm -r pa-IN/ - -cd ../../babel -sh babel.sh diff --git a/babel/crowdin_push.sh b/babel/crowdin_push.sh deleted file mode 100755 index e91dd9490d..0000000000 --- a/babel/crowdin_push.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -sh babel.sh -curl -F "files[/admin.pot]=@admin.pot" http://api.crowdin.net/api/project/flask-admin/update-file?key=`cat ~/.crowdin.flaskadmin.key` diff --git a/doc/_static/flask-admin.css b/doc/_static/flask-admin.css new file mode 100644 index 0000000000..19daed19e2 --- /dev/null +++ b/doc/_static/flask-admin.css @@ -0,0 +1,5 @@ +#flask-admin h1 { + text-indent: -999999px; + background: url('flask-admin.png') no-repeat center center; + height: 140px; +} diff --git a/doc/_templates/sidebarintro.html b/doc/_templates/sidebarintro.html index af7e3203f4..63a16d3881 100644 --- a/doc/_templates/sidebarintro.html +++ b/doc/_templates/sidebarintro.html @@ -1,8 +1,8 @@

Useful Links

-Fork me on GitHub +Fork me on GitHub diff --git a/doc/_themes/.gitignore b/doc/_themes/.gitignore deleted file mode 100644 index 66b6e4c2f3..0000000000 --- a/doc/_themes/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -*.pyc -*.pyo -.DS_Store diff --git a/doc/_themes/LICENSE b/doc/_themes/LICENSE deleted file mode 100755 index 8daab7ee6e..0000000000 --- a/doc/_themes/LICENSE +++ /dev/null @@ -1,37 +0,0 @@ -Copyright (c) 2010 by Armin Ronacher. - -Some rights reserved. - -Redistribution and use in source and binary forms of the theme, with or -without modification, are permitted provided that the following conditions -are met: - -* Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - -* 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. - -* The names of the contributors may not be used to endorse or - promote products derived from this software without specific - prior written permission. - -We kindly ask you to only use these themes in an unmodified manner just -for Flask and Flask-related products, not for unrelated projects. If you -like the visual style and want to use it for your own projects, please -consider making some larger changes to the themes (such as changing -font faces, sizes, colors or margins). - -THIS THEME 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 OWNER 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 THEME, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. diff --git a/doc/_themes/README b/doc/_themes/README deleted file mode 100755 index b3292bdff8..0000000000 --- a/doc/_themes/README +++ /dev/null @@ -1,31 +0,0 @@ -Flask Sphinx Styles -=================== - -This repository contains sphinx styles for Flask and Flask related -projects. To use this style in your Sphinx documentation, follow -this guide: - -1. put this folder as _themes into your docs folder. Alternatively - you can also use git submodules to check out the contents there. -2. add this to your conf.py: - - sys.path.append(os.path.abspath('_themes')) - html_theme_path = ['_themes'] - html_theme = 'flask' - -The following themes exist: - -- 'flask' - the standard flask documentation theme for large - projects -- 'flask_small' - small one-page theme. Intended to be used by - very small addon libraries for flask. - -The following options exist for the flask_small theme: - - [options] - index_logo = '' filename of a picture in _static - to be used as replacement for the - h1 in the index.rst file. - index_logo_height = 120px height of the index logo - github_fork = '' repository name on github for the - "fork me" badge diff --git a/doc/_themes/flask/layout.html b/doc/_themes/flask/layout.html deleted file mode 100755 index 19c43fbbef..0000000000 --- a/doc/_themes/flask/layout.html +++ /dev/null @@ -1,24 +0,0 @@ -{%- extends "basic/layout.html" %} -{%- block extrahead %} - {{ super() }} - {% if theme_touch_icon %} - - {% endif %} - -{% endblock %} -{%- block relbar2 %}{% endblock %} -{% block header %} - {{ super() }} - {% if pagename == 'index' %} -
- {% endif %} -{% endblock %} -{%- block footer %} - - {% if pagename == 'index' %} -
- {% endif %} -{%- endblock %} diff --git a/doc/_themes/flask/relations.html b/doc/_themes/flask/relations.html deleted file mode 100755 index 3bbcde85bb..0000000000 --- a/doc/_themes/flask/relations.html +++ /dev/null @@ -1,19 +0,0 @@ -

Related Topics

- diff --git a/doc/_themes/flask/static/flasky.css_t b/doc/_themes/flask/static/flasky.css_t deleted file mode 100755 index 0840bb57e5..0000000000 --- a/doc/_themes/flask/static/flasky.css_t +++ /dev/null @@ -1,583 +0,0 @@ -/* - * flasky.css_t - * ~~~~~~~~~~~~ - * - * :copyright: Copyright 2010 by Armin Ronacher. - * :license: Flask Design License, see LICENSE for details. - */ - -{% set page_width = '940px' %} -{% set sidebar_width = '220px' %} - -@import url("basic.css"); - -/* -- page layout ----------------------------------------------------------- */ - -body { - font-family: 'Georgia', serif; - font-size: 17px; - background-color: white; - color: #000; - margin: 0; - padding: 0; -} - -div.document { - width: {{ page_width }}; - margin: 30px auto 0 auto; -} - -div.documentwrapper { - float: left; - width: 100%; -} - -div.bodywrapper { - margin: 0 0 0 {{ sidebar_width }}; -} - -div.sphinxsidebar { - width: {{ sidebar_width }}; -} - -hr { - border: 1px solid #B1B4B6; -} - -div.body { - background-color: #ffffff; - color: #3E4349; - padding: 0 30px 0 30px; -} - -img.floatingflask { - padding: 0 0 10px 10px; - float: right; -} - -div.footer { - width: {{ page_width }}; - margin: 20px auto 30px auto; - font-size: 14px; - color: #888; - text-align: right; -} - -div.footer a { - color: #888; -} - -div.related { - display: none; -} - -div.sphinxsidebar a { - color: #444; - text-decoration: none; - border-bottom: 1px dotted #999; -} - -div.sphinxsidebar a:hover { - border-bottom: 1px solid #999; -} - -div.sphinxsidebar { - font-size: 14px; - line-height: 1.5; -} - -div.sphinxsidebarwrapper { - padding: 18px 10px; -} - -div.sphinxsidebarwrapper p.logo { - padding: 0 0 20px 0; - margin: 0; - text-align: center; -} - -div.sphinxsidebar h3, -div.sphinxsidebar h4 { - font-family: 'Garamond', 'Georgia', serif; - color: #444; - font-size: 24px; - font-weight: normal; - margin: 0 0 5px 0; - padding: 0; -} - -div.sphinxsidebar h4 { - font-size: 20px; -} - -div.sphinxsidebar h3 a { - color: #444; -} - -div.sphinxsidebar p.logo a, -div.sphinxsidebar h3 a, -div.sphinxsidebar p.logo a:hover, -div.sphinxsidebar h3 a:hover { - border: none; -} - -div.sphinxsidebar p { - color: #555; - margin: 10px 0; -} - -div.sphinxsidebar ul { - margin: 10px 0; - padding: 0; - color: #000; -} - -div.sphinxsidebar input { - border: 1px solid #ccc; - font-family: 'Georgia', serif; - font-size: 1em; -} - -/* -- body styles ----------------------------------------------------------- */ - -a { - color: #004B6B; - text-decoration: underline; -} - -a:hover { - color: #6D4100; - text-decoration: underline; -} - -div.body h1, -div.body h2, -div.body h3, -div.body h4, -div.body h5, -div.body h6 { - font-family: 'Garamond', 'Georgia', serif; - font-weight: normal; - margin: 30px 0px 10px 0px; - padding: 0; -} - -{% if theme_index_logo %} -div.indexwrapper h1 { - text-indent: -999999px; - background: url({{ theme_index_logo }}) no-repeat center center; - height: {{ theme_index_logo_height }}; -} -{% endif %} -div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } -div.body h2 { font-size: 180%; } -div.body h3 { font-size: 150%; } -div.body h4 { font-size: 130%; } -div.body h5 { font-size: 100%; } -div.body h6 { font-size: 100%; } - -a.headerlink { - color: #ddd; - padding: 0 4px; - text-decoration: none; -} - -a.headerlink:hover { - color: #444; - background: #eaeaea; -} - -div.body p, div.body dd, div.body li { - line-height: 1.4em; -} - -div.admonition { - background: #fafafa; - margin: 20px -30px; - padding: 10px 30px; - border-top: 1px solid #ccc; - border-bottom: 1px solid #ccc; -} - -div.admonition tt.xref, div.admonition a tt { - border-bottom: 1px solid #fafafa; -} - -dd div.admonition { - margin-left: -60px; - padding-left: 60px; -} - -div.admonition p.admonition-title { - font-family: 'Garamond', 'Georgia', serif; - font-weight: normal; - font-size: 24px; - margin: 0 0 10px 0; - padding: 0; - line-height: 1; -} - -div.admonition p.last { - margin-bottom: 0; -} - -div.highlight { - background-color: white; -} - -dt:target, .highlight { - background: #FAF3E8; -} - -div.note { - background-color: #eee; - border: 1px solid #ccc; -} - -div.seealso { - background-color: #ffc; - border: 1px solid #ff6; -} - -div.topic { - background-color: #eee; -} - -p.admonition-title { - display: inline; -} - -p.admonition-title:after { - content: ":"; -} - -pre, tt { - font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; - font-size: 0.9em; -} - -img.screenshot { -} - -tt.descname, tt.descclassname { - font-size: 0.95em; -} - -tt.descname { - padding-right: 0.08em; -} - -img.screenshot { - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils { - border: 1px solid #888; - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils td, table.docutils th { - border: 1px solid #888; - padding: 0.25em 0.7em; -} - -table.field-list, table.footnote { - border: none; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; -} - -table.footnote { - margin: 15px 0; - width: 100%; - border: 1px solid #eee; - background: #fdfdfd; - font-size: 0.9em; -} - -table.footnote + table.footnote { - margin-top: -15px; - border-top: none; -} - -table.field-list th { - padding: 0 0.8em 0 0; -} - -table.field-list td { - padding: 0; -} - -table.footnote td.label { - width: 0px; - padding: 0.3em 0 0.3em 0.5em; -} - -table.footnote td { - padding: 0.3em 0.5em; -} - -dl { - margin: 0; - padding: 0; -} - -dl dd { - margin-left: 30px; -} - -blockquote { - margin: 0 0 0 30px; - padding: 0; -} - -ul, ol { - margin: 10px 0 10px 30px; - padding: 0; -} - -pre { - background: #eee; - padding: 7px 30px; - margin: 15px -30px; - line-height: 1.3em; -} - -dl pre, blockquote pre, li pre { - margin-left: -60px; - padding-left: 60px; -} - -dl dl pre { - margin-left: -90px; - padding-left: 90px; -} - -tt { - background-color: #ecf0f3; - color: #222; - /* padding: 1px 2px; */ -} - -tt.xref, a tt { - background-color: #FBFBFB; - border-bottom: 1px solid white; -} - -a.reference { - text-decoration: none; - border-bottom: 1px dotted #004B6B; -} - -a.reference:hover { - border-bottom: 1px solid #6D4100; -} - -a.footnote-reference { - text-decoration: none; - font-size: 0.7em; - vertical-align: top; - border-bottom: 1px dotted #004B6B; -} - -a.footnote-reference:hover { - border-bottom: 1px solid #6D4100; -} - -a:hover tt { - background: #EEE; -} - - -@media screen and (max-width: 870px) { - - div.sphinxsidebar { - display: none; - } - - div.document { - width: 100%; - - } - - div.documentwrapper { - margin-left: 0; - margin-top: 0; - margin-right: 0; - margin-bottom: 0; - } - - div.bodywrapper { - margin-top: 0; - margin-right: 0; - margin-bottom: 0; - margin-left: 0; - } - - ul { - margin-left: 0; - } - - .document { - width: auto; - } - - .footer { - width: auto; - } - - .bodywrapper { - margin: 0; - } - - .footer { - width: auto; - } - - .github { - display: none; - } - - - -} - -@media screen and (max-width: 768px) { - {% if theme_index_logo %} - div.indexwrapper h1 { - background-size: 100%; - } - {% endif %} -} - -@media screen and (max-width: 875px) { - - body { - margin: 0; - padding: 20px 30px; - } - - div.documentwrapper { - float: none; - background: white; - } - - div.sphinxsidebar { - display: block; - float: none; - width: 102.5%; - margin: 50px -30px -20px -30px; - padding: 10px 20px; - background: #333; - color: white; - } - - div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, - div.sphinxsidebar h3 a { - color: white; - } - - div.sphinxsidebar a { - color: #aaa; - } - - div.sphinxsidebar p.logo { - display: none; - } - - div.document { - width: 100%; - margin: 0; - } - - div.related { - display: block; - margin: 0; - padding: 10px 0 20px 0; - } - - div.related ul, - div.related ul li { - margin: 0; - padding: 0; - } - - div.footer { - display: none; - } - - div.bodywrapper { - margin: 0; - } - - div.body { - min-height: 0; - padding: 0; - } - - .rtd_doc_footer { - display: none; - } - - .document { - width: auto; - } - - .footer { - width: auto; - } - - .footer { - width: auto; - } - - .github { - display: none; - } -} - - -/* scrollbars */ - -::-webkit-scrollbar { - width: 6px; - height: 6px; -} - -::-webkit-scrollbar-button:start:decrement, -::-webkit-scrollbar-button:end:increment { - display: block; - height: 10px; -} - -::-webkit-scrollbar-button:vertical:increment { - background-color: #fff; -} - -::-webkit-scrollbar-track-piece { - background-color: #eee; - -webkit-border-radius: 3px; -} - -::-webkit-scrollbar-thumb:vertical { - height: 50px; - background-color: #ccc; - -webkit-border-radius: 3px; -} - -::-webkit-scrollbar-thumb:horizontal { - width: 50px; - background-color: #ccc; - -webkit-border-radius: 3px; -} - -/* misc. */ - -.revsys-inline { - display: none!important; -} \ No newline at end of file diff --git a/doc/_themes/flask/theme.conf b/doc/_themes/flask/theme.conf deleted file mode 100755 index 16c5f8fbb0..0000000000 --- a/doc/_themes/flask/theme.conf +++ /dev/null @@ -1,10 +0,0 @@ -[theme] -inherit = basic -stylesheet = flasky.css -pygments_style = flask_theme_support.FlaskyStyle - -[options] -index_logo = 'flask-admin.png' -index_logo_height = 140px -touch_icon = -github_fork = 'flask-admin/flask-admin' \ No newline at end of file diff --git a/doc/_themes/flask_small/layout.html b/doc/_themes/flask_small/layout.html deleted file mode 100755 index aa1716aaff..0000000000 --- a/doc/_themes/flask_small/layout.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends "basic/layout.html" %} -{% block header %} - {{ super() }} - {% if pagename == 'index' %} -
- {% endif %} -{% endblock %} -{% block footer %} - {% if pagename == 'index' %} -
- {% endif %} -{% endblock %} -{# do not display relbars #} -{% block relbar1 %}{% endblock %} -{% block relbar2 %} - {% if theme_github_fork %} - Fork me on GitHub - {% endif %} -{% endblock %} -{% block sidebar1 %}{% endblock %} -{% block sidebar2 %}{% endblock %} diff --git a/doc/_themes/flask_small/static/flasky.css_t b/doc/_themes/flask_small/static/flasky.css_t deleted file mode 100755 index fe2141c565..0000000000 --- a/doc/_themes/flask_small/static/flasky.css_t +++ /dev/null @@ -1,287 +0,0 @@ -/* - * flasky.css_t - * ~~~~~~~~~~~~ - * - * Sphinx stylesheet -- flasky theme based on nature theme. - * - * :copyright: Copyright 2007-2010 by the Sphinx team, see AUTHORS. - * :license: BSD, see LICENSE for details. - * - */ - -@import url("basic.css"); - -/* -- page layout ----------------------------------------------------------- */ - -body { - font-family: 'Georgia', serif; - font-size: 17px; - color: #000; - background: white; - margin: 0; - padding: 0; -} - -div.documentwrapper { - float: left; - width: 100%; -} - -div.bodywrapper { - margin: 40px auto 0 auto; - width: 700px; -} - -hr { - border: 1px solid #B1B4B6; -} - -div.body { - background-color: #ffffff; - color: #3E4349; - padding: 0 30px 30px 30px; -} - -img.floatingflask { - padding: 0 0 10px 10px; - float: right; -} - -div.footer { - text-align: right; - color: #888; - padding: 10px; - font-size: 14px; - width: 650px; - margin: 0 auto 40px auto; -} - -div.footer a { - color: #888; - text-decoration: underline; -} - -div.related { - line-height: 32px; - color: #888; -} - -div.related ul { - padding: 0 0 0 10px; -} - -div.related a { - color: #444; -} - -/* -- body styles ----------------------------------------------------------- */ - -a { - color: #004B6B; - text-decoration: underline; -} - -a:hover { - color: #6D4100; - text-decoration: underline; -} - -div.body { - padding-bottom: 40px; /* saved for footer */ -} - -div.body h1, -div.body h2, -div.body h3, -div.body h4, -div.body h5, -div.body h6 { - font-family: 'Garamond', 'Georgia', serif; - font-weight: normal; - margin: 30px 0px 10px 0px; - padding: 0; -} - -{% if theme_index_logo %} -div.indexwrapper h1 { - text-indent: -999999px; - background: url({{ theme_index_logo }}) no-repeat center center; - height: {{ theme_index_logo_height }}; -} -{% endif %} - -div.body h2 { font-size: 180%; } -div.body h3 { font-size: 150%; } -div.body h4 { font-size: 130%; } -div.body h5 { font-size: 100%; } -div.body h6 { font-size: 100%; } - -a.headerlink { - color: white; - padding: 0 4px; - text-decoration: none; -} - -a.headerlink:hover { - color: #444; - background: #eaeaea; -} - -div.body p, div.body dd, div.body li { - line-height: 1.4em; -} - -div.admonition { - background: #fafafa; - margin: 20px -30px; - padding: 10px 30px; - border-top: 1px solid #ccc; - border-bottom: 1px solid #ccc; -} - -div.admonition p.admonition-title { - font-family: 'Garamond', 'Georgia', serif; - font-weight: normal; - font-size: 24px; - margin: 0 0 10px 0; - padding: 0; - line-height: 1; -} - -div.admonition p.last { - margin-bottom: 0; -} - -div.highlight{ - background-color: white; -} - -dt:target, .highlight { - background: #FAF3E8; -} - -div.note { - background-color: #eee; - border: 1px solid #ccc; -} - -div.seealso { - background-color: #ffc; - border: 1px solid #ff6; -} - -div.topic { - background-color: #eee; -} - -div.warning { - background-color: #ffe4e4; - border: 1px solid #f66; -} - -p.admonition-title { - display: inline; -} - -p.admonition-title:after { - content: ":"; -} - -pre, tt { - font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; - font-size: 0.85em; -} - -img.screenshot { -} - -tt.descname, tt.descclassname { - font-size: 0.95em; -} - -tt.descname { - padding-right: 0.08em; -} - -img.screenshot { - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils { - border: 1px solid #888; - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils td, table.docutils th { - border: 1px solid #888; - padding: 0.25em 0.7em; -} - -table.field-list, table.footnote { - border: none; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; -} - -table.footnote { - margin: 15px 0; - width: 100%; - border: 1px solid #eee; -} - -table.field-list th { - padding: 0 0.8em 0 0; -} - -table.field-list td { - padding: 0; -} - -table.footnote td { - padding: 0.5em; -} - -dl { - margin: 0; - padding: 0; -} - -dl dd { - margin-left: 30px; -} - -pre { - padding: 0; - margin: 15px -30px; - padding: 8px; - line-height: 1.3em; - padding: 7px 30px; - background: #eee; - border-radius: 2px; - -moz-border-radius: 2px; - -webkit-border-radius: 2px; -} - -dl pre { - margin-left: -60px; - padding-left: 60px; -} - -tt { - background-color: #ecf0f3; - color: #222; - /* padding: 1px 2px; */ -} - -tt.xref, a tt { - background-color: #FBFBFB; -} - -a:hover tt { - background: #EEE; -} diff --git a/doc/_themes/flask_small/theme.conf b/doc/_themes/flask_small/theme.conf deleted file mode 100755 index 542b462515..0000000000 --- a/doc/_themes/flask_small/theme.conf +++ /dev/null @@ -1,10 +0,0 @@ -[theme] -inherit = basic -stylesheet = flasky.css -nosidebar = true -pygments_style = flask_theme_support.FlaskyStyle - -[options] -index_logo = '' -index_logo_height = 120px -github_fork = '' diff --git a/doc/_themes/flask_theme_support.py b/doc/_themes/flask_theme_support.py deleted file mode 100755 index 33f47449c1..0000000000 --- a/doc/_themes/flask_theme_support.py +++ /dev/null @@ -1,86 +0,0 @@ -# flasky extensions. flasky pygments style based on tango style -from pygments.style import Style -from pygments.token import Keyword, Name, Comment, String, Error, \ - Number, Operator, Generic, Whitespace, Punctuation, Other, Literal - - -class FlaskyStyle(Style): - background_color = "#f8f8f8" - default_style = "" - - styles = { - # No corresponding class for the following: - #Text: "", # class: '' - Whitespace: "underline #f8f8f8", # class: 'w' - Error: "#a40000 border:#ef2929", # class: 'err' - Other: "#000000", # class 'x' - - Comment: "italic #8f5902", # class: 'c' - Comment.Preproc: "noitalic", # class: 'cp' - - Keyword: "bold #004461", # class: 'k' - Keyword.Constant: "bold #004461", # class: 'kc' - Keyword.Declaration: "bold #004461", # class: 'kd' - Keyword.Namespace: "bold #004461", # class: 'kn' - Keyword.Pseudo: "bold #004461", # class: 'kp' - Keyword.Reserved: "bold #004461", # class: 'kr' - Keyword.Type: "bold #004461", # class: 'kt' - - Operator: "#582800", # class: 'o' - Operator.Word: "bold #004461", # class: 'ow' - like keywords - - Punctuation: "bold #000000", # class: 'p' - - # because special names such as Name.Class, Name.Function, etc. - # are not recognized as such later in the parsing, we choose them - # to look the same as ordinary variables. - Name: "#000000", # class: 'n' - Name.Attribute: "#c4a000", # class: 'na' - to be revised - Name.Builtin: "#004461", # class: 'nb' - Name.Builtin.Pseudo: "#3465a4", # class: 'bp' - Name.Class: "#000000", # class: 'nc' - to be revised - Name.Constant: "#000000", # class: 'no' - to be revised - Name.Decorator: "#888", # class: 'nd' - to be revised - Name.Entity: "#ce5c00", # class: 'ni' - Name.Exception: "bold #cc0000", # class: 'ne' - Name.Function: "#000000", # class: 'nf' - Name.Property: "#000000", # class: 'py' - Name.Label: "#f57900", # class: 'nl' - Name.Namespace: "#000000", # class: 'nn' - to be revised - Name.Other: "#000000", # class: 'nx' - Name.Tag: "bold #004461", # class: 'nt' - like a keyword - Name.Variable: "#000000", # class: 'nv' - to be revised - Name.Variable.Class: "#000000", # class: 'vc' - to be revised - Name.Variable.Global: "#000000", # class: 'vg' - to be revised - Name.Variable.Instance: "#000000", # class: 'vi' - to be revised - - Number: "#990000", # class: 'm' - - Literal: "#000000", # class: 'l' - Literal.Date: "#000000", # class: 'ld' - - String: "#4e9a06", # class: 's' - String.Backtick: "#4e9a06", # class: 'sb' - String.Char: "#4e9a06", # class: 'sc' - String.Doc: "italic #8f5902", # class: 'sd' - like a comment - String.Double: "#4e9a06", # class: 's2' - String.Escape: "#4e9a06", # class: 'se' - String.Heredoc: "#4e9a06", # class: 'sh' - String.Interpol: "#4e9a06", # class: 'si' - String.Other: "#4e9a06", # class: 'sx' - String.Regex: "#4e9a06", # class: 'sr' - String.Single: "#4e9a06", # class: 's1' - String.Symbol: "#4e9a06", # class: 'ss' - - Generic: "#000000", # class: 'g' - Generic.Deleted: "#a40000", # class: 'gd' - Generic.Emph: "italic #000000", # class: 'ge' - Generic.Error: "#ef2929", # class: 'gr' - Generic.Heading: "bold #000080", # class: 'gh' - Generic.Inserted: "#00A000", # class: 'gi' - Generic.Output: "#888", # class: 'go' - Generic.Prompt: "#745334", # class: 'gp' - Generic.Strong: "bold #000000", # class: 'gs' - Generic.Subheading: "bold #800080", # class: 'gu' - Generic.Traceback: "bold #a40000", # class: 'gt' - } diff --git a/doc/advanced.rst b/doc/advanced.rst index 83208f44e5..2f3dcba10c 100644 --- a/doc/advanced.rst +++ b/doc/advanced.rst @@ -18,52 +18,133 @@ 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 -------------------------------- +CSP support +----------- -**** +To support `CSP `_ +in Flask-Admin, you can pass a `csp_nonce_generator` function through to Flask-Admin on +initialisation. This function should return a CSP nonce that will be attached to all +` +{% endblock %} diff --git a/examples/custom_layout/templates/admin/index.html b/examples/custom_layout/templates/admin/index.html new file mode 100644 index 0000000000..975989e137 --- /dev/null +++ b/examples/custom_layout/templates/admin/index.html @@ -0,0 +1,51 @@ +{% extends 'admin/master.html' %} + +{% block body %} +
+
+
+
+
Users
+

{{ user_count or 0 }}

+ +
+
+
+ +
+
+
+
Sales
+

$ {{ sales_sum }}

+ +
+
+
+ +
+
+
+
Pages
+

{{ pages_count or 0 }}

+ +
+
+
+ +
+
+ +
+
+
+
Dashboard Note
+

+ This is an Example Custom Layout. +

+
+ +

+

+
+
+{% endblock %} diff --git a/examples/custom_layout/templates/create.html b/examples/custom_layout/templates/create.html new file mode 100644 index 0000000000..938de84d27 --- /dev/null +++ b/examples/custom_layout/templates/create.html @@ -0,0 +1,34 @@ +{% extends 'admin/model/create.html' %} + +{% block body %} +
+
+
+

New {{ admin_view.name }}

+ +
+
+ +
+
+
Entry Details
+
+
+ {{ super() }} +
+
+
+ + +{% endblock %} diff --git a/examples/custom_layout/templates/edit.html b/examples/custom_layout/templates/edit.html new file mode 100644 index 0000000000..30e1957ad3 --- /dev/null +++ b/examples/custom_layout/templates/edit.html @@ -0,0 +1,8 @@ +{% extends 'admin/model/edit.html' %} + +{% block navlinks %} + {{ super() }} + +{% endblock %} diff --git a/examples/custom_layout/templates/list.html b/examples/custom_layout/templates/list.html new file mode 100644 index 0000000000..25684a535b --- /dev/null +++ b/examples/custom_layout/templates/list.html @@ -0,0 +1,7 @@ +{% extends 'admin/model/list.html' %} + +{% block model_menu_bar %} +
Viewing all {{ admin_view.name }} records +
+ {{ super() }} +{% endblock %} diff --git a/examples/datetime_timezone/.python-version b/examples/datetime_timezone/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/datetime_timezone/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/datetime_timezone/README.md b/examples/datetime_timezone/README.md new file mode 100644 index 0000000000..81129ecfda --- /dev/null +++ b/examples/datetime_timezone/README.md @@ -0,0 +1,20 @@ +# Datetime Timezone Example + +This example shows how to make Flask-Admin display all datetime fields in client's timezone. Timezone conversion is handled by the frontend in /static/js/timezone.js, but an automatic post request to /set_timezone is done so that flask session can store the client's timezone and save datetime inputs in the correct timezone. + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/datetime-timezone +``` + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` diff --git a/examples/datetime_timezone/__init__.py b/examples/datetime_timezone/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/datetime_timezone/main.py b/examples/datetime_timezone/main.py new file mode 100644 index 0000000000..a6294a7dea --- /dev/null +++ b/examples/datetime_timezone/main.py @@ -0,0 +1,131 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from flask import Flask +from flask import jsonify +from flask import request +from flask import session +from flask_admin import Admin +from flask_admin.contrib.sqla import ModelView +from flask_admin.model import typefmt +from flask_sqlalchemy_lite import SQLAlchemy +from markupsafe import Markup +from sqlalchemy import DateTime +from sqlalchemy import String +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column + +app = Flask(__name__) +app.config["SECRET_KEY"] = "secret" +app.config["SQLALCHEMY_ENGINES"] = {"default": "sqlite:///:memory:"} +db = SQLAlchemy() +db.init_app(app) +admin = Admin(app, name="Example: Datetime and Timezone") + + +class Base(DeclarativeBase): + pass + + +@app.route("/") +def index(): + return 'Click me to get to Admin!' + + +@app.route("/set_timezone", methods=["POST"]) +def set_timezone(): + """ + Save timezone to session so that datetime inputs can be correctly converted to UTC. + """ + session.permanent = True + timezone = request.get_json() + if timezone: + session["timezone"] = timezone + return jsonify({"message": "Timezone set successfully"}), 200 + else: + return jsonify({"error": "Invalid timezone"}), 400 + + +class Article(Base): + __tablename__ = "article" + id: Mapped[int] = mapped_column(primary_key=True) + text: Mapped[str] = mapped_column(String(30)) + last_edit: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + +def date_format(view, value, name): + """ + Ensure consistent date format and inject class for timezone.js parser. + """ + if value is None: + return "" + return Markup( + f'{value.strftime("%Y-%m-%d %H:%M:%S")}' + ) + + +MY_DEFAULT_FORMATTERS = dict(typefmt.BASE_FORMATTERS) +MY_DEFAULT_FORMATTERS.update( + { + datetime: date_format, + } +) + + +class TimezoneAwareModelView(ModelView): + column_type_formatters = MY_DEFAULT_FORMATTERS + extra_js = ["/static/js/timezone.js"] + + def on_model_change(self, form, model, is_created): + """ + Save datetime fields after converting from session['timezone'] to UTC. + """ + user_timezone = session["timezone"] + + for field_name, field_value in form.data.items(): + if isinstance(field_value, datetime): + # Convert naive datetime to timezone-aware datetime + aware_time = field_value.replace(tzinfo=ZoneInfo(user_timezone)) + + # Convert the time to UTC + utc_time = aware_time.astimezone(ZoneInfo("UTC")) + + # Assign the UTC time to the model + setattr(model, field_name, utc_time) + + super().on_model_change(form, model, is_created) + + +# inherit TimeZoneAwareModelView to make any admin page timezone-aware +class TimezoneAwareBlogModelView(TimezoneAwareModelView): + column_labels = { + "last_edit": "Last Edit (local time)", + } + + +# compare with regular ModelView to display data as saved on db +class BlogModelView(ModelView): + column_labels = { + "last_edit": "Last Edit (UTC)", + } + + +if __name__ == "__main__": + with app.app_context(): + Base.metadata.drop_all(db.engine) + Base.metadata.create_all(db.engine) + db.session.add( + Article(text="Written at 9:00 UTC", last_edit=datetime(2024, 8, 8, 9, 0, 0)) + ) + db.session.commit() + admin.add_view(BlogModelView(Article, db, name="Article", endpoint="article")) + admin.add_view( + TimezoneAwareBlogModelView( + Article, + db, + name="Timezone Aware Article", + endpoint="timezone_aware_article", + ) + ) + app.run(debug=True) diff --git a/examples/datetime_timezone/pyproject.toml b/examples/datetime_timezone/pyproject.toml new file mode 100644 index 0000000000..c31c668f28 --- /dev/null +++ b/examples/datetime_timezone/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "example-datetime-timezone" +version = "0.1.0" +description = "Datetime Timezone Example." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin[sqlalchemy-lite]", + "sqlalchemy>=2.0", +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/examples/datetime_timezone/static/js/timezone.js b/examples/datetime_timezone/static/js/timezone.js new file mode 100644 index 0000000000..71004669eb --- /dev/null +++ b/examples/datetime_timezone/static/js/timezone.js @@ -0,0 +1,38 @@ +// post client's timezone so that backend can correctly convert datetime inputs to UTC +fetch('/set_timezone', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(Intl.DateTimeFormat().resolvedOptions().timeZone) +}) + +// convert all datetime fields to client timezone +function localizeDateTimes() { + const inputsOrSpans = document.querySelectorAll('input[data-date-format], span.timezone-aware'); + + inputsOrSpans.forEach(element => { + const isInput = element.tagName.toLowerCase() === 'input'; + const rawValue = isInput + ? element.getAttribute("value") + : element.textContent.trim(); + + // Skip empty, missing, or Python "None" values + if (!rawValue || rawValue === 'None') return; + + const localizedTime = new Date(rawValue + "Z"); + + // Skip if the date parsed as invalid + if (isNaN(localizedTime.getTime())) return; + + const formattedTime = moment(localizedTime).format('YYYY-MM-DD HH:mm:ss'); + + if (isInput) { + element.setAttribute("value", formattedTime); + } else { + element.textContent = formattedTime; + } + }); +} + +localizeDateTimes(); diff --git a/examples/forms-files-images/README.rst b/examples/forms-files-images/README.rst deleted file mode 100644 index 9b4038d27f..0000000000 --- a/examples/forms-files-images/README.rst +++ /dev/null @@ -1,34 +0,0 @@ -This example shows how you can:: - - * define your own custom forms by using form rendering rules - * handle generic static file uploads - * handle image uploads - * turn a TextArea field into a rich WYSIWYG editor using WTForms and CKEditor - * set up a Flask-Admin view as a Redis terminal - - -To run this example: - -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 - source env/bin/activate - -3. Install requirements:: - - pip install -r 'examples/forms-files-images/requirements.txt' - -4. Run the application:: - - python examples/forms-files-images/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::: - - if not os.path.exists(database_path): - build_sample_db() diff --git a/examples/forms-files-images/__init__.py b/examples/forms-files-images/__init__.py deleted file mode 100644 index bc6af4c909..0000000000 --- a/examples/forms-files-images/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__author__ = 'petrus' diff --git a/examples/forms-files-images/app.py b/examples/forms-files-images/app.py deleted file mode 100644 index d83cd5cf4c..0000000000 --- a/examples/forms-files-images/app.py +++ /dev/null @@ -1,295 +0,0 @@ -import os -import os.path as op - -from flask import Flask, url_for -from flask_sqlalchemy import SQLAlchemy -from redis import Redis -from wtforms import fields, widgets - -from sqlalchemy.event import listens_for -from jinja2 import Markup - -from flask_admin import Admin, form -from flask_admin.form import rules -from flask_admin.contrib import sqla, rediscli - - -# Create application -app = Flask(__name__, static_folder='files') - -# set optional bootswatch theme -# see http://bootswatch.com/3/ for available swatches -app.config['FLASK_ADMIN_SWATCH'] = 'cerulean' - -# Create dummy secrey key so we can use sessions -app.config['SECRET_KEY'] = '123456790' - -# Create in-memory database -app.config['DATABASE_FILE'] = 'sample_db.sqlite' -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + app.config['DATABASE_FILE'] -app.config['SQLALCHEMY_ECHO'] = True -db = SQLAlchemy(app) - -# Create directory for file fields to use -file_path = op.join(op.dirname(__file__), 'files') -try: - os.mkdir(file_path) -except OSError: - pass - - -# Create models -class File(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.Unicode(64)) - path = db.Column(db.Unicode(128)) - - def __unicode__(self): - return self.name - - -class Image(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.Unicode(64)) - path = db.Column(db.Unicode(128)) - - def __unicode__(self): - return self.name - - -class User(db.Model): - id = db.Column(db.Integer, primary_key=True) - first_name = db.Column(db.Unicode(64)) - last_name = db.Column(db.Unicode(64)) - email = db.Column(db.Unicode(128)) - phone = db.Column(db.Unicode(32)) - city = db.Column(db.Unicode(128)) - country = db.Column(db.Unicode(128)) - notes = db.Column(db.UnicodeText) - - -class Page(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.Unicode(64)) - text = db.Column(db.UnicodeText) - - def __unicode__(self): - return self.name - - -# Delete hooks for models, delete files if models are getting deleted -@listens_for(File, 'after_delete') -def del_file(mapper, connection, target): - if target.path: - try: - os.remove(op.join(file_path, target.path)) - except OSError: - # Don't care if was not deleted because it does not exist - pass - - -@listens_for(Image, 'after_delete') -def del_image(mapper, connection, target): - if target.path: - # Delete image - try: - os.remove(op.join(file_path, target.path)) - except OSError: - pass - - # Delete thumbnail - try: - os.remove(op.join(file_path, - form.thumbgen_filename(target.path))) - except OSError: - pass - - -# define a custom wtforms widget and field. -# see https://wtforms.readthedocs.io/en/latest/widgets.html#custom-widgets -class CKTextAreaWidget(widgets.TextArea): - def __call__(self, field, **kwargs): - # add WYSIWYG class to existing classes - existing_classes = kwargs.pop('class', '') or kwargs.pop('class_', '') - kwargs['class'] = '{} {}'.format(existing_classes, "ckeditor") - return super(CKTextAreaWidget, self).__call__(field, **kwargs) - - -class CKTextAreaField(fields.TextAreaField): - widget = CKTextAreaWidget() - - -# Administrative views -class PageView(sqla.ModelView): - form_overrides = { - 'text': CKTextAreaField - } - create_template = 'create_page.html' - edit_template = 'edit_page.html' - -class FileView(sqla.ModelView): - # Override form field to use Flask-Admin FileUploadField - form_overrides = { - 'path': form.FileUploadField - } - - # Pass additional parameters to 'path' to FileUploadField constructor - form_args = { - 'path': { - 'label': 'File', - 'base_path': file_path, - 'allow_overwrite': False - } - } - - -class ImageView(sqla.ModelView): - def _list_thumbnail(view, context, model, name): - if not model.path: - return '' - - return Markup('' % url_for('static', - filename=form.thumbgen_filename(model.path))) - - column_formatters = { - 'path': _list_thumbnail - } - - # Alternative way to contribute field is to override it completely. - # In this case, Flask-Admin won't attempt to merge various parameters for the field. - form_extra_fields = { - 'path': form.ImageUploadField('Image', - base_path=file_path, - thumbnail_size=(100, 100, True)) - } - - -class UserView(sqla.ModelView): - """ - This class demonstrates the use of 'rules' for controlling the rendering of forms. - """ - form_create_rules = [ - # Header and four fields. Email field will go above phone field. - rules.FieldSet(('first_name', 'last_name', 'email', 'phone'), 'Personal'), - # Separate header and few fields - rules.Header('Location'), - rules.Field('city'), - # String is resolved to form field, so there's no need to explicitly use `rules.Field` - 'country', - # Show macro that's included in the templates - rules.Container('rule_demo.wrap', rules.Field('notes')) - ] - - # Use same rule set for edit page - form_edit_rules = form_create_rules - - create_template = 'create_user.html' - edit_template = 'edit_user.html' - - -# Flask views -@app.route('/') -def index(): - return 'Click me to get to Admin!' - -# Create admin -admin = Admin(app, 'Example: Forms', template_mode='bootstrap4') - -# Add views -admin.add_view(FileView(File, db.session)) -admin.add_view(ImageView(Image, db.session)) -admin.add_view(UserView(User, db.session)) -admin.add_view(PageView(Page, db.session)) -admin.add_view(rediscli.RedisCli(Redis())) - - -def build_sample_db(): - """ - Populate a small db with some example entries. - """ - - import random - import string - - db.drop_all() - db.create_all() - - first_names = [ - 'Harry', 'Amelia', 'Oliver', 'Jack', 'Isabella', 'Charlie','Sophie', 'Mia', - 'Jacob', 'Thomas', 'Emily', 'Lily', 'Ava', 'Isla', 'Alfie', 'Olivia', 'Jessica', - 'Riley', 'William', 'James', 'Geoffrey', 'Lisa', 'Benjamin', 'Stacey', 'Lucy' - ] - last_names = [ - 'Brown', 'Smith', 'Patel', 'Jones', 'Williams', 'Johnson', 'Taylor', 'Thomas', - 'Roberts', 'Khan', 'Lewis', 'Jackson', 'Clarke', 'James', 'Phillips', 'Wilson', - 'Ali', 'Mason', 'Mitchell', 'Rose', 'Davis', 'Davies', 'Rodriguez', 'Cox', 'Alexander' - ] - locations = [ - ("Shanghai", "China"), - ("Istanbul", "Turkey"), - ("Karachi", "Pakistan"), - ("Mumbai", "India"), - ("Moscow", "Russia"), - ("Sao Paulo", "Brazil"), - ("Beijing", "China"), - ("Tianjin", "China"), - ("Guangzhou", "China"), - ("Delhi", "India"), - ("Seoul", "South Korea"), - ("Shenzhen", "China"), - ("Jakarta", "Indonesia"), - ("Tokyo", "Japan"), - ("Mexico City", "Mexico"), - ("Kinshasa", "Democratic Republic of the Congo"), - ("Bangalore", "India"), - ("New York City", "United States"), - ("London", "United Kingdom"), - ("Bangkok", "Thailand"), - ("Tehran", "Iran"), - ("Dongguan", "China"), - ("Lagos", "Nigeria"), - ("Lima", "Peru"), - ("Ho Chi Minh City", "Vietnam"), - ] - - for i in range(len(first_names)): - user = User() - user.first_name = first_names[i] - user.last_name = last_names[i] - user.email = user.first_name.lower() + "@example.com" - tmp = ''.join(random.choice(string.digits) for i in range(10)) - user.phone = "(" + tmp[0:3] + ") " + tmp[3:6] + " " + tmp[6::] - user.city = locations[i][0] - user.country = locations[i][1] - db.session.add(user) - - images = ["Buffalo", "Elephant", "Leopard", "Lion", "Rhino"] - for name in images: - image = Image() - image.name = name - image.path = name.lower() + ".jpg" - db.session.add(image) - - for i in [1, 2, 3]: - file = File() - file.name = "Example " + str(i) - file.path = "example_" + str(i) + ".pdf" - db.session.add(file) - - sample_text = "

This is a test

" + \ - "

Create HTML content in a text area field with the help of WTForms and CKEditor.

" - db.session.add(Page(name="Test Page", text=sample_text)) - - db.session.commit() - return - -if __name__ == '__main__': - - # Build a sample db on the fly, if one does not exist yet. - app_dir = op.realpath(os.path.dirname(__file__)) - database_path = op.join(app_dir, app.config['DATABASE_FILE']) - if not os.path.exists(database_path): - build_sample_db() - - # Start app - app.run(debug=True) diff --git a/examples/forms-files-images/files/example_1.pdf b/examples/forms-files-images/files/example_1.pdf deleted file mode 100644 index 5a7bb22c9d..0000000000 Binary files a/examples/forms-files-images/files/example_1.pdf and /dev/null differ diff --git a/examples/forms-files-images/requirements.txt b/examples/forms-files-images/requirements.txt deleted file mode 100644 index 8c069c1102..0000000000 --- a/examples/forms-files-images/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -Flask -Flask-Admin -Flask-SQLAlchemy -pillow -redis diff --git a/examples/forms_files_images/.python-version b/examples/forms_files_images/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/forms_files_images/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/forms_files_images/README.md b/examples/forms_files_images/README.md new file mode 100644 index 0000000000..d7725612e1 --- /dev/null +++ b/examples/forms_files_images/README.md @@ -0,0 +1,25 @@ +# Forms Files and Images Example + +This example shows how you can: + +* define your own custom forms by using form rendering rules +* handle generic static file uploads +* handle image uploads +* turn a TextArea field into a rich WYSIWYG editor using WTForms and CKEditor + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/forms-files-images +``` + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` diff --git a/examples/forms_files_images/__init__.py b/examples/forms_files_images/__init__.py new file mode 100644 index 0000000000..ee2c5c6feb --- /dev/null +++ b/examples/forms_files_images/__init__.py @@ -0,0 +1 @@ +__author__ = "petrus" diff --git a/examples/forms_files_images/data.py b/examples/forms_files_images/data.py new file mode 100644 index 0000000000..b60ab02bc1 --- /dev/null +++ b/examples/forms_files_images/data.py @@ -0,0 +1,81 @@ +first_names = [ + "Harry", + "Amelia", + "Oliver", + "Jack", + "Isabella", + "Charlie", + "Sophie", + "Mia", + "Jacob", + "Thomas", + "Emily", + "Lily", + "Ava", + "Isla", + "Alfie", + "Olivia", + "Jessica", + "Riley", + "William", + "James", + "Geoffrey", + "Lisa", + "Benjamin", + "Stacey", + "Lucy", +] +last_names = [ + "Brown", + "Smith", + "Patel", + "Jones", + "Williams", + "Johnson", + "Taylor", + "Thomas", + "Roberts", + "Khan", + "Lewis", + "Jackson", + "Clarke", + "James", + "Phillips", + "Wilson", + "Ali", + "Mason", + "Mitchell", + "Rose", + "Davis", + "Davies", + "Rodriguez", + "Cox", + "Alexander", +] +locations = [ + ("Shanghai", "China"), + ("Istanbul", "Turkey"), + ("Karachi", "Pakistan"), + ("Mumbai", "India"), + ("Moscow", "Russia"), + ("Sao Paulo", "Brazil"), + ("Beijing", "China"), + ("Tianjin", "China"), + ("Guangzhou", "China"), + ("Delhi", "India"), + ("Seoul", "South Korea"), + ("Shenzhen", "China"), + ("Jakarta", "Indonesia"), + ("Tokyo", "Japan"), + ("Mexico City", "Mexico"), + ("Kinshasa", "Democratic Republic of the Congo"), + ("Bangalore", "India"), + ("New York City", "United States"), + ("London", "United Kingdom"), + ("Bangkok", "Thailand"), + ("Tehran", "Iran"), + ("Dongguan", "China"), + ("Lagos", "Nigeria"), + ("Lima", "Peru"), + ("Ho Chi Minh City", "Vietnam"), +] diff --git a/examples/forms-files-images/files/3d364b7a-7ccf-4a08-b362-a9d2a3b8cf05.jpg b/examples/forms_files_images/files/3d364b7a-7ccf-4a08-b362-a9d2a3b8cf05.jpg similarity index 100% rename from examples/forms-files-images/files/3d364b7a-7ccf-4a08-b362-a9d2a3b8cf05.jpg rename to examples/forms_files_images/files/3d364b7a-7ccf-4a08-b362-a9d2a3b8cf05.jpg diff --git a/examples/forms-files-images/files/3d364b7a-7ccf-4a08-b362-a9d2a3b8cf05_thumb.jpg b/examples/forms_files_images/files/3d364b7a-7ccf-4a08-b362-a9d2a3b8cf05_thumb.jpg similarity index 100% rename from examples/forms-files-images/files/3d364b7a-7ccf-4a08-b362-a9d2a3b8cf05_thumb.jpg rename to examples/forms_files_images/files/3d364b7a-7ccf-4a08-b362-a9d2a3b8cf05_thumb.jpg diff --git a/examples/forms-files-images/files/buffalo.jpg b/examples/forms_files_images/files/buffalo.jpg similarity index 100% rename from examples/forms-files-images/files/buffalo.jpg rename to examples/forms_files_images/files/buffalo.jpg diff --git a/examples/forms-files-images/files/buffalo_thumb.jpg b/examples/forms_files_images/files/buffalo_thumb.jpg similarity index 100% rename from examples/forms-files-images/files/buffalo_thumb.jpg rename to examples/forms_files_images/files/buffalo_thumb.jpg diff --git a/examples/forms-files-images/files/elephant.jpg b/examples/forms_files_images/files/elephant.jpg similarity index 100% rename from examples/forms-files-images/files/elephant.jpg rename to examples/forms_files_images/files/elephant.jpg diff --git a/examples/forms-files-images/files/elephant_thumb.jpg b/examples/forms_files_images/files/elephant_thumb.jpg similarity index 100% rename from examples/forms-files-images/files/elephant_thumb.jpg rename to examples/forms_files_images/files/elephant_thumb.jpg diff --git a/examples/forms-files-images/files/example_2.pdf b/examples/forms_files_images/files/example_2.pdf similarity index 100% rename from examples/forms-files-images/files/example_2.pdf rename to examples/forms_files_images/files/example_2.pdf diff --git a/examples/forms-files-images/files/example_3.pdf b/examples/forms_files_images/files/example_3.pdf similarity index 100% rename from examples/forms-files-images/files/example_3.pdf rename to examples/forms_files_images/files/example_3.pdf diff --git a/examples/forms-files-images/files/leopard.jpg b/examples/forms_files_images/files/leopard.jpg similarity index 100% rename from examples/forms-files-images/files/leopard.jpg rename to examples/forms_files_images/files/leopard.jpg diff --git a/examples/forms-files-images/files/leopard_thumb.jpg b/examples/forms_files_images/files/leopard_thumb.jpg similarity index 100% rename from examples/forms-files-images/files/leopard_thumb.jpg rename to examples/forms_files_images/files/leopard_thumb.jpg diff --git a/examples/forms-files-images/files/lion.jpg b/examples/forms_files_images/files/lion.jpg similarity index 100% rename from examples/forms-files-images/files/lion.jpg rename to examples/forms_files_images/files/lion.jpg diff --git a/examples/forms-files-images/files/lion_thumb.jpg b/examples/forms_files_images/files/lion_thumb.jpg similarity index 100% rename from examples/forms-files-images/files/lion_thumb.jpg rename to examples/forms_files_images/files/lion_thumb.jpg diff --git a/examples/forms-files-images/files/rhino.jpg b/examples/forms_files_images/files/rhino.jpg similarity index 100% rename from examples/forms-files-images/files/rhino.jpg rename to examples/forms_files_images/files/rhino.jpg diff --git a/examples/forms-files-images/files/rhino_thumb.jpg b/examples/forms_files_images/files/rhino_thumb.jpg similarity index 100% rename from examples/forms-files-images/files/rhino_thumb.jpg rename to examples/forms_files_images/files/rhino_thumb.jpg diff --git a/examples/forms_files_images/main.py b/examples/forms_files_images/main.py new file mode 100644 index 0000000000..b58a3b271c --- /dev/null +++ b/examples/forms_files_images/main.py @@ -0,0 +1,258 @@ +import os +import os.path as op +import typing as t + +import jinja2.runtime +from flask import Flask +from flask import url_for +from flask_admin import Admin +from flask_admin import form +from flask_admin.contrib.sqla import ModelView +from flask_admin.form import rules +from flask_admin.theme import Bootstrap4Theme +from flask_sqlalchemy import SQLAlchemy +from markupsafe import Markup +from sqlalchemy import Boolean +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy import Text +from sqlalchemy.event import listens_for +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from wtforms import fields +from wtforms import widgets + +from examples.forms_files_images.data import first_names +from examples.forms_files_images.data import last_names +from examples.forms_files_images.data import locations + +app = Flask(__name__, static_folder="files") +app.config["SECRET_KEY"] = "secret" +app.config["DATABASE_FILE"] = "db.sqlite" +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + app.config["DATABASE_FILE"] +app.config["SQLALCHEMY_ECHO"] = False +db = SQLAlchemy(app) +admin = Admin(app, name="Example: Forms", theme=Bootstrap4Theme(swatch="cerulean")) + + +@app.route("/") +def index(): + return 'Click me to get to Admin!' + + +# Create directory for file fields to use +file_path = op.join(op.dirname(__file__), "files") +try: + os.mkdir(file_path) +except OSError: + pass + + +class File(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64)) + path = mapped_column(String(128)) + + def __String__(self): + return self.name + + +class Image(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64)) + path: Mapped[str] = mapped_column(String(128)) + + def __String__(self): + return self.name + + +class User(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + first_name: Mapped[str] = mapped_column(String(64)) + last_name: Mapped[str] = mapped_column(String(64)) + email: Mapped[str] = mapped_column(String(128)) + phone: Mapped[str] = mapped_column(String(32)) + city: Mapped[str] = mapped_column(String(128)) + country: Mapped[str] = mapped_column(String(128)) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) + + +class Page(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64)) + text: Mapped[str] = mapped_column(Text) + + def __String__(self): + return self.name + + +# Delete hooks for models, delete files if models are getting deleted +@listens_for(File, "after_delete") +def del_file(mapper, connection, target): + if target.path: + try: + os.remove(op.join(file_path, target.path)) + except OSError: + # Don't care if was not deleted because it does not exist + pass + + +@listens_for(Image, "after_delete") +def del_image(mapper, connection, target): + if target.path: + # Delete image + try: + os.remove(op.join(file_path, target.path)) + except OSError: + pass + + # Delete thumbnail + try: + os.remove(op.join(file_path, form.thumbgen_filename(target.path))) + except OSError: + pass + + +# define a custom wtforms widget and field. +# see https://wtforms.readthedocs.io/en/latest/widgets.html#custom-widgets +class CKTextAreaWidget(widgets.TextArea): + def __call__(self, field, **kwargs): + # add WYSIWYG class to existing classes + existing_classes = kwargs.pop("class", "") or kwargs.pop("class_", "") + kwargs["class"] = "{} {}".format(existing_classes, "ckeditor") + return super().__call__(field, **kwargs) + + +class CKTextAreaField(fields.TextAreaField): + widget = CKTextAreaWidget() + + +class PageView(ModelView): + form_overrides = {"text": CKTextAreaField} + create_template = "create_page.html" + edit_template = "edit_page.html" + + +class FileView(ModelView): + # Override form field to use Flask-Admin FileUploadField + form_overrides = {"path": form.FileUploadField} + + # Pass additional parameters to 'path' to FileUploadField constructor + form_args = { + "path": {"label": "File", "base_path": file_path, "allow_overwrite": False} + } + + +class ImageView(ModelView): + def _list_thumbnail( + view: t.Any, context: jinja2.runtime.Context | None, model: t.Any, name: str + ) -> str | Markup: + if not model.path: + return "" + + return Markup( + ''.format( + url_for("static", filename=form.thumbgen_filename(model.path)) + ) + ) + + column_formatters = {"path": _list_thumbnail} + + # Alternative way to contribute field is to override it completely. + # In this case, Flask-Admin won't attempt to merge various parameters for the field. + form_extra_fields = { + "path": form.ImageUploadField( + "Image", base_path=file_path, thumbnail_size=(100, 100, True) + ) + } + + +class UserView(ModelView): + """ + This class demonstrates the use of 'rules' for controlling the rendering of forms. + """ + + form_create_rules = [ + # Header and four fields. Email field will go above phone field. + rules.FieldSet( + ("first_name", "last_name", "email", "phone", "is_admin"), "Personal" + ), + # Separate header and few fields + rules.Header("Location"), + rules.Field("city"), + # String is resolved to form field, so there's no need to explicitly use + # `rules.Field` + "country", + # Show macro that's included in the templates + rules.Container("rule_demo.wrap", rules.Field("notes")), + ] + + # Use same rule set for edit page + form_edit_rules = form_create_rules + + create_template = "create_user.html" + edit_template = "edit_user.html" + + column_descriptions = { + "is_admin": "Is this an admin user?", + } + + +def build_sample_db(): + """ + Populate a small db with some example entries. + """ + + import random + import string + + db.drop_all() + db.create_all() + + users = [] + for name, surname, location in zip( + first_names, last_names, locations, strict=False + ): + tmp = "".join(random.choice(string.digits) for i in range(10)) + phone = f"({tmp[0:3]}) {tmp[3:6]} {tmp[6::]}" + users.append( + User( + first_name=name, + last_name=surname, + email=f"{name.lower()}.{surname.lower()}@example.com", + phone=phone, + city=location[0], + country=location[1], + ) + ) + + images = ["Buffalo", "Elephant", "Leopard", "Lion", "Rhino"] + images_objects = [Image(name=name, path=f"{name.lower()}.jpg") for name in images] + files = [File(name=f"Example {str(i)}") for i in range(1, 4)] + + db.session.add_all(users + images_objects + files) + + sample_text = ( + "

This is a test

" + "

Create HTML content in a text area field with the help of " + "WTForms and CKEditor.

" + ) + db.session.add(Page(name="Test Page", text=sample_text)) + + db.session.commit() + + +if __name__ == "__main__": + admin.add_view(FileView(File, db)) + admin.add_view(ImageView(Image, db)) + admin.add_view(UserView(User, db)) + admin.add_view(PageView(Page, db)) + + app_dir = op.realpath(os.path.dirname(__file__)) + database_path = op.join(app_dir, app.config["DATABASE_FILE"]) + if not os.path.exists(database_path): + with app.app_context(): + build_sample_db() + + app.run(debug=True) diff --git a/examples/forms_files_images/pyproject.toml b/examples/forms_files_images/pyproject.toml new file mode 100644 index 0000000000..2d710962fb --- /dev/null +++ b/examples/forms_files_images/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "example-forms-files-images" +version = "0.1.0" +description = "Forms Files and Images Example." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin[sqlalchemy-with-utils,images]" +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/examples/forms-files-images/templates/admin/index.html b/examples/forms_files_images/templates/admin/index.html similarity index 97% rename from examples/forms-files-images/templates/admin/index.html rename to examples/forms_files_images/templates/admin/index.html index 6b2f17015b..d3a6345f31 100644 --- a/examples/forms-files-images/templates/admin/index.html +++ b/examples/forms_files_images/templates/admin/index.html @@ -18,4 +18,4 @@

Flask-Admin example

-{% endblock body %} \ No newline at end of file +{% endblock body %} diff --git a/examples/forms-files-images/templates/create_page.html b/examples/forms_files_images/templates/create_page.html similarity index 100% rename from examples/forms-files-images/templates/create_page.html rename to examples/forms_files_images/templates/create_page.html diff --git a/examples/forms-files-images/templates/create_user.html b/examples/forms_files_images/templates/create_user.html similarity index 100% rename from examples/forms-files-images/templates/create_user.html rename to examples/forms_files_images/templates/create_user.html diff --git a/examples/forms-files-images/templates/edit_page.html b/examples/forms_files_images/templates/edit_page.html similarity index 100% rename from examples/forms-files-images/templates/edit_page.html rename to examples/forms_files_images/templates/edit_page.html diff --git a/examples/forms-files-images/templates/edit_user.html b/examples/forms_files_images/templates/edit_user.html similarity index 100% rename from examples/forms-files-images/templates/edit_user.html rename to examples/forms_files_images/templates/edit_user.html diff --git a/examples/forms-files-images/templates/macros.html b/examples/forms_files_images/templates/macros.html similarity index 90% rename from examples/forms-files-images/templates/macros.html rename to examples/forms_files_images/templates/macros.html index edff56416e..24122e8e73 100644 --- a/examples/forms-files-images/templates/macros.html +++ b/examples/forms_files_images/templates/macros.html @@ -2,4 +2,4 @@
{{ caller() }}
-{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/examples/geo_alchemy/.python-version b/examples/geo_alchemy/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/geo_alchemy/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/geo_alchemy/README.md b/examples/geo_alchemy/README.md new file mode 100644 index 0000000000..df545f5dd0 --- /dev/null +++ b/examples/geo_alchemy/README.md @@ -0,0 +1,32 @@ +# GeoAlchemy Example + +GeoAlchemy Example with PostGIS. + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/geo-alchemy +``` + +Install PostgreSQL on your macOS: + +```sh +brew install postgresql +``` + +> This example uses [testcontainers](https://testcontainers.com/) to manage its service dependencies automatically. Make sure [Docker](https://docs.docker.com/get-docker/) is installed and running before starting the example. + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` + +You will notice that the maps are not rendered. By default, Flask-Admin expects an integration with [Mapbox](https://www.mapbox.com/). To see them, you will have to register for a free account at [Mapbox](https://www.mapbox.com/) and set the `FLASK_ADMIN_MAPBOX_MAP_ID*` and `FLASK_ADMIN_MAPBOX_ACCESS_TOKEN` config variables accordingly. + +However, some of the maps are overridden to use Open Street Maps diff --git a/examples/geo_alchemy/README.rst b/examples/geo_alchemy/README.rst deleted file mode 100644 index 40a8a58c18..0000000000 --- a/examples/geo_alchemy/README.rst +++ /dev/null @@ -1,39 +0,0 @@ -SQLAlchemy model backend integration examples. - -To run this example: - -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 - source env/bin/activate - -3. Install requirements:: - - pip install -r 'examples/geo_alchemy/requirements.txt' - -4. Setup the database:: - - psql postgres - - CREATE DATABASE flask_admin_geo; - CREATE ROLE flask_admin_geo LOGIN PASSWORD 'flask_admin_geo'; - GRANT ALL PRIVILEGES ON DATABASE flask_admin_geo TO flask_admin_geo; - \q - - psql flask_admin_geo - - CREATE EXTENSION postgis; - \q - -5. Run the application:: - - python examples/geo_alchemy/app.py - -6. You will notice that the maps are not rendered. To see them, you will have -to register for a free account at `Mapbox `_ and set -the *MAPBOX_MAP_ID* and *MAPBOX_ACCESS_TOKEN* config variables accordingly. diff --git a/examples/geo_alchemy/app.py b/examples/geo_alchemy/app.py deleted file mode 100644 index d91c48afcf..0000000000 --- a/examples/geo_alchemy/app.py +++ /dev/null @@ -1,72 +0,0 @@ -from flask import Flask -from flask_sqlalchemy import SQLAlchemy - -import flask_admin as admin -from geoalchemy2.types import Geometry -from flask_admin.contrib.geoa import ModelView - - -# Create application -app = Flask(__name__) -app.config.from_pyfile('config.py') -db = SQLAlchemy(app) - - -class Point(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(64), unique=True) - point = db.Column(Geometry("POINT")) - - -class MultiPoint(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(64), unique=True) - point = db.Column(Geometry("MULTIPOINT")) - - -class Polygon(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(64), unique=True) - point = db.Column(Geometry("POLYGON")) - - -class MultiPolygon(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(64), unique=True) - point = db.Column(Geometry("MULTIPOLYGON")) - - -class LineString(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(64), unique=True) - point = db.Column(Geometry("LINESTRING")) - - -class MultiLineString(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(64), unique=True) - point = db.Column(Geometry("MULTILINESTRING")) - - -# Flask views -@app.route('/') -def index(): - return 'Click me to get to Admin!' - -# Create admin -admin = admin.Admin(app, name='Example: GeoAlchemy', template_mode='bootstrap4') - -# Add views -admin.add_view(ModelView(Point, db.session, category='Points')) -admin.add_view(ModelView(MultiPoint, db.session, category='Points')) -admin.add_view(ModelView(Polygon, db.session, category='Polygons')) -admin.add_view(ModelView(MultiPolygon, db.session, category='Polygons')) -admin.add_view(ModelView(LineString, db.session, category='Lines')) -admin.add_view(ModelView(MultiLineString, db.session, category='Lines')) - -if __name__ == '__main__': - - db.create_all() - - # Start app - app.run(debug=True) diff --git a/examples/geo_alchemy/config.py b/examples/geo_alchemy/config.py deleted file mode 100644 index fa05163309..0000000000 --- a/examples/geo_alchemy/config.py +++ /dev/null @@ -1,14 +0,0 @@ -# Create dummy secrey key so we can use sessions -SECRET_KEY = '123456790' - -# database connection -SQLALCHEMY_DATABASE_URI = 'postgresql+psycopg2://flask_admin_geo:flask_admin_geo@localhost/flask_admin_geo' -SQLALCHEMY_ECHO = True - -# credentials for loading map tiles from mapbox -MAPBOX_MAP_ID = '...' -MAPBOX_ACCESS_TOKEN = '...' - -# when the creating new shapes, use this default map center -DEFAULT_CENTER_LAT = -33.918861 -DEFAULT_CENTER_LONG = 18.423300 diff --git a/examples/geo_alchemy/main.py b/examples/geo_alchemy/main.py new file mode 100644 index 0000000000..ada9e59a73 --- /dev/null +++ b/examples/geo_alchemy/main.py @@ -0,0 +1,104 @@ +from flask import Flask +from flask_admin import Admin +from flask_admin.contrib.geoa import ModelView +from flask_admin.theme import Bootstrap4Theme +from flask_sqlalchemy import SQLAlchemy +from geoalchemy2.types import Geometry +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from testcontainers.postgres import PostgresContainer + +db = SQLAlchemy() +admin = Admin(name="Example: GeoAlchemy", theme=Bootstrap4Theme()) + + +def index(): + return 'Click me to get to Admin!' + + +class Point(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64), unique=True) + point: Mapped[Geometry] = mapped_column(Geometry("POINT")) + + +class MultiPoint(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64), unique=True) + point: Mapped[Geometry] = mapped_column(Geometry("MULTIPOINT")) + + +class Polygon(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64), unique=True) + point: Mapped[Geometry] = mapped_column(Geometry("POLYGON")) + + +class MultiPolygon(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64), unique=True) + point: Mapped[Geometry] = mapped_column(Geometry("MULTIPOLYGON")) + + +class LineString(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64), unique=True) + point: Mapped[Geometry] = mapped_column(Geometry("LINESTRING")) + + +class MultiLineString(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64), unique=True) + point: Mapped[Geometry] = mapped_column(Geometry("MULTILINESTRING")) + + +class LeafletModelView(ModelView): + edit_modal = True + + +class OSMModelView(ModelView): + tile_layer_url = "{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" + tile_layer_attribution = ( + '© OpenStreetMap ' + "contributors" + ) + + +if __name__ == "__main__": + with PostgresContainer( + image="postgis/postgis:12-3.0", + port=5432, + username="username", + password="password", + dbname="main", + ) as postgres: + app = Flask(__name__) + app.config["SECRET_KEY"] = "secret" + app.config["SQLALCHEMY_DATABASE_URI"] = postgres.get_connection_url() + app.config["SQLALCHEMY_ECHO"] = False + # Credentials for loading map tiles from Mapbox + app.config["FLASK_ADMIN_MAPS"] = True + app.config["FLASK_ADMIN_MAPS_SEARCH"] = False + app.config["FLASK_ADMIN_MAPBOX_MAP_ID"] = "light-v10" + app.config["FLASK_ADMIN_MAPBOX_ACCESS_TOKEN"] = "..." + # When creating new shapes, use this default map center + app.config["FLASK_ADMIN_DEFAULT_CENTER_LAT"] = -33.918861 + app.config["FLASK_ADMIN_DEFAULT_CENTER_LONG"] = 18.423300 + # If you want to use Google Maps, set the API key here + app.config["FLASK_ADMIN_GOOGLE_MAPS_API_KEY"] = "..." + app.add_url_rule(rule="/", view_func=index) + db.init_app(app) + admin.init_app(app) + admin.add_view(LeafletModelView(Point, db, category="Points")) + admin.add_view(OSMModelView(MultiPoint, db, category="Points")) + admin.add_view(LeafletModelView(Polygon, db, category="Polygons")) + admin.add_view(OSMModelView(MultiPolygon, db, category="Polygons")) + admin.add_view(LeafletModelView(LineString, db, category="Lines")) + admin.add_view(OSMModelView(MultiLineString, db, category="Lines")) + + with app.app_context(): + db.create_all() + + app.run(debug=True) diff --git a/examples/geo_alchemy/pyproject.toml b/examples/geo_alchemy/pyproject.toml new file mode 100644 index 0000000000..84c4104fa9 --- /dev/null +++ b/examples/geo_alchemy/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "example-geo-alchemy" +version = "0.1.0" +description = "GeoAlchemy Example." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin[sqlalchemy-with-utils,geoalchemy]", + "testcontainers[postgres]", + "psycopg2", + # "psycopg2-binary", +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/examples/geo_alchemy/requirements.txt b/examples/geo_alchemy/requirements.txt deleted file mode 100644 index 2d1da2188a..0000000000 --- a/examples/geo_alchemy/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -Flask -Flask-Admin -Flask-SQLAlchemy -shapely -geoalchemy2 -psycopg2 \ No newline at end of file diff --git a/examples/host_matching/.python-version b/examples/host_matching/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/host_matching/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/host_matching/README.md b/examples/host_matching/README.md new file mode 100644 index 0000000000..4b055de8fc --- /dev/null +++ b/examples/host_matching/README.md @@ -0,0 +1,20 @@ +# Host Matching Example + +This example shows how to configure Flask-Admin when you're using Flask's `host_matching` mode. Any Flask-Admin instance can be exposed on just a specific host, or on every host. + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/host-matching +``` + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` diff --git a/examples/host_matching/__init__.py b/examples/host_matching/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/host_matching/main.py b/examples/host_matching/main.py new file mode 100644 index 0000000000..39073a1efa --- /dev/null +++ b/examples/host_matching/main.py @@ -0,0 +1,60 @@ +from flask import Flask +from flask import url_for +from flask_admin import Admin +from flask_admin import BaseView +from flask_admin import expose + + +class FirstView(BaseView): + @expose("/") + def index(self): + return self.render("first.html") + + +class SecondView(BaseView): + @expose("/") + def index(self): + return self.render("second.html") + + +class ThirdViewAllHosts(BaseView): + @expose("/") + def index(self): + return self.render("third.html") + + +app = Flask( + __name__, + template_folder="templates", + host_matching=True, + static_host="static.localhost:5000", +) + + +@app.route("/", host="") +def index(anyhost): + admin_host = url_for("admin3.index", admin_routes_host="anything.localhost:5000") + return ( + f'Click me to get to Admin 1' + f"
" + f'Click me to get to Admin 2' + f"
" + f'Click me to get to Admin 3 under ' + f"`anything.localhost:5000`" + ) + + +if __name__ == "__main__": + # Create first administrative interface at `first.localhost:5000/admin1` + admin1 = Admin(app, url="/admin1", host="first.localhost:5000") + admin1.add_view(FirstView()) + + # Create second administrative interface at `second.localhost:5000/admin2` + admin2 = Admin(app, url="/admin2", endpoint="admin2", host="second.localhost:5000") + admin2.add_view(SecondView()) + + # Create third administrative interface, available on any domain at `/admin3` + admin3 = Admin(app, url="/admin3", endpoint="admin3", host="*") + admin3.add_view(ThirdViewAllHosts()) + + app.run(debug=True) diff --git a/examples/host_matching/pyproject.toml b/examples/host_matching/pyproject.toml new file mode 100644 index 0000000000..02a7af676a --- /dev/null +++ b/examples/host_matching/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "example-host-matching" +version = "0.1.0" +description = "Host Matching Example." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin" +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/examples/multiple-admin-instances/templates/first.html b/examples/host_matching/templates/first.html similarity index 100% rename from examples/multiple-admin-instances/templates/first.html rename to examples/host_matching/templates/first.html diff --git a/examples/multiple-admin-instances/templates/second.html b/examples/host_matching/templates/second.html similarity index 100% rename from examples/multiple-admin-instances/templates/second.html rename to examples/host_matching/templates/second.html diff --git a/flask_admin/templates/bootstrap2/admin/index.html b/examples/host_matching/templates/third.html similarity index 75% rename from flask_admin/templates/bootstrap2/admin/index.html rename to examples/host_matching/templates/third.html index fbfdf4c0b8..bba73a9757 100644 --- a/flask_admin/templates/bootstrap2/admin/index.html +++ b/examples/host_matching/templates/third.html @@ -1,4 +1,4 @@ {% extends 'admin/master.html' %} - {% block body %} + Third admin view. {% endblock %} diff --git a/examples/methodview/.python-version b/examples/methodview/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/methodview/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/methodview/README.md b/examples/methodview/README.md new file mode 100644 index 0000000000..8b65a0d384 --- /dev/null +++ b/examples/methodview/README.md @@ -0,0 +1,19 @@ +# MethodView Example + +Example which shows how to integrate Flask `MethodView` with Flask-Admin. + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/methodview + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` diff --git a/examples/methodview/README.rst b/examples/methodview/README.rst deleted file mode 100644 index 03c6c0fd18..0000000000 --- a/examples/methodview/README.rst +++ /dev/null @@ -1,21 +0,0 @@ -Example which shows how to integrate Flask `MethodView` with Flask-Admin. - -To run this example: - -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 - source env/bin/activate - -3. Install requirements:: - - pip install -r 'examples/methodview/requirements.txt' - -4. Run the application:: - - python examples/methodview/app.py diff --git a/examples/methodview/__init__.py b/examples/methodview/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/methodview/app.py b/examples/methodview/app.py deleted file mode 100644 index a6b6fcbaf3..0000000000 --- a/examples/methodview/app.py +++ /dev/null @@ -1,46 +0,0 @@ -from flask import Flask, redirect, request - -import flask_admin as admin -from flask.views import MethodView - - -class ViewWithMethodViews(admin.BaseView): - @admin.expose('/') - def index(self): - return self.render('methodtest.html') - - @admin.expose_plugview('/_api/1') - class API_v1(MethodView): - def get(self, cls): - return cls.render('test.html', request=request, name="API_v1") - - def post(self, cls): - return cls.render('test.html', request=request, name="API_v1") - - @admin.expose_plugview('/_api/2') - class API_v2(MethodView): - def get(self, cls): - return cls.render('test.html', request=request, name="API_v2") - - def post(self, cls): - return cls.render('test.html', request=request, name="API_v2") - - -# Create flask app -app = Flask(__name__, template_folder='templates') - - -# Flask views -@app.route('/') -def index(): - return redirect('/admin') - - -if __name__ == '__main__': - # Create admin interface - admin = admin.Admin(name="Example: MethodView") - admin.add_view(ViewWithMethodViews()) - admin.init_app(app) - - # Start app - app.run(debug=True) diff --git a/examples/methodview/main.py b/examples/methodview/main.py new file mode 100644 index 0000000000..4c430fc144 --- /dev/null +++ b/examples/methodview/main.py @@ -0,0 +1,44 @@ +from flask import Flask +from flask import redirect +from flask import request +from flask.views import MethodView +from flask_admin import Admin +from flask_admin import BaseView +from flask_admin import expose +from flask_admin import expose_plugview + +app = Flask(__name__, template_folder="templates") +admin = Admin(app, name="Example: MethodView") + + +@app.route("/") +def index(): + return redirect("/admin") + + +class ViewWithMethodViews(BaseView): + @expose("/") + def index(self): + return self.render("methodtest.html") + + @expose_plugview("/_api/1") + class API_v1(MethodView): + def get(self, cls): + return cls.render("test.html", request=request, name="API_v1") + + def post(self, cls): + return cls.render("test.html", request=request, name="API_v1") + + @expose_plugview("/_api/2") + class API_v2(MethodView): + def get(self, cls): + return cls.render("test.html", request=request, name="API_v2") + + def post(self, cls): + return cls.render("test.html", request=request, name="API_v2") + + +if __name__ == "__main__": + admin.add_view(ViewWithMethodViews()) + + app.run(debug=True) diff --git a/examples/methodview/pyproject.toml b/examples/methodview/pyproject.toml new file mode 100644 index 0000000000..592f7aec6b --- /dev/null +++ b/examples/methodview/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "example-methodview" +version = "0.1.0" +description = "MethodView Example." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin" +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/examples/methodview/requirements.txt b/examples/methodview/requirements.txt deleted file mode 100644 index a821c9bd4f..0000000000 --- a/examples/methodview/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Flask -Flask-Admin diff --git a/examples/mongoengine/README.md b/examples/mongoengine/README.md new file mode 100644 index 0000000000..225c6e4711 --- /dev/null +++ b/examples/mongoengine/README.md @@ -0,0 +1,21 @@ +# MongoEngine Example + +MongoEngine model backend integration example. + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/mongoengine +``` + +> This example uses [testcontainers](https://testcontainers.com/) to manage its service dependencies automatically. Make sure [Docker](https://docs.docker.com/get-docker/) is installed and running before starting the example. + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` diff --git a/examples/mongoengine/README.rst b/examples/mongoengine/README.rst deleted file mode 100644 index c818730958..0000000000 --- a/examples/mongoengine/README.rst +++ /dev/null @@ -1,22 +0,0 @@ -MongoEngine model backend integration. - -To run this example: - -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 - source env/bin/activate - -3. Install requirements:: - - pip install -r 'examples/mongoengine/requirements.txt' - -4. Run the application:: - - python examples/mongoengine/app.py - diff --git a/examples/mongoengine/app.py b/examples/mongoengine/app.py deleted file mode 100644 index ec4c5ae71e..0000000000 --- a/examples/mongoengine/app.py +++ /dev/null @@ -1,133 +0,0 @@ -import datetime - -from flask import Flask - -import flask_admin as admin -from flask_mongoengine import MongoEngine -from flask_admin.form import rules -from flask_admin.contrib.mongoengine import ModelView - -# Create application -app = Flask(__name__) - -# Create dummy secrey key so we can use sessions -app.config['SECRET_KEY'] = '123456790' -app.config['MONGODB_SETTINGS'] = {'DB': 'testing'} - -# Create models -db = MongoEngine() -db.init_app(app) - - -# Define mongoengine documents -class User(db.Document): - name = db.StringField(max_length=40) - tags = db.ListField(db.ReferenceField('Tag')) - password = db.StringField(max_length=40) - - def __unicode__(self): - return self.name - - -class Todo(db.Document): - title = db.StringField(max_length=60) - text = db.StringField() - done = db.BooleanField(default=False) - pub_date = db.DateTimeField(default=datetime.datetime.now) - user = db.ReferenceField(User, required=False) - - # Required for administrative interface - def __unicode__(self): - return self.title - - -class Tag(db.Document): - name = db.StringField(max_length=10) - - def __unicode__(self): - return self.name - - -class Comment(db.EmbeddedDocument): - name = db.StringField(max_length=20, required=True) - value = db.StringField(max_length=20) - tag = db.ReferenceField(Tag) - - -class Post(db.Document): - name = db.StringField(max_length=20, required=True) - value = db.StringField(max_length=20) - inner = db.ListField(db.EmbeddedDocumentField(Comment)) - lols = db.ListField(db.StringField(max_length=20)) - - -class File(db.Document): - name = db.StringField(max_length=20) - data = db.FileField() - - -class Image(db.Document): - name = db.StringField(max_length=20) - image = db.ImageField(thumbnail_size=(100, 100, True)) - - -# Customized admin views -class UserView(ModelView): - column_filters = ['name'] - - column_searchable_list = ('name', 'password') - - form_ajax_refs = { - 'tags': { - 'fields': ('name',) - } - } - - -class TodoView(ModelView): - column_filters = ['done'] - - form_ajax_refs = { - 'user': { - 'fields': ['name'] - } - } - - -class PostView(ModelView): - form_subdocuments = { - 'inner': { - 'form_subdocuments': { - None: { - # Add
at the end of the form - 'form_rules': ('name', 'tag', 'value', rules.HTML('
')), - 'form_widget_args': { - 'name': { - 'style': 'color: red' - } - } - } - } - } - } - -# Flask views -@app.route('/') -def index(): - return 'Click me to get to Admin!' - - -if __name__ == '__main__': - # Create admin - admin = admin.Admin(app, 'Example: MongoEngine') - - # Add views - admin.add_view(UserView(User)) - admin.add_view(TodoView(Todo)) - admin.add_view(ModelView(Tag)) - admin.add_view(PostView(Post)) - admin.add_view(ModelView(File)) - admin.add_view(ModelView(Image)) - - # Start app - app.run(debug=True) diff --git a/examples/mongoengine/main.py b/examples/mongoengine/main.py new file mode 100644 index 0000000000..5cdd59d0cd --- /dev/null +++ b/examples/mongoengine/main.py @@ -0,0 +1,189 @@ +from bson import ObjectId +from flask import Flask +from flask_admin import Admin +from flask_admin.contrib.mongoengine import filters +from flask_admin.contrib.mongoengine import ModelView +from flask_admin.form import Select2Widget +from flask_admin.model.fields import InlineFieldList +from flask_admin.model.fields import InlineFormField +from mongoengine import BooleanField +from mongoengine import connect +from mongoengine import Document +from mongoengine import EmbeddedDocument +from mongoengine import EmbeddedDocumentField +from mongoengine import EmbeddedDocumentListField +from mongoengine import ReferenceField +from mongoengine import StringField +from testcontainers.mongodb import MongoDbContainer +from wtforms import fields +from wtforms import form + +app = Flask(__name__) +app.config["SECRET_KEY"] = "secret" +admin = Admin(app, name="Example: MongoEngine") + + +class InnerForm(form.Form): + name = fields.StringField("Name") + test = fields.StringField("Test") + + +class Inner(EmbeddedDocument): + name = StringField() + test = StringField() + + +class User(Document): + name = StringField() + email = StringField() + password = StringField() + + inner = EmbeddedDocumentField(Inner, default=Inner) + form_list = EmbeddedDocumentListField(Inner, default=list) + + meta = {"collection": "user"} + + +class SafeInlineFieldList(InlineFieldList): + """ + Prevent type error when InlineFieldList is assigned to an empty object. + """ + + def populate_obj(self, obj, name): + # Get the current value or initialize as empty list + values = getattr(obj, name, []) + + # Clear the existing list + while values: + values.pop() + + # Add new values from form data + for entry in self.entries: + if entry.data and any(entry.data.values()): # Only add non-empty entries + # Create a new embedded document + embedded_doc = Inner() + for field_name, field_value in entry.data.items(): + if field_value: # Only set non-empty values + setattr(embedded_doc, field_name, field_value) + values.append(embedded_doc) + + +class UserForm(form.Form): + name = fields.StringField("Name") + email = fields.StringField("Email") + password = fields.StringField("Password") + + # Inner form + inner = InlineFormField(InnerForm) + + # Form list + form_list = SafeInlineFieldList(InlineFormField(InnerForm)) + + +class UserView(ModelView): + column_list = ("name", "email", "password") + column_sortable_list = ("name", "email", "password") + + form = UserForm + + +class Tweet(Document): + name = StringField(required=True) + user_id = ReferenceField(User, required=True) + text = StringField(required=True) + testie = BooleanField(default=False) + meta = {"collection": "tweet"} + + +# Tweet view +class TweetForm(form.Form): + name = fields.StringField("Name") + user_id = fields.SelectField("User", widget=Select2Widget()) + text = fields.StringField("Text") + + testie = fields.BooleanField("Test") + + +class TweetView(ModelView): + column_list = ("name", "user_name", "text") + column_sortable_list = ("name", "text") + + column_filters = ( + "text", + filters.FilterEqual("name", "Name"), + filters.FilterNotEqual("name", "Name"), + filters.FilterLike("name", "Name"), + filters.FilterNotLike("name", "Name"), + filters.BooleanEqualFilter("testie", "Testie"), + ) + + column_searchable_list = ("name", "text") + + form = TweetForm + + def get_list(self, *args, **kwargs): + count, data = super().get_list(*args, **kwargs) + + # Extract user IDs from tweets + user_ids = [tweet.user_id.id if tweet.user_id else None for tweet in data] + user_ids = list(filter(None, user_ids)) # Remove None values + + # Fetch user names by IDs + users = User.objects(id__in=user_ids).only("name") + users_map = {user.id: user.name for user in users} + + # Add user_name attribute for display + for tweet in data: + tweet.user_name = users_map.get(tweet.user_id.id if tweet.user_id else None) + + return count, data + + # Contribute list of user choices to the forms + def _feed_user_choices(self, form): + users = User.objects.only("name") + form.user_id.choices = [(str(user.id), user.name) for user in users] + return form + + def create_form(self, obj=None): + form = super().create_form(obj) + return self._feed_user_choices(form) + + def edit_form(self, obj): # type: ignore[override] + form = super().edit_form(obj) + return self._feed_user_choices(form) + + def on_model_change(self, form, model, is_created): + if isinstance(model.user_id, str): + model.user_id = ObjectId(model.user_id) + return super().on_model_change(form, model, is_created) + + +# Flask views +@app.route("/") +def index(): + return 'Click me to get to Admin!' + + +def create_example_data(): + # Create example users + user1 = User(name="Alice", email="alice@example.com", password="alice123").save() + user2 = User(name="Bob", email="bob@example.com", password="bob123").save() + + # Create example tweets + Tweet( + name="First Tweet", user_id=user1, text="Hello from Alice!", testie=True + ).save() + Tweet( + name="Second Tweet", user_id=user2, text="Bob's first tweet.", testie=False + ).save() + + +if __name__ == "__main__": + with MongoDbContainer("mongo:7.0.7") as mongo: + mongo_uri = mongo.get_connection_url() + connect(host=mongo_uri) + create_example_data() + admin.add_view(UserView(User, "User")) + admin.add_view(TweetView(Tweet, "Tweets")) + + app.run(debug=True) diff --git a/examples/mongoengine/pyproject.toml b/examples/mongoengine/pyproject.toml new file mode 100644 index 0000000000..0eb3d890bb --- /dev/null +++ b/examples/mongoengine/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "example-mongoengine" +version = "0.1.0" +description = "MongoEngine Example." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin[mongoengine]", + "testcontainers[mongodb]" +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/examples/mongoengine/requirements.txt b/examples/mongoengine/requirements.txt deleted file mode 100644 index 385aeb98c5..0000000000 --- a/examples/mongoengine/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -Flask -Flask-Admin -Flask-MongoEngine -Flask-Login>=0.3.0 -Pillow \ No newline at end of file diff --git a/examples/multiple-admin-instances/README.rst b/examples/multiple-admin-instances/README.rst deleted file mode 100644 index e46a971521..0000000000 --- a/examples/multiple-admin-instances/README.rst +++ /dev/null @@ -1,21 +0,0 @@ -This example shows how to create two separate instances of Flask-Admin for one Flask application. - -To run this example: - -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 - source env/bin/activate - -3. Install requirements:: - - pip install -r 'examples/multiple-admin-instances/requirements.txt' - -4. Run the application:: - - python examples/multiple-admin-instances/app.py diff --git a/examples/multiple-admin-instances/app.py b/examples/multiple-admin-instances/app.py deleted file mode 100644 index db80cf8150..0000000000 --- a/examples/multiple-admin-instances/app.py +++ /dev/null @@ -1,39 +0,0 @@ -from flask import Flask - -import flask_admin as admin - - -# Views -class FirstView(admin.BaseView): - @admin.expose('/') - def index(self): - return self.render('first.html') - - -class SecondView(admin.BaseView): - @admin.expose('/') - def index(self): - return self.render('second.html') - - -# Create flask app -app = Flask(__name__, template_folder='templates') - - -# Flask views -@app.route('/') -def index(): - return 'Click me to get to Admin 1
Click me to get to Admin 2' - - -if __name__ == '__main__': - # Create first administrative interface under /admin1 - admin1 = admin.Admin(app, url='/admin1') - admin1.add_view(FirstView()) - - # Create second administrative interface under /admin2 - admin2 = admin.Admin(app, url='/admin2', endpoint='admin2') - admin2.add_view(SecondView()) - - # Start app - app.run(debug=True) diff --git a/examples/multiple-admin-instances/requirements.txt b/examples/multiple-admin-instances/requirements.txt deleted file mode 100644 index a821c9bd4f..0000000000 --- a/examples/multiple-admin-instances/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Flask -Flask-Admin diff --git a/examples/multiple_admin_instances/.python-version b/examples/multiple_admin_instances/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/multiple_admin_instances/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/multiple_admin_instances/README.md b/examples/multiple_admin_instances/README.md new file mode 100644 index 0000000000..d3edffe301 --- /dev/null +++ b/examples/multiple_admin_instances/README.md @@ -0,0 +1,20 @@ +# Multiple Admin Instances Example + +This example shows how to create two separate instances of Flask-Admin for one Flask application. + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/multiple-admin-instances +``` + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` diff --git a/examples/multiple_admin_instances/__init__.py b/examples/multiple_admin_instances/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/multiple_admin_instances/main.py b/examples/multiple_admin_instances/main.py new file mode 100644 index 0000000000..b891d311c1 --- /dev/null +++ b/examples/multiple_admin_instances/main.py @@ -0,0 +1,35 @@ +from flask import Flask +from flask_admin import Admin +from flask_admin import BaseView +from flask_admin import expose + + +class FirstView(BaseView): + @expose("/") + def index(self): + return self.render("first.html") + + +class SecondView(BaseView): + @expose("/") + def index(self): + return self.render("second.html") + + +app = Flask(__name__, template_folder="templates") +admin1 = Admin(app, url="/admin1") +admin2 = Admin(app, url="/admin2", endpoint="admin2") + + +@app.route("/") +def index(): + return ( + 'Click me to get to Admin 1
' + "Click me to get to Admin 2" + ) + + +if __name__ == "__main__": + admin1.add_view(FirstView()) + admin2.add_view(SecondView()) + app.run(debug=True) diff --git a/examples/multiple_admin_instances/pyproject.toml b/examples/multiple_admin_instances/pyproject.toml new file mode 100644 index 0000000000..862d13ed2b --- /dev/null +++ b/examples/multiple_admin_instances/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "example-multiple-admin-instances" +version = "0.1.0" +description = "Multiple Admin Instances Example." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin" +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/flask_admin/templates/bootstrap3/admin/index.html b/examples/multiple_admin_instances/templates/first.html similarity index 75% rename from flask_admin/templates/bootstrap3/admin/index.html rename to examples/multiple_admin_instances/templates/first.html index fbfdf4c0b8..b3cf9a03b8 100644 --- a/flask_admin/templates/bootstrap3/admin/index.html +++ b/examples/multiple_admin_instances/templates/first.html @@ -1,4 +1,4 @@ {% extends 'admin/master.html' %} - {% block body %} + First admin view. {% endblock %} diff --git a/examples/multiple_admin_instances/templates/second.html b/examples/multiple_admin_instances/templates/second.html new file mode 100644 index 0000000000..64ba3182b9 --- /dev/null +++ b/examples/multiple_admin_instances/templates/second.html @@ -0,0 +1,4 @@ +{% extends 'admin/master.html' %} +{% block body %} + Second admin view. +{% endblock %} diff --git a/examples/peewee/README.rst b/examples/peewee/README.rst deleted file mode 100644 index 0c0bc5f133..0000000000 --- a/examples/peewee/README.rst +++ /dev/null @@ -1,22 +0,0 @@ -Peewee model backend integration example. - -To run this example: - -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 - source env/bin/activate - -3. Install requirements:: - - pip install -r 'examples/peewee/requirements.txt' - -4. Run the application:: - - python examples/peewee/app.py - diff --git a/examples/peewee/app.py b/examples/peewee/app.py deleted file mode 100644 index 3615b97aea..0000000000 --- a/examples/peewee/app.py +++ /dev/null @@ -1,98 +0,0 @@ -from flask import Flask - -import peewee - -import flask_admin as admin -from flask_admin.contrib.peewee import ModelView - - -app = Flask(__name__) -app.config['SECRET_KEY'] = '123456790' - -db = peewee.SqliteDatabase('test.sqlite', check_same_thread=False) - - -class BaseModel(peewee.Model): - class Meta: - database = db - - -class User(BaseModel): - username = peewee.CharField(max_length=80) - email = peewee.CharField(max_length=120) - - def __unicode__(self): - return self.username - - -class UserInfo(BaseModel): - key = peewee.CharField(max_length=64) - value = peewee.CharField(max_length=64) - - user = peewee.ForeignKeyField(User) - - def __unicode__(self): - return '%s - %s' % (self.key, self.value) - - -class Post(BaseModel): - title = peewee.CharField(max_length=120) - text = peewee.TextField(null=False) - date = peewee.DateTimeField() - - user = peewee.ForeignKeyField(User) - - def __unicode__(self): - return self.title - - -class UserAdmin(ModelView): - inline_models = (UserInfo,) - - -class PostAdmin(ModelView): - # Visible columns in the list view - column_exclude_list = ['text'] - - # List of columns that can be sorted. For 'user' column, use User.email as - # a column. - column_sortable_list = ('title', ('user', User.email), 'date') - - # Full text search - column_searchable_list = ('title', User.username) - - # Column filters - column_filters = ('title', - 'date', - User.username) - - form_ajax_refs = { - 'user': { - 'fields': (User.username, 'email') - } - } - - -@app.route('/') -def index(): - return 'Click me to get to Admin!' - - -if __name__ == '__main__': - import logging - logging.basicConfig() - logging.getLogger().setLevel(logging.DEBUG) - - admin = admin.Admin(app, name='Example: Peewee') - - admin.add_view(UserAdmin(User)) - admin.add_view(PostAdmin(Post)) - - try: - User.create_table() - UserInfo.create_table() - Post.create_table() - except: - pass - - app.run(debug=True) diff --git a/examples/peewee/requirements.txt b/examples/peewee/requirements.txt deleted file mode 100644 index 5f4212903c..0000000000 --- a/examples/peewee/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -Flask -Flask-Admin -peewee -wtf-peewee diff --git a/examples/peewee_simple/.python-version b/examples/peewee_simple/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/peewee_simple/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/peewee_simple/README.md b/examples/peewee_simple/README.md new file mode 100644 index 0000000000..e2971a2576 --- /dev/null +++ b/examples/peewee_simple/README.md @@ -0,0 +1,18 @@ +# Peewee Model Backend Integration Example + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/peewee +``` + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` diff --git a/examples/peewee_simple/__init__.py b/examples/peewee_simple/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/peewee_simple/main.py b/examples/peewee_simple/main.py new file mode 100644 index 0000000000..037e3172e1 --- /dev/null +++ b/examples/peewee_simple/main.py @@ -0,0 +1,155 @@ +import uuid + +import peewee +from flask import Flask +from flask_admin import Admin +from flask_admin.contrib.peewee import ModelView +from peewee import CharField +from peewee import Model +from peewee import SqliteDatabase + +app = Flask(__name__) +app.config["SECRET_KEY"] = "123456790" + +db = SqliteDatabase("db.sqlite", check_same_thread=False) + + +class BaseModel(Model): + class Meta: + database = db + + +class User(BaseModel): + username = CharField(max_length=80) + email = CharField(max_length=120) + + def __str__(self): + return self.username + + +class UserInfo(BaseModel): + key = CharField(max_length=64) + value = CharField(max_length=64) + + user = peewee.ForeignKeyField(User) + + def __str__(self): + return f"{self.key} - {self.value}" + + +class Post(BaseModel): + id = peewee.UUIDField(primary_key=True, default=uuid.uuid4) + title = CharField(max_length=120) + text = peewee.TextField(null=False) + date = peewee.DateTimeField() + + user = peewee.ForeignKeyField(User) + + def __str__(self): + return self.title + + +class UserAdmin(ModelView): + inline_models = (UserInfo,) + + +class PostAdmin(ModelView): + # Visible columns in the list view + column_exclude_list = ["text"] + + # List of columns that can be sorted. For 'user' column, use User.email as + # a column. + # column_sortable_list = ("title", ("user", User.email), "date") + column_sortable_list = ("name", ("user", ("user.first_name", "user.last_name"))) + + # Full text search + column_searchable_list = ("title", User.username) + + # mapped_column filters + column_filters = ("title", "date", User.username) + + form_ajax_refs = {"user": {"fields": (User.username, "email")}} + + +class Book(BaseModel): + isbn = CharField(max_length=13, primary_key=True) + name = CharField(max_length=120) + + +class Category(BaseModel): + id = peewee.AutoField(primary_key=True) + name = CharField(unique=True, max_length=50) + + +# broken many-to-many model +class BookCategory(BaseModel): + id = peewee.AutoField() # Surrogate key + isbn = peewee.ForeignKeyField( + Book, + # column_name='isbn', + backref="book_categories", + ) + category = peewee.ForeignKeyField( + Category, + # column_name='category' + backref="category", + ) + + class Meta: + db_table = "books_categories" + indexes = ((("isbn", "category"), True),) + + +class ViewWithPk(ModelView): + column_display_pk = True + form_columns = [ + "isbn", + "name", + "category", + ] + + +@app.route("/") +def index(): + return 'Click me to get to Admin!' + + +if __name__ == "__main__": + import logging + + logging.basicConfig() + logging.getLogger().setLevel(logging.DEBUG) + + admin = Admin(app, name="Example: Peewee") + + admin.add_view(UserAdmin(User)) + admin.add_view(PostAdmin(Post)) + + admin.add_view(ViewWithPk(Book)) + admin.add_view(ModelView(Category)) + admin.add_view(ViewWithPk(BookCategory)) + + # Create tables first + with db: + db.drop_tables([BookCategory, Post, UserInfo, Book, Category, User], safe=True) + db.create_tables( + [User, UserInfo, Post, Book, Category, BookCategory], safe=True + ) + with db.atomic(): + # Create sample books + book1, created = Book.get_or_create( + isbn="111", defaults={"name": "Sample Book 1"} + ) + book2, created = Book.get_or_create( + isbn="222", defaults={"name": "Sample Book 2"} + ) + + # Create sample categories + cat1, created = Category.get_or_create(name="Fiction") + cat2, created = Category.get_or_create(name="Science") + + # Create relationships + BookCategory.get_or_create(isbn=book1, category=cat1) + BookCategory.get_or_create(isbn=book2, category=cat2) + + app.run(debug=True) diff --git a/examples/peewee_simple/pyproject.toml b/examples/peewee_simple/pyproject.toml new file mode 100644 index 0000000000..e5c5f4c0ad --- /dev/null +++ b/examples/peewee_simple/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "example-peewee" +version = "0.1.0" +description = "Peewee Model Backend Integration Example" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin[peewee]" +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/examples/pymongo/README.rst b/examples/pymongo/README.rst deleted file mode 100644 index 9cf6dbd0a3..0000000000 --- a/examples/pymongo/README.rst +++ /dev/null @@ -1,22 +0,0 @@ -PyMongo model backend integration example. - -To run this example: - -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 - source env/bin/activate - -3. Install requirements:: - - pip install -r 'examples/pymongo/requirements.txt' - -4. Run the application:: - - python examples/pymongo/app.py - diff --git a/examples/pymongo/app.py b/examples/pymongo/app.py deleted file mode 100644 index dc66b28b06..0000000000 --- a/examples/pymongo/app.py +++ /dev/null @@ -1,124 +0,0 @@ -import pymongo -from bson.objectid import ObjectId - -from flask import Flask -import flask_admin as admin - -from wtforms import form, fields - -from flask_admin.form import Select2Widget -from flask_admin.contrib.pymongo import ModelView, filters -from flask_admin.model.fields import InlineFormField, InlineFieldList - -# Create application -app = Flask(__name__) - -# Create dummy secrey key so we can use sessions -app.config['SECRET_KEY'] = '123456790' - -# Create models -conn = pymongo.Connection() -db = conn.test - - -# User admin -class InnerForm(form.Form): - name = fields.StringField('Name') - test = fields.StringField('Test') - - -class UserForm(form.Form): - name = fields.StringField('Name') - email = fields.StringField('Email') - password = fields.StringField('Password') - - # Inner form - inner = InlineFormField(InnerForm) - - # Form list - form_list = InlineFieldList(InlineFormField(InnerForm)) - - -class UserView(ModelView): - column_list = ('name', 'email', 'password') - column_sortable_list = ('name', 'email', 'password') - - form = UserForm - - -# Tweet view -class TweetForm(form.Form): - name = fields.StringField('Name') - user_id = fields.SelectField('User', widget=Select2Widget()) - text = fields.StringField('Text') - - testie = fields.BooleanField('Test') - - -class TweetView(ModelView): - column_list = ('name', 'user_name', 'text') - column_sortable_list = ('name', 'text') - - column_filters = (filters.FilterEqual('name', 'Name'), - filters.FilterNotEqual('name', 'Name'), - filters.FilterLike('name', 'Name'), - filters.FilterNotLike('name', 'Name'), - filters.BooleanEqualFilter('testie', 'Testie')) - - column_searchable_list = ('name', 'text') - - form = TweetForm - - def get_list(self, *args, **kwargs): - count, data = super(TweetView, self).get_list(*args, **kwargs) - - # Grab user names - query = {'_id': {'$in': [x['user_id'] for x in data]}} - users = db.user.find(query, fields=('name',)) - - # Contribute user names to the models - users_map = dict((x['_id'], x['name']) for x in users) - - for item in data: - item['user_name'] = users_map.get(item['user_id']) - - return count, data - - # Contribute list of user choices to the forms - def _feed_user_choices(self, form): - users = db.user.find(fields=('name',)) - form.user_id.choices = [(str(x['_id']), x['name']) for x in users] - return form - - def create_form(self): - form = super(TweetView, self).create_form() - return self._feed_user_choices(form) - - def edit_form(self, obj): - form = super(TweetView, self).edit_form(obj) - return self._feed_user_choices(form) - - # Correct user_id reference before saving - def on_model_change(self, form, model): - user_id = model.get('user_id') - model['user_id'] = ObjectId(user_id) - - return model - - -# Flask views -@app.route('/') -def index(): - return 'Click me to get to Admin!' - - -if __name__ == '__main__': - # Create admin - admin = admin.Admin(app, name='Example: PyMongo') - - # Add views - admin.add_view(UserView(db.user, 'User')) - admin.add_view(TweetView(db.tweet, 'Tweets')) - - # Start app - app.run(debug=True) diff --git a/examples/pymongo/requirements.txt b/examples/pymongo/requirements.txt deleted file mode 100644 index 76c8bf8403..0000000000 --- a/examples/pymongo/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -Flask -Flask-Admin -pymongo==2.4.1 diff --git a/examples/pymongo_simple/.python-version b/examples/pymongo_simple/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/pymongo_simple/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/pymongo_simple/README.md b/examples/pymongo_simple/README.md new file mode 100644 index 0000000000..65ad77ab29 --- /dev/null +++ b/examples/pymongo_simple/README.md @@ -0,0 +1,22 @@ +# PyMongo Example + +PyMongo model backend integration example. + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/pymongo +``` + +> This example uses [testcontainers](https://testcontainers.com/) to manage its service dependencies automatically. Make sure [Docker](https://docs.docker.com/get-docker/) is installed and running before starting the example. + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` diff --git a/examples/pymongo_simple/__init__.py b/examples/pymongo_simple/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/pymongo_simple/main.py b/examples/pymongo_simple/main.py new file mode 100644 index 0000000000..b1cdf46c08 --- /dev/null +++ b/examples/pymongo_simple/main.py @@ -0,0 +1,118 @@ +from typing import Any + +from bson.objectid import ObjectId +from flask import Flask +from flask import url_for +from flask_admin import Admin +from flask_admin.contrib.pymongo import filters +from flask_admin.contrib.pymongo import ModelView +from flask_admin.form import Select2Widget +from flask_admin.model.fields import InlineFieldList +from flask_admin.model.fields import InlineFormField +from pymongo import MongoClient +from testcontainers.mongodb import MongoDbContainer +from wtforms import fields +from wtforms import form + +app = Flask(__name__) +app.config["SECRET_KEY"] = "secret" +admin = Admin(app, name="Example: PyMongo") + + +class InnerForm(form.Form): + name = fields.StringField("Name") + test = fields.StringField("Test") + + +class UserForm(form.Form): + name = fields.StringField("Name") + email = fields.StringField("Email") + password = fields.StringField("Password") + + inner = InlineFormField(InnerForm) + + form_list = InlineFieldList(InlineFormField(InnerForm)) + + +class UserView(ModelView): + column_list = ("name", "email", "password") + column_sortable_list = ("name", "email", "password") + + form = UserForm + + +class TweetForm(form.Form): + name = fields.StringField("Name") + user_id = fields.SelectField("User", widget=Select2Widget()) + text = fields.StringField("Text") + + testie = fields.BooleanField("Test") + + +class TweetView(ModelView): + column_list = ("name", "user_name", "text") + column_sortable_list = ("name", "text") + + column_filters = ( + filters.FilterEqual("name", "Name"), + filters.FilterNotEqual("name", "Name"), + filters.FilterLike("name", "Name"), + filters.FilterNotLike("name", "Name"), + filters.BooleanEqualFilter("testie", "Testie"), + ) + + column_searchable_list = ("name", "text") + + form = TweetForm + + def get_list(self, *args, **kwargs): + count, data = super().get_list(*args, **kwargs) + + # Grab user names + query = {"_id": {"$in": [x["user_id"] for x in data]}} + users = db.user.find(query, projection=("name",)) + + # Contribute user names to the models + users_map = dict((x["_id"], x["name"]) for x in users) + + for item in data: + item["user_name"] = users_map.get(item["user_id"]) + + return count, data + + # Contribute list of user choices to the forms + def _feed_user_choices(self, form): + users = db.user.find(projection=("name",)) + form.user_id.choices = [(str(x["_id"]), x["name"]) for x in users] + return form + + def create_form(self): # type: ignore[override] + form = super().create_form() + return self._feed_user_choices(form) + + def edit_form(self, obj): # type: ignore[override] + form = super().edit_form(obj) + return self._feed_user_choices(form) + + # Correct user_id reference before saving + def on_model_change(self, form, model, is_created): + user_id = model.get("user_id") + model["user_id"] = ObjectId(user_id) + + return model + + +@app.route("/") +def index(): + return f'Go to admin!' + + +if __name__ == "__main__": + with MongoDbContainer("mongo:7.0.7") as mongo: + conn: MongoClient[Any] = MongoClient(mongo.get_connection_url()) + db = conn.test + + admin.add_view(UserView(db.user, "User")) + admin.add_view(TweetView(db.tweet, "Tweets")) + + app.run(debug=True) diff --git a/examples/pymongo_simple/pyproject.toml b/examples/pymongo_simple/pyproject.toml new file mode 100644 index 0000000000..7c1156bf33 --- /dev/null +++ b/examples/pymongo_simple/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "example-pymongo" +version = "0.1.0" +description = "PyMongo Example." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin[pymongo]", + "testcontainers[mongodb]" +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/examples/rediscli/.python-version b/examples/rediscli/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/rediscli/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/rediscli/README.md b/examples/rediscli/README.md new file mode 100644 index 0000000000..b20714752a --- /dev/null +++ b/examples/rediscli/README.md @@ -0,0 +1,20 @@ +# RedisCLI Example + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/rediscli +``` + +> This example uses [testcontainers](https://testcontainers.com/) to manage its service dependencies automatically. Make sure [Docker](https://docs.docker.com/get-docker/) is installed and running before starting the example. + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` diff --git a/examples/rediscli/__init__.py b/examples/rediscli/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/rediscli/main.py b/examples/rediscli/main.py new file mode 100644 index 0000000000..5b5e9e1916 --- /dev/null +++ b/examples/rediscli/main.py @@ -0,0 +1,30 @@ +from flask import Flask +from flask_admin import Admin +from flask_admin.contrib.rediscli import RedisCli +from redis import Redis +from testcontainers.redis import RedisContainer + +app = Flask(__name__) +app.config["SECRET_KEY"] = "secret" +admin = Admin(app, name="Example: RedisCLI") + + +@app.route("/") +def index(): + return 'Click me to get to Admin!' + + +if __name__ == "__main__": + with RedisContainer() as redis_container: + redis_client = redis_container.get_client() + admin.add_view( + RedisCli( + Redis( + host=redis_container.get_container_host_ip(), + port=redis_container.get_exposed_port(redis_container.port), + password=redis_container.password, + ) + ) + ) + + app.run(debug=True) diff --git a/examples/rediscli/pyproject.toml b/examples/rediscli/pyproject.toml new file mode 100644 index 0000000000..5f451dc51d --- /dev/null +++ b/examples/rediscli/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "example-rediscli" +version = "0.1.0" +description = "RedisCLI Example." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin[rediscli]", + "testcontainers[redis]", +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/examples/s3/.python-version b/examples/s3/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/s3/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/s3/README.md b/examples/s3/README.md new file mode 100644 index 0000000000..9e3a4adf81 --- /dev/null +++ b/examples/s3/README.md @@ -0,0 +1,22 @@ +# S3 Example + +Flask-Admin example for an S3 bucket. + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/s3 +``` + +> This example uses [testcontainers](https://testcontainers.com/) to manage its service dependencies automatically. Make sure [Docker](https://docs.docker.com/get-docker/) is installed and running before starting the example. + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` diff --git a/examples/s3/__init__.py b/examples/s3/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/s3/localdir/yy/zz/afile.txt b/examples/s3/localdir/yy/zz/afile.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/s3/main.py b/examples/s3/main.py new file mode 100644 index 0000000000..948ed7ad03 --- /dev/null +++ b/examples/s3/main.py @@ -0,0 +1,72 @@ +import os +from io import BytesIO + +import boto3 +from flask import Flask +from flask_admin import Admin +from flask_admin.contrib.fileadmin import FileAdmin +from flask_admin.contrib.fileadmin.s3 import S3FileAdmin +from flask_babel import Babel +from testcontainers.localstack import LocalStackContainer + +app = Flask(__name__) +app.config["SECRET_KEY"] = "secret" +admin = Admin(app, name="Example: S3 File Admin") +babel = Babel(app) + + +@app.route("/") +def index(): + return 'Click me to get to Admin!' + + +if __name__ == "__main__": + with LocalStackContainer(image="localstack/localstack:latest") as localstack: + s3_endpoint = localstack.get_url() + os.environ["AWS_ENDPOINT_OVERRIDE"] = s3_endpoint + + # Create S3 client + s3_client = boto3.client( + "s3", + aws_access_key_id="test", + aws_secret_access_key="test", + endpoint_url=s3_endpoint, + ) + + # Create S3 bucket + bucket_name = "bucket" + s3_client.create_bucket(Bucket=bucket_name) + + s3_client.upload_fileobj( + BytesIO(b"abcdef"), + "bucket", + "some-file", + ExtraArgs={"ContentType": "text/plain"}, + ) + + s3_client.upload_fileobj( + BytesIO(b"abcdef"), + "bucket", + "some-directory/some-file", + ExtraArgs={"ContentType": "text/plain"}, + ) + + s3_client.upload_fileobj( + BytesIO(b"abcdef"), + "bucket", + "some-directory/yy/another-file", + ExtraArgs={"ContentType": "text/plain"}, + ) + + # Add S3FileAdmin view + admin.add_view( + S3FileAdmin( + bucket_name=bucket_name, + s3_client=s3_client, + ) + ) + + # Add Local Directory view + admin.add_view(FileAdmin("localdir", name="Local Dir")) + + app.run(debug=True) diff --git a/examples/s3/pyproject.toml b/examples/s3/pyproject.toml new file mode 100644 index 0000000000..dce6c9c7a2 --- /dev/null +++ b/examples/s3/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "example-s3" +version = "0.1.0" +description = "PyMongo Example." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin[s3,translation]", + "testcontainers", +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/examples/simple/.python-version b/examples/simple/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/simple/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/simple/README.md b/examples/simple/README.md new file mode 100644 index 0000000000..0607b55faa --- /dev/null +++ b/examples/simple/README.md @@ -0,0 +1,20 @@ +# Simple Flask-Admin Example + +This example shows how to add some simple views to your admin interface. The views do not have to be associated to any of your models, and you can fill them with whatever content you want. + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/simple +``` + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` diff --git a/examples/simple/README.rst b/examples/simple/README.rst deleted file mode 100644 index 2889a22c49..0000000000 --- a/examples/simple/README.rst +++ /dev/null @@ -1,22 +0,0 @@ -This example shows how to add some simple views to your admin interface. -The views do not have to be associated to any of your models, and you can fill them with whatever content you want. - -To run this example: - -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 - source env/bin/activate - -3. Install requirements:: - - pip install -r 'examples/simple/requirements.txt' - -4. Run the application:: - - python examples/simple/app.py diff --git a/examples/simple/app.py b/examples/simple/app.py deleted file mode 100644 index d62b6d7a65..0000000000 --- a/examples/simple/app.py +++ /dev/null @@ -1,41 +0,0 @@ -from flask import Flask - -import flask_admin as admin - - -# Create custom admin view -class MyAdminView(admin.BaseView): - @admin.expose('/') - def index(self): - return self.render('myadmin.html') - - -class AnotherAdminView(admin.BaseView): - @admin.expose('/') - def index(self): - return self.render('anotheradmin.html') - - @admin.expose('/test/') - def test(self): - return self.render('test.html') - - -# Create flask app -app = Flask(__name__, template_folder='templates') -app.debug = True - -# Flask views -@app.route('/') -def index(): - return 'Click me to get to Admin!' - -# Create admin interface -admin = admin.Admin(name="Example: Simple Views", template_mode='bootstrap4') -admin.add_view(MyAdminView(name="view1", category='Test')) -admin.add_view(AnotherAdminView(name="view2", category='Test')) -admin.init_app(app) - -if __name__ == '__main__': - - # Start app - app.run() diff --git a/examples/simple/main.py b/examples/simple/main.py new file mode 100644 index 0000000000..ec299c5db5 --- /dev/null +++ b/examples/simple/main.py @@ -0,0 +1,36 @@ +from flask import Flask +from flask_admin import Admin +from flask_admin import BaseView +from flask_admin import expose +from flask_admin.theme import Bootstrap4Theme + +app = Flask(__name__, template_folder="templates") +app.debug = True +admin = Admin(app, name="Example: Simple Views", theme=Bootstrap4Theme()) + + +@app.route("/") +def index(): + return 'Click me to get to Admin!' + + +class MyAdminView(BaseView): + @expose("/") + def index(self): + return self.render("myadmin.html") + + +class AnotherAdminView(BaseView): + @expose("/") + def index(self): + return self.render("anotheradmin.html") + + @expose("/test/") + def test(self): + return self.render("test.html") + + +if __name__ == "__main__": + admin.add_view(MyAdminView(name="view1", category="Test")) + admin.add_view(AnotherAdminView(name="view2", category="Test")) + app.run(debug=True) diff --git a/examples/simple/pyproject.toml b/examples/simple/pyproject.toml new file mode 100644 index 0000000000..720118c60d --- /dev/null +++ b/examples/simple/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "example-simple" +version = "0.1.0" +description = "Simple Flask-Admin Example." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin" +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/examples/simple/requirements.txt b/examples/simple/requirements.txt deleted file mode 100644 index a821c9bd4f..0000000000 --- a/examples/simple/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Flask -Flask-Admin diff --git a/examples/sqla-association_proxy/README.rst b/examples/sqla-association_proxy/README.rst deleted file mode 100644 index 6b27d65ec9..0000000000 --- a/examples/sqla-association_proxy/README.rst +++ /dev/null @@ -1,24 +0,0 @@ -Example of how to use (and filter on) an association proxy with the SQLAlchemy backend. - -For information about association proxies and how to use them, please visit: -http://docs.sqlalchemy.org/en/latest/orm/extensions/associationproxy.html - -To run this example: - -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 - source env/bin/activate - -3. Install requirements:: - - pip install -r 'examples/sqla-association_proxy/requirements.txt' - -4. Run the application:: - - python examples/sqla-association_proxy/app.py diff --git a/examples/sqla-association_proxy/app.py b/examples/sqla-association_proxy/app.py deleted file mode 100644 index 8d8e32ee92..0000000000 --- a/examples/sqla-association_proxy/app.py +++ /dev/null @@ -1,108 +0,0 @@ -from flask import Flask -from flask_sqlalchemy import SQLAlchemy -from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.orm import relationship, backref - -import flask_admin as admin -from flask_admin.contrib import sqla - -# Create application -app = Flask(__name__) - -# Create dummy secrey key so we can use sessions -app.config['SECRET_KEY'] = '123456790' - -# Create in-memory database -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' -app.config['SQLALCHEMY_ECHO'] = True -db = SQLAlchemy(app) - - -# Flask views -@app.route('/') -def index(): - return 'Click me to get to Admin!' - - -class User(db.Model): - __tablename__ = 'user' - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(64)) - - # Association proxy of "user_keywords" collection to "keyword" attribute - a list of keywords objects. - keywords = association_proxy('user_keywords', 'keyword') - # Association proxy to association proxy - a list of keywords strings. - keywords_values = association_proxy('user_keywords', 'keyword_value') - - def __init__(self, name=None): - self.name = name - - -class UserKeyword(db.Model): - __tablename__ = 'user_keyword' - user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) - keyword_id = db.Column(db.Integer, db.ForeignKey('keyword.id'), primary_key=True) - special_key = db.Column(db.String(50)) - - # bidirectional attribute/collection of "user"/"user_keywords" - user = relationship(User, backref=backref("user_keywords", cascade="all, delete-orphan")) - - # reference to the "Keyword" object - keyword = relationship("Keyword") - # Reference to the "keyword" column inside the "Keyword" object. - keyword_value = association_proxy('keyword', 'keyword') - - def __init__(self, keyword=None, user=None, special_key=None): - self.user = user - self.keyword = keyword - self.special_key = special_key - - -class Keyword(db.Model): - __tablename__ = 'keyword' - id = db.Column(db.Integer, primary_key=True) - keyword = db.Column('keyword', db.String(64)) - - def __init__(self, keyword=None): - self.keyword = keyword - - def __repr__(self): - return 'Keyword(%s)' % repr(self.keyword) - - -class UserAdmin(sqla.ModelView): - """ Flask-admin can not automatically find a association_proxy yet. You will - need to manually define the column in list_view/filters/sorting/etc. - Moreover, support for association proxies to association proxies - (e.g.: keywords_values) is currently limited to column_list only.""" - - column_list = ('id', 'name', 'keywords', 'keywords_values') - column_sortable_list = ('id', 'name') - column_filters = ('id', 'name', 'keywords') - form_columns = ('name', 'keywords') - - -class KeywordAdmin(sqla.ModelView): - column_list = ('id', 'keyword') - - -# Create admin -admin = admin.Admin(app, name='Example: SQLAlchemy Association Proxy', template_mode='bootstrap4') -admin.add_view(UserAdmin(User, db.session)) -admin.add_view(KeywordAdmin(Keyword, db.session)) - -if __name__ == '__main__': - - # Create DB - db.create_all() - - # Add sample data - user = User('log') - for kw in (Keyword('new_from_blammo'), Keyword('its_big')): - user.keywords.append(kw) - - db.session.add(user) - db.session.commit() - - # Start app - app.run(debug=True) diff --git a/examples/sqla-association_proxy/requirements.txt b/examples/sqla-association_proxy/requirements.txt deleted file mode 100644 index f0e0ebaf82..0000000000 --- a/examples/sqla-association_proxy/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -Flask -Flask-Admin -Flask-SQLAlchemy diff --git a/examples/sqla-custom-inline-forms/README.rst b/examples/sqla-custom-inline-forms/README.rst deleted file mode 100644 index 1da76474e6..0000000000 --- a/examples/sqla-custom-inline-forms/README.rst +++ /dev/null @@ -1,21 +0,0 @@ -This example shows how to use inline forms when working with related models. - -To run this example: - -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 - source env/bin/activate - -3. Install requirements:: - - pip install -r 'examples/sqla-custom-inline-forms/requirements.txt' - -4. Run the application:: - - python examples/sqla-custom-inline-forms/app.py diff --git a/examples/sqla-custom-inline-forms/app.py b/examples/sqla-custom-inline-forms/app.py deleted file mode 100644 index b0e9334c55..0000000000 --- a/examples/sqla-custom-inline-forms/app.py +++ /dev/null @@ -1,133 +0,0 @@ -import os -import os.path as op - -from werkzeug.utils import secure_filename -from sqlalchemy import event - -from flask import Flask, request, render_template -from flask_sqlalchemy import SQLAlchemy - -from wtforms import fields - -import flask_admin as admin -from flask_admin.form import RenderTemplateWidget -from flask_admin.model.form import InlineFormAdmin -from flask_admin.contrib.sqla import ModelView -from flask_admin.contrib.sqla.form import InlineModelConverter -from flask_admin.contrib.sqla.fields import InlineModelFormList - -# Create application -app = Flask(__name__) - -# Create dummy secrey key so we can use sessions -app.config['SECRET_KEY'] = '123456790' - -# Create in-memory database -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///test.sqlite' -app.config['SQLALCHEMY_ECHO'] = True -db = SQLAlchemy(app) - -# Figure out base upload path -base_path = op.join(op.dirname(__file__), 'static') - - -# Create models -class Location(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.Unicode(64)) - - -class LocationImage(db.Model): - id = db.Column(db.Integer, primary_key=True) - alt = db.Column(db.Unicode(128)) - path = db.Column(db.String(64)) - - location_id = db.Column(db.Integer, db.ForeignKey(Location.id)) - location = db.relation(Location, backref='images') - - -# Register after_delete handler which will delete image file after model gets deleted -@event.listens_for(LocationImage, 'after_delete') -def _handle_image_delete(mapper, conn, target): - try: - if target.path: - os.remove(op.join(base_path, target.path)) - except: - pass - - -# This widget uses custom template for inline field list -class CustomInlineFieldListWidget(RenderTemplateWidget): - def __init__(self): - super(CustomInlineFieldListWidget, self).__init__('field_list.html') - - -# This InlineModelFormList will use our custom widget and hide row controls -class CustomInlineModelFormList(InlineModelFormList): - widget = CustomInlineFieldListWidget() - - def display_row_controls(self, field): - return False - - -# Create custom InlineModelConverter and tell it to use our InlineModelFormList -class CustomInlineModelConverter(InlineModelConverter): - inline_field_list_type = CustomInlineModelFormList - - -# Customized inline form handler -class InlineModelForm(InlineFormAdmin): - form_excluded_columns = ('path',) - - form_label = 'Image' - - def __init__(self): - return super(InlineModelForm, self).__init__(LocationImage) - - def postprocess_form(self, form_class): - form_class.upload = fields.FileField('Image') - return form_class - - def on_model_change(self, form, model): - file_data = request.files.get(form.upload.name) - - if file_data: - model.path = secure_filename(file_data.filename) - file_data.save(op.join(base_path, model.path)) - - -# Administrative class -class LocationAdmin(ModelView): - inline_model_form_converter = CustomInlineModelConverter - - inline_models = (InlineModelForm(),) - - def __init__(self): - super(LocationAdmin, self).__init__(Location, db.session, name='Locations') - - -# Simple page to show images -@app.route('/') -def index(): - locations = db.session.query(Location).all() - return render_template('locations.html', locations=locations) - - -if __name__ == '__main__': - # Create upload directory - try: - os.mkdir(base_path) - except OSError: - pass - - # Create admin - admin = admin.Admin(app, name='Example: Inline Models') - - # Add views - admin.add_view(LocationAdmin()) - - # Create DB - db.create_all() - - # Start app - app.run(debug=True) diff --git a/examples/sqla-custom-inline-forms/requirements.txt b/examples/sqla-custom-inline-forms/requirements.txt deleted file mode 100644 index 64899f83d7..0000000000 --- a/examples/sqla-custom-inline-forms/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -Flask -Flask-Admin -Flask-SQLAlchemy -WTForms==1.0.5 diff --git a/examples/sqla/.python-version b/examples/sqla/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/sqla/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/sqla/README.md b/examples/sqla/README.md new file mode 100644 index 0000000000..3b9a576734 --- /dev/null +++ b/examples/sqla/README.md @@ -0,0 +1,18 @@ +# SQLAlchemy Model Backend Example + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/sqla +``` + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` diff --git a/examples/sqla/README.rst b/examples/sqla/README.rst deleted file mode 100644 index 797a4820ac..0000000000 --- a/examples/sqla/README.rst +++ /dev/null @@ -1,24 +0,0 @@ -SQLAlchemy model backend integration examples. - -To run this example: - -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 - -The first time you run this example, a sample sqlite database gets populated automatically. To start -with a fresh database: `rm examples/sqla/admin/sample_db.sqlite`, and then restart the application. diff --git a/examples/sqla/__init__.py b/examples/sqla/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/sqla/admin/__init__.py b/examples/sqla/admin/__init__.py index 99c979b39d..c18cdf60ee 100644 --- a/examples/sqla/admin/__init__.py +++ b/examples/sqla/admin/__init__.py @@ -1,24 +1,28 @@ -from flask import Flask, request, session +from flask import Flask +from flask import request +from flask import session +from flask_babel import Babel from flask_sqlalchemy import SQLAlchemy -from flask_babelex import Babel - app = Flask(__name__) -app.config.from_pyfile('config.py') +app.config["SECRET_KEY"] = "secret" +app.config["DATABASE_FILE"] = "db.sqlite" +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + app.config["DATABASE_FILE"] +app.config["SQLALCHEMY_ECHO"] = False +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db = SQLAlchemy(app) -# Initialize babel -babel = Babel(app) - -@babel.localeselector def get_locale(): - override = request.args.get('lang') + override = request.args.get("lang") if override: - session['lang'] = override + session["lang"] = override + + return session.get("lang", "en") + - return session.get('lang', 'en') +babel = Babel(app, locale_selector=get_locale) -import admin.main +import admin.main # noqa: F401, E402 diff --git a/examples/sqla/admin/config.py b/examples/sqla/admin/config.py deleted file mode 100644 index c685d83a05..0000000000 --- a/examples/sqla/admin/config.py +++ /dev/null @@ -1,12 +0,0 @@ -# set optional bootswatch theme -# see http://bootswatch.com/3/ for available swatches -FLASK_ADMIN_SWATCH = 'cerulean' - -# Create dummy secrey key so we can use sessions -SECRET_KEY = '123456790' - -# Create in-memory database -DATABASE_FILE = 'sample_db.sqlite' -SQLALCHEMY_DATABASE_URI = 'sqlite:///' + DATABASE_FILE -SQLALCHEMY_ECHO = True -SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/examples/sqla/admin/data.py b/examples/sqla/admin/data.py index 0cb461356d..b5be9e928b 100644 --- a/examples/sqla/admin/data.py +++ b/examples/sqla/admin/data.py @@ -1,7 +1,14 @@ -from admin import db -from admin.models import User, Post, Tag, Tree, AVAILABLE_USER_TYPES -import random import datetime +import random + +from admin import db +from admin.models import Account +from admin.models import AccountProvider +from admin.models import AVAILABLE_USER_TYPES +from admin.models import Post +from admin.models import Tag +from admin.models import Tree +from admin.models import User def build_sample_db(): @@ -12,16 +19,59 @@ def build_sample_db(): db.drop_all() db.create_all() - # Create sample Users first_names = [ - 'Harry', 'Amelia', 'Oliver', 'Jack', 'Isabella', 'Charlie', 'Sophie', 'Mia', - 'Jacob', 'Thomas', 'Emily', 'Lily', 'Ava', 'Isla', 'Alfie', 'Olivia', 'Jessica', - 'Riley', 'William', 'James', 'Geoffrey', 'Lisa', 'Benjamin', 'Stacey', 'Lucy' + "Harry", + "Amelia", + "Oliver", + "Jack", + "Isabella", + "Charlie", + "Sophie", + "Mia", + "Jacob", + "Thomas", + "Emily", + "Lily", + "Ava", + "Isla", + "Alfie", + "Olivia", + "Jessica", + "Riley", + "William", + "James", + "Geoffrey", + "Lisa", + "Benjamin", + "Stacey", + "Lucy", ] last_names = [ - 'Brown', 'Brown', 'Patel', 'Jones', 'Williams', 'Johnson', 'Taylor', 'Thomas', - 'Roberts', 'Khan', 'Clarke', 'Clarke', 'Clarke', 'James', 'Phillips', 'Wilson', - 'Ali', 'Mason', 'Mitchell', 'Rose', 'Davis', 'Davies', 'Rodriguez', 'Cox', 'Alexander' + "Brown", + "Brown", + "Patel", + "Jones", + "Williams", + "Johnson", + "Taylor", + "Thomas", + "Roberts", + "Khan", + "Clarke", + "Clarke", + "Clarke", + "James", + "Phillips", + "Wilson", + "Ali", + "Mason", + "Mitchell", + "Rose", + "Davis", + "Davies", + "Rodriguez", + "Cox", + "Alexander", ] countries = [ @@ -34,6 +84,13 @@ def build_sample_db(): ("CN", "China", 86, "CNY", "Asia/Shanghai"), ] + accounts = [] + for first in first_names: + provider = random.choice([e.value for e in AccountProvider]) + account = Account(username=f"{first.lower()}_{provider}", provider=provider) + accounts.append(account) + db.session.add_all(accounts) + user_list = [] for i in range(len(first_names)): user = User() @@ -42,6 +99,7 @@ def build_sample_db(): user.first_name = first_names[i] user.last_name = last_names[i] user.email = first_names[i].lower() + "@example.com" + user.account = accounts[i] user.website = "https://www.example.com" user.ip_address = "127.0.0.1" @@ -51,71 +109,92 @@ def build_sample_db(): user.timezone = country[4] user.dialling_code = country[2] - user.local_phone_number = '0' + ''.join(random.choices('123456789', k=9)) + user.local_phone_number = "0" + "".join(random.choices("123456789", k=9)) user_list.append(user) db.session.add(user) - # Create sample Tags tag_list = [] - for tmp in ["YELLOW", "WHITE", "BLUE", "GREEN", "RED", "BLACK", "BROWN", "PURPLE", "ORANGE"]: + for tmp in [ + "YELLOW", + "WHITE", + "BLUE", + "GREEN", + "RED", + "BLACK", + "BROWN", + "PURPLE", + "ORANGE", + ]: tag = Tag() tag.name = tmp tag_list.append(tag) db.session.add(tag) - # Create sample Posts sample_text = [ { - 'title': "de Finibus Bonorum et Malorum - Part I", - 'content': "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor \ -incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ -exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure \ -dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. \ -Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt \ -mollit anim id est laborum." + "title": "de Finibus Bonorum et Malorum - Part I", + "content": ( + "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do " + "eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim " + "ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut " + "aliquip ex ea commodo consequat. Duis aute irure dolor in " + "reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla " + "pariatur. Excepteur sint occaecat cupidatat non proident, sunt in " + "culpa qui officia deserunt mollit anim id est laborum." + ), }, { - 'title': "de Finibus Bonorum et Malorum - Part II", - 'content': "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque \ -laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto \ -beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur \ -aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi \ -nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, \ -adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam \ -aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam \ -corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum \ -iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum \ -qui dolorem eum fugiat quo voluptas nulla pariatur?" + "title": "de Finibus Bonorum et Malorum - Part II", + "content": ( + "Sed ut perspiciatis unde omnis iste natus error sit voluptatem " + "accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae " + "ab illo inventore veritatis et quasi architecto beatae vitae dicta " + "sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit " + "aspernatur aut odit aut fugit, sed quia consequuntur magni dolores " + "eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, " + "qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, " + "sed quia non numquam eius modi tempora incidunt ut labore et dolore " + "magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis " + "nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut " + "aliquid ex ea commodi consequatur? Quis autem vel eum iure " + "reprehenderit qui in ea voluptate velit esse quam nihil molestiae " + "consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla " + "pariatur?" + ), }, { - 'title': "de Finibus Bonorum et Malorum - Part III", - 'content': "At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium \ -voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati \ -cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id \ -est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam \ -libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod \ -maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. \ -Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet \ -ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur \ -a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis \ -doloribus asperiores repellat." - } + "title": "de Finibus Bonorum et Malorum - Part III", + "content": ( + "At vero eos et accusamus et iusto odio dignissimos ducimus qui " + "blanditiis praesentium voluptatum deleniti atque corrupti quos " + "dolores et quas molestias excepturi sint occaecati cupiditate non " + "provident, similique sunt in culpa qui officia deserunt mollitia " + "animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis " + "est et expedita distinctio. Nam libero tempore, cum soluta nobis est " + "eligendi optio cumque nihil impedit quo minus id quod maxime placeat " + "facere possimus, omnis voluptas assumenda est, omnis dolor " + "repellendus. Temporibus autem quibusdam et aut officiis debitis aut " + "rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint " + "et molestiae non recusandae. Itaque earum rerum hic tenetur a " + "sapiente delectus, ut aut reiciendis voluptatibus maiores alias " + "consequatur aut perferendis doloribus asperiores repellat." + ), + }, ] for user in user_list: - entry = random.choice(sample_text) # select text at random + entry = random.choice(sample_text) post = Post() post.user = user - post.title = "{}'s opinion on {}".format(user.first_name, entry['title']) - post.text = entry['content'] + post.title = "{}'s opinion on {}".format(user.first_name, entry["title"]) + post.text = entry["content"] post.background_color = random.choice(["#cccccc", "red", "lightblue", "#0f0"]) - tmp = int(1000 * random.random()) # random number between 0 and 1000: - post.date = datetime.datetime.now() - datetime.timedelta(days=tmp) - post.tags = random.sample(tag_list, 2) # select a couple of tags at random + tmp_days = int(1000 * random.random()) + post.date = datetime.datetime.now() - datetime.timedelta(days=tmp_days) + post.tags = random.sample(tag_list, 2) db.session.add(post) - # Create a sample Tree structure trunk = Tree(name="Trunk") db.session.add(trunk) for i in range(5): diff --git a/examples/sqla/admin/main.py b/examples/sqla/admin/main.py index 3fc7878a46..64e02a5005 100644 --- a/examples/sqla/admin/main.py +++ b/examples/sqla/admin/main.py @@ -1,21 +1,30 @@ -from admin import app, db -from admin.models import AVAILABLE_USER_TYPES, User, Post, Tag, Tree -from flask import Markup, send_file - -from wtforms import validators - -import flask_admin as admin +from flask import redirect +from flask import url_for +from flask_admin import Admin +from flask_admin.babel import gettext from flask_admin.base import MenuLink -from flask_admin.contrib import sqla from flask_admin.contrib.sqla import filters -from flask_admin.contrib.sqla.filters import BaseSQLAFilter, FilterEqual -from flask_admin.babel import gettext +from flask_admin.contrib.sqla import ModelView +from flask_admin.contrib.sqla.filters import BaseSQLAFilter +from flask_admin.contrib.sqla.filters import FilterEqual +from flask_admin.form import rules +from flask_admin.theme import Bootstrap4Theme +from markupsafe import Markup +from wtforms import validators +from . import app +from . import db +from .models import AccountProvider +from .models import AVAILABLE_USER_TYPES +from .models import Post +from .models import Tag +from .models import Tree +from .models import User -# Flask views -@app.route('/') + +@app.route("/") def index(): - tmp = u""" + tmp = """

Click me to get to Admin! (English)

Click me to get to Admin! (Czech)

Click me to get to Admin! (German)

@@ -26,229 +35,294 @@ def index():

Click me to get to Admin! (Russian)

Click me to get to Admin! (Punjabi)

Click me to get to Admin! (Chinese - Simplified)

-

Click me to get to Admin! (Chinese - Traditional)

+

+ Click me to get to Admin! (Chinese - Traditional) +

""" return tmp -@app.route('/favicon.ico') +@app.route("/favicon.ico") def favicon(): - return send_file('static/favicon.ico') + return redirect(url_for("static", filename="/favicon.ico")) # Custom filter class class FilterLastNameBrown(BaseSQLAFilter): def apply(self, query, value, alias=None): - if value == '1': + if value == "1": return query.filter(self.column == "Brown") else: return query.filter(self.column != "Brown") def operation(self): - return 'is Brown' + return "is Brown" # Customized User model admin def phone_number_formatter(view, context, model, name): - return Markup("{}".format(model.phone_number)) if model.phone_number else None + return Markup(f"{model.phone_number}") if model.phone_number else None def is_numberic_validator(form, field): if field.data and not field.data.isdigit(): - raise validators.ValidationError(gettext('Only numbers are allowed.')) - + raise validators.ValidationError(gettext("Only numbers are allowed.")) -class UserAdmin(sqla.ModelView): - can_view_details = True # show a modal dialog with records details - action_disallowed_list = ['delete', ] +class UserAdmin(ModelView): + can_set_page_size = True + page_size = 5 + page_size_options = (5, 10, 15) + can_view_details = True + action_disallowed_list = [ + "delete", + ] form_choices = { - 'type': AVAILABLE_USER_TYPES, + "type": AVAILABLE_USER_TYPES, } form_args = { - 'dialling_code': {'label': 'Dialling code'}, - 'local_phone_number': { - 'label': 'Phone number', - 'validators': [is_numberic_validator] + "dialling_code": {"label": "Dialling code"}, + "local_phone_number": { + "label": "Phone number", + "validators": [is_numberic_validator], }, } - form_widget_args = { - 'id': { - 'readonly': True - } - } + form_widget_args = {"id": {"readonly": True}} column_list = [ - 'type', - 'first_name', - 'last_name', - 'email', - 'ip_address', - 'currency', - 'timezone', - 'phone_number', + "type", + "first_name", + "last_name", + "email", + "ip_address", + "currency", + "timezone", + "phone_number", ] column_searchable_list = [ - 'first_name', - 'last_name', - 'phone_number', - 'email', + "first_name", + "last_name", + "phone_number", + "email", ] - column_editable_list = ['type', 'currency', 'timezone'] + column_editable_list = ["type", "currency", "timezone"] column_details_list = [ - 'id', - 'featured_post', - 'website', - 'enum_choice_field', - 'sqla_utils_choice_field', - 'sqla_utils_enum_choice_field', - ] + column_list + "id", + "featured_post", + "website", + "enum_choice_field", + "sqla_utils_choice_field", + "sqla_utils_enum_choice_field", + ] + column_list # type: ignore[operator] form_columns = [ - 'id', - 'type', - 'featured_post', - 'enum_choice_field', - 'sqla_utils_choice_field', - 'sqla_utils_enum_choice_field', - 'last_name', - 'first_name', - 'email', - 'website', - 'dialling_code', - 'local_phone_number', + "id", + "type", + "featured_post", + "enum_choice_field", + "sqla_utils_choice_field", + "sqla_utils_enum_choice_field", + "last_name", + "first_name", + "email", + "website", + "dialling_code", + "local_phone_number", + "ip_address", + "timezone", ] + create_template = "admin/users/create.html" form_create_rules = [ - 'last_name', - 'first_name', - 'type', - 'email', + rules.Header("Users"), # HTML header + rules.HTML("
"), # HTML horizontal line + "last_name", # show it as first field + "first_name", # show it as second field + rules.Text("Foobar"), # static text + rules.FieldSet( + ( + "type", + rules.Row( + rules.Group( + "dialling_code", + prepend="➕", + append='', + ), + "local_phone_number", + "email", + ), + rules.Text("--- The end of contact details ---"), + ), + "Contact details:", + ), # field set with legend + # custom container (see templates/admin/create.html) + rules.Container( + "wrap_in_card", + rules.NestedRule( + separator="
", + rules=[ + rules.Field("enum_choice_field"), + rules.Field("sqla_utils_choice_field"), + rules.Field("sqla_utils_enum_choice_field"), + ], + ), + card_title="Some Choices", + ), + "website", + "ip_address", + "timezone", + # render a macro (see templates/admin/create.html) + rules.Macro("my_macro", arg1="Just a Title", arg2="bla bla bla"), ] column_auto_select_related = True - column_default_sort = [('last_name', False), ('first_name', False)] # sort on multiple columns + column_default_sort = [ + ("last_name", False), + ("first_name", False), + ] # sort on multiple columns - # custom filter: each filter in the list is a filter operation (equals, not equals, etc) - # filters with the same name will appear as operations under the same filter + # custom filter: each filter in the list is a filter operation (equals, not equals, + # etc) filters with the same name will appear as operations under the same filter column_filters = [ - 'first_name', - FilterEqual(column=User.last_name, name='Last Name'), - FilterLastNameBrown(column=User.last_name, name='Last Name', - options=(('1', 'Yes'), ('0', 'No'))), - 'phone_number', - 'email', - 'ip_address', - 'currency', - 'timezone', + "first_name", + FilterEqual(column=User.last_name, name="Last Name"), + FilterLastNameBrown( + column=User.last_name, name="Last Name", options=(("1", "Yes"), ("0", "No")) + ), + "phone_number", + "email", + "ip_address", + "currency", + "timezone", ] - column_formatters = {'phone_number': phone_number_formatter} + column_formatters = {"phone_number": phone_number_formatter} - # setup edit forms so that only posts created by this user can be selected as 'featured' - def edit_form(self, obj): - return self._filtered_posts( - super(UserAdmin, self).edit_form(obj) - ) + # setup edit forms so that only posts created by this user can be selected as + # 'featured' + def edit_form(self, obj): # type: ignore[override] + return self._filtered_posts(super().edit_form(obj)) # type: ignore[override] def _filtered_posts(self, form): - form.featured_post.query_factory = lambda: Post.query.filter(Post.user_id == form._obj.id).all() + form.featured_post.query_factory = lambda: Post.query.filter( + Post.user_id == form._obj.id + ).all() return form -# Customized Post model admin -class PostAdmin(sqla.ModelView): +class PostAdmin(ModelView): column_display_pk = True - column_list = ['id', 'user', 'title', 'date', 'tags', 'background_color', 'created_at', ] - column_editable_list = ['background_color', ] - column_default_sort = ('date', True) + column_list = [ + "id", + "user", + "user.email", + "title", + "date", + "tags", + "background_color", + "created_at", + ] + column_editable_list = [ + "background_color", + ] + column_default_sort = ("date", True) create_modal = True edit_modal = True column_sortable_list = [ - 'id', - 'title', - 'date', - ('user', ('user.last_name', 'user.first_name')), # sort on multiple columns + "id", + "title", + "date", + "user.email", + ("user", ("user.last_name", "user.first_name")), # sort on multiple columns ] - column_labels = { - 'title': 'Post Title' # Rename 'title' column in list view - } column_searchable_list = [ - 'title', - 'tags.name', - 'user.first_name', - 'user.last_name', + "title", + "tags.name", + "user.first_name", + "user.last_name", ] column_labels = { - 'title': 'Title', - 'tags.name': 'Tags', - 'user.first_name': 'User\'s first name', - 'user.last_name': 'Last name', + "title": "Post Title", + "tags.name": "Tags", + "user.first_name": "User's first name", + "user.last_name": "Last name", } column_filters = [ - 'id', - 'user.first_name', - 'user.id', - 'background_color', - 'created_at', - 'title', - 'date', - 'tags', - filters.FilterLike(Post.title, 'Fixed Title', options=(('test1', 'Test 1'), ('test2', 'Test 2'))), + "id", + "user.first_name", + "user.id", + "background_color", + "created_at", + "title", + "date", + "tags", + filters.FilterLike( + Post.title, + "Fixed Title", + options=(("test1", "Test 1"), ("test2", "Test 2")), + ), + # Filter instances also accept a dotted-path string for ``column``; + # it is resolved against the view's model and the necessary joins + # are added automatically. Paths can traverse multiple + # relationships, e.g. Post -> user -> account -> username. + filters.FilterLike(column="user.email", name="Author Email"), + filters.FilterInList( + column="user.account.provider", + name="Author's Account Provider", + options=[(e.value, e.value) for e in AccountProvider], + ), ] can_export = True export_max_rows = 1000 - export_types = ['csv', 'xls'] + export_types = ["csv", "xls"] # Pass arguments to WTForms. In this case, change label for text field to # be 'Big Text' and add DataRequired() validator. - form_args = { - 'text': dict(label='Big Text', validators=[validators.DataRequired()]) - } - form_widget_args = { - 'text': { - 'rows': 10 - } - } + form_args = {"text": dict(label="Big Text", validators=[validators.DataRequired()])} + form_widget_args = {"text": {"rows": 10}} form_ajax_refs = { - 'user': { - 'fields': (User.first_name, User.last_name) - }, - 'tags': { - 'fields': (Tag.name,), - 'minimum_input_length': 0, # show suggestions, even before any user input - 'placeholder': 'Please select', - 'page_size': 5, + "user": {"fields": (User.first_name, User.last_name)}, + "tags": { + "fields": (Tag.name,), + "minimum_input_length": 0, # show suggestions, even before any user input + "placeholder": "Please select", + "page_size": 5, }, } def __init__(self, session): # Just call parent class with predefined model. - super(PostAdmin, self).__init__(Post, session) + super().__init__(Post, session) -class TreeView(sqla.ModelView): - list_template = 'tree_list.html' +class TreeView(ModelView): + list_template = "tree_list.html" column_auto_select_related = True column_list = [ - 'id', - 'name', - 'parent', + "id", + "name", + "parent", + ] + form_excluded_columns = [ + "children", + ] + column_filters = [ + "id", + "name", + "parent", ] - form_excluded_columns = ['children', ] - column_filters = ['id', 'name', 'parent', ] # override the 'render' method to pass your own parameters to the template def render(self, template, **kwargs): - return super(TreeView, self).render(template, foo="bar", **kwargs) + return super().render(template, foo="bar", **kwargs) -# Create admin -admin = admin.Admin(app, name='Example: SQLAlchemy', template_mode='bootstrap4') +admin = Admin(app, name="Example: SQLAlchemy", theme=Bootstrap4Theme(swatch="default")) -# Add views -admin.add_view(UserAdmin(User, db.session)) -admin.add_view(sqla.ModelView(Tag, db.session)) -admin.add_view(PostAdmin(db.session)) -admin.add_view(TreeView(Tree, db.session, category="Other")) +admin.add_view(UserAdmin(User, db)) +admin.add_view(ModelView(Tag, db)) +admin.add_view(PostAdmin(db)) +admin.add_view(TreeView(Tree, db, category="Other")) admin.add_sub_category(name="Links", parent_name="Other") -admin.add_link(MenuLink(name='Back Home', url='/', category='Links')) -admin.add_link(MenuLink(name='External link', url='http://www.example.com/', category='Links')) +admin.add_link(MenuLink(name="Back Home", url="/", category="Links")) +admin.add_link( + MenuLink(name="External link", url="http://www.example.com/", category="Links") +) diff --git a/examples/sqla/admin/models.py b/examples/sqla/admin/models.py index 25d1f1a275..0b041893d2 100644 --- a/examples/sqla/admin/models.py +++ b/examples/sqla/admin/models.py @@ -1,19 +1,38 @@ -from admin import db -from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy import sql, cast +import enum import uuid +from typing import Optional -from sqlalchemy_utils import ChoiceType, EmailType, UUIDType, URLType, CurrencyType -from sqlalchemy_utils import ColorType, ArrowType, IPAddressType, TimezoneType import arrow -import enum - +from admin import db +from sqlalchemy import cast +from sqlalchemy import Column +from sqlalchemy import Date +from sqlalchemy import Enum +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import sql +from sqlalchemy import String +from sqlalchemy import Table +from sqlalchemy import Text +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from sqlalchemy.orm import relationship +from sqlalchemy_utils import ArrowType +from sqlalchemy_utils import ChoiceType +from sqlalchemy_utils import ColorType +from sqlalchemy_utils import CurrencyType +from sqlalchemy_utils import EmailType +from sqlalchemy_utils import IPAddressType +from sqlalchemy_utils import TimezoneType +from sqlalchemy_utils import URLType +from sqlalchemy_utils import UUIDType AVAILABLE_USER_TYPES = [ - (u'admin', u'Admin'), - (u'content-writer', u'Content writer'), - (u'editor', u'Editor'), - (u'regular-user', u'Regular user'), + ("admin", "Admin"), + ("content-writer", "Content writer"), + ("editor", "Editor"), + ("regular-user", "Regular user"), ] @@ -22,92 +41,143 @@ class EnumChoices(enum.Enum): second = 2 -# Create models -class User(db.Model): - id = db.Column(UUIDType(binary=False), default=uuid.uuid4, primary_key=True) +class AccountProvider(enum.Enum): + GOOGLE = "google" + FACEBOOK = "facebook" + GITHUB = "github" - # use a regular string field, for which we can specify a list of available choices later on - type = db.Column(db.String(100)) - # fixed choices can be handled in a number of different ways: - enum_choice_field = db.Column(db.Enum(EnumChoices), nullable=True) - sqla_utils_choice_field = db.Column(ChoiceType(AVAILABLE_USER_TYPES), nullable=True) - sqla_utils_enum_choice_field = db.Column(ChoiceType(EnumChoices, impl=db.Integer()), nullable=True) +class Account(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + username: Mapped[str] = mapped_column(String(64), unique=True) + provider: Mapped[str] = mapped_column(ChoiceType(AccountProvider)) - first_name = db.Column(db.String(100)) - last_name = db.Column(db.String(100)) - - # some sqlalchemy_utils data types (see https://sqlalchemy-utils.readthedocs.io/) - email = db.Column(EmailType, unique=True, nullable=False) - website = db.Column(URLType) - ip_address = db.Column(IPAddressType) - currency = db.Column(CurrencyType, nullable=True, default=None) - timezone = db.Column(TimezoneType(backend='pytz')) + def __str__(self): + return f"{self.provider}:{self.username}" - dialling_code = db.Column(db.Integer()) - local_phone_number = db.Column(db.String(10)) - featured_post_id = db.Column(db.Integer, db.ForeignKey('post.id')) - featured_post = db.relationship('Post', foreign_keys=[featured_post_id]) +class User(db.Model): + id: Mapped[UUIDType] = mapped_column( + UUIDType(binary=False), default=uuid.uuid4, primary_key=True + ) + + # use a regular string field, for which we can specify a list of available choices + # later on + type: Mapped[str] = mapped_column(String(100)) + + account_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("account.id"), nullable=True + ) + account: Mapped["Account | None"] = relationship("Account") + + # Fixed choices can be handled in a number of different ways: + enum_choice_field: Mapped[Enum] = mapped_column(Enum(EnumChoices), nullable=True) # type: ignore[var-annotated] + sqla_utils_choice_field: Mapped[ChoiceType] = mapped_column( + ChoiceType(AVAILABLE_USER_TYPES), nullable=True + ) + sqla_utils_enum_choice_field: Mapped[ChoiceType] = mapped_column( + ChoiceType(EnumChoices, impl=Integer()), nullable=True + ) + + first_name: Mapped[str] = mapped_column(String(100)) + last_name: Mapped[str] = mapped_column(String(100)) + + # Some sqlalchemy_utils data types (see https://sqlalchemy-utils.readthedocs.io/) + email: Mapped[EmailType] = mapped_column(EmailType, unique=True, nullable=False) + website: Mapped[URLType] = mapped_column(URLType) + ip_address: Mapped[IPAddressType] = mapped_column(IPAddressType) + currency: Mapped[CurrencyType] = mapped_column( + CurrencyType, nullable=True, default=None + ) + timezone: Mapped[TimezoneType] = mapped_column(TimezoneType(backend="pytz")) + + dialling_code: Mapped[int] = mapped_column(Integer()) + local_phone_number: Mapped[str] = mapped_column(String(10)) + + featured_post_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey( + "post.id", + use_alter=True, + name="fk_user_featured_post", + ), + nullable=True, + ) + featured_post: Mapped["Post | None"] = relationship( + "Post", foreign_keys=[featured_post_id] + ) @hybrid_property def phone_number(self): if self.dialling_code and self.local_phone_number: number = str(self.local_phone_number) - return "+{} ({}) {} {} {}".format(self.dialling_code, number[0], number[1:3], number[3:6], number[6::]) + return ( + f"+{self.dialling_code} ({number[0]}) {number[1:3]} " + f"{number[3:6]} {number[6::]}" + ) return - @phone_number.expression + @phone_number.expression # type: ignore[no-redef] def phone_number(cls): - return sql.operators.ColumnOperators.concat(cast(cls.dialling_code, db.String), cls.local_phone_number) + return sql.operators.ColumnOperators.concat( + cast(cls.dialling_code, String), cls.local_phone_number + ) def __str__(self): - return "{}, {}".format(self.last_name, self.first_name) + return f"{self.last_name}, {self.first_name}" def __repr__(self): - return "{}: {}".format(self.id, self.__str__()) + return f"{self.id}: {self.__str__()}" # Create M2M table -post_tags_table = db.Table('post_tags', db.Model.metadata, - db.Column('post_id', db.Integer, db.ForeignKey('post.id')), - db.Column('tag_id', db.Integer, db.ForeignKey('tag.id')) - ) +post_tags_table = Table( + "post_tags", + db.Model.metadata, + Column("post_id", Integer, ForeignKey("post.id"), primary_key=True), + Column("tag_id", Integer, ForeignKey("tag.id"), primary_key=True), +) -class Post(db.Model): - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.String(120)) - text = db.Column(db.Text, nullable=False) - date = db.Column(db.Date) +class Tag(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64), unique=True) - # some sqlalchemy_utils data types (see https://sqlalchemy-utils.readthedocs.io/) - background_color = db.Column(ColorType) - created_at = db.Column(ArrowType, default=arrow.utcnow()) - user_id = db.Column(UUIDType(binary=False), db.ForeignKey(User.id)) + def __str__(self): + return f"{self.name}" - user = db.relationship(User, foreign_keys=[user_id], backref='posts') - tags = db.relationship('Tag', secondary=post_tags_table) - def __str__(self): - return "{}".format(self.title) +class Post(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + title: Mapped[str] = mapped_column(String(120)) + text: Mapped[str] = mapped_column(Text, nullable=False) + date: Mapped[Date] = mapped_column(Date) + # some sqlalchemy_utils data types (see https://sqlalchemy-utils.readthedocs.io/) + background_color: Mapped[ColorType] = mapped_column(ColorType) + created_at: Mapped[ArrowType] = mapped_column(ArrowType, default=arrow.utcnow) + user_id: Mapped[UUIDType] = mapped_column( + UUIDType(binary=False), ForeignKey(User.id) + ) -class Tag(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.Unicode(64), unique=True) + user: Mapped[User] = relationship(User, foreign_keys=[user_id], backref="posts") + tags: Mapped[list[Tag]] = relationship("Tag", secondary=post_tags_table) def __str__(self): - return "{}".format(self.name) + return f"{self.title}" class Tree(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(64)) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64)) # recursive relationship - parent_id = db.Column(db.Integer, db.ForeignKey('tree.id')) - parent = db.relationship('Tree', remote_side=[id], backref='children') + parent_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("tree.id"), nullable=True + ) + parent: Mapped[Optional["Tree"]] = relationship( + "Tree", remote_side=[id], backref="children" + ) def __str__(self): - return "{}".format(self.name) + return f"{self.name}" diff --git a/examples/sqla/admin/templates/admin/users/create.html b/examples/sqla/admin/templates/admin/users/create.html new file mode 100644 index 0000000000..611e429e7c --- /dev/null +++ b/examples/sqla/admin/templates/admin/users/create.html @@ -0,0 +1,29 @@ +{% extends 'admin/model/create.html' %} + + +{% macro wrap_in_card( card_title ) %} + +
+
+
{{ card_title }}
+
+
+ {{ caller() }} +
+
+ +{% endmacro %} + + +{% macro my_macro(arg1, arg2) %} + +
+
+
{{ arg1 }}
+
+
+ {{ arg2 }} +
+
+ +{% endmacro %} diff --git a/examples/sqla/main.py b/examples/sqla/main.py new file mode 100644 index 0000000000..aab49176ed --- /dev/null +++ b/examples/sqla/main.py @@ -0,0 +1,16 @@ +import os +import os.path as op + +from admin import app +from admin.data import build_sample_db +from jinja2 import StrictUndefined + +if __name__ == "__main__": + app_dir = op.join(op.realpath(os.path.dirname(__file__)), "admin") + database_path = op.join(app_dir, app.config["DATABASE_FILE"]) + if not os.path.exists(database_path): + with app.app_context(): + build_sample_db() + + app.jinja_env.undefined = StrictUndefined + app.run(debug=True) diff --git a/examples/sqla/pyproject.toml b/examples/sqla/pyproject.toml new file mode 100644 index 0000000000..5c82d86964 --- /dev/null +++ b/examples/sqla/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "example-sqla" +version = "0.1.0" +description = "SQLAlchemy Model Backend Example." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin[sqlalchemy-with-utils,export,translation]", +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/examples/sqla/requirements.txt b/examples/sqla/requirements.txt deleted file mode 100644 index b965f9bd84..0000000000 --- a/examples/sqla/requirements.txt +++ /dev/null @@ -1,13 +0,0 @@ -Flask -Flask-Admin -Flask-BabelEx -Flask-SQLAlchemy -tablib -enum34; python_version < '3.0' -sqlalchemy_utils -arrow -colour - -# note: for local development, replace 'Flask-Admin' above with a reference to -# your local copy of the repo e.g. '-e .' if you're installing this from the -# repo's root directory diff --git a/examples/sqla/run_server.py b/examples/sqla/run_server.py deleted file mode 100644 index 8d8c3e0674..0000000000 --- a/examples/sqla/run_server.py +++ /dev/null @@ -1,14 +0,0 @@ -from admin import app -from admin.data import build_sample_db -import os -import os.path as op - -# Build a sample db on the fly, if one does not exist yet. -app_dir = op.join(op.realpath(os.path.dirname(__file__)), 'admin') -database_path = op.join(app_dir, app.config['DATABASE_FILE']) -if not os.path.exists(database_path): - build_sample_db() - -if __name__ == '__main__': - # Start app - app.run(debug=True) diff --git a/examples/sqla_association_proxy/.python-version b/examples/sqla_association_proxy/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/sqla_association_proxy/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/sqla_association_proxy/README.md b/examples/sqla_association_proxy/README.md new file mode 100644 index 0000000000..8fff8685ef --- /dev/null +++ b/examples/sqla_association_proxy/README.md @@ -0,0 +1,22 @@ +# SQLAlchemy Association Proxy Example + +Example of how to use (and filter on) an association proxy with the SQLAlchemy backend. + +For information about association proxies and how to use them, please visit the [docs](https://docs.sqlalchemy.org/en/latest/orm/extensions/associationproxy.html) + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/sqla-association-proxy +``` + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` diff --git a/examples/sqla_association_proxy/__init__.py b/examples/sqla_association_proxy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/sqla_association_proxy/main.py b/examples/sqla_association_proxy/main.py new file mode 100644 index 0000000000..cd545b038c --- /dev/null +++ b/examples/sqla_association_proxy/main.py @@ -0,0 +1,120 @@ +from flask import Flask +from flask_admin import Admin +from flask_admin.contrib.sqla import ModelView +from flask_admin.theme import Bootstrap4Theme +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.associationproxy import AssociationProxy +from sqlalchemy.orm import backref +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from sqlalchemy.orm import relationship + +app = Flask(__name__) +app.config["SECRET_KEY"] = "secret" +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite://" +app.config["SQLALCHEMY_ECHO"] = False +db = SQLAlchemy(app) +admin = Admin( + app, + name="Example: SQLAlchemy Association Proxy", + theme=Bootstrap4Theme(), +) + + +@app.route("/") +def index(): + return 'Click me to get to Admin!' + + +class User(db.Model): + __tablename__ = "user" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64)) + + # Association proxy of "user_keywords" collection to "keyword" attribute - a list + # of keywords objects. + keywords: AssociationProxy[list[str]] = association_proxy( + "user_keywords", "keyword" + ) + # Association proxy to association proxy - a list of keywords strings. + keywords_values: AssociationProxy[list[str]] = association_proxy( + "user_keywords", "keyword_value" + ) + + def __init__(self, name=None): + self.name = name + + +class UserKeyword(db.Model): + __tablename__ = "user_keyword" + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), primary_key=True + ) + keyword_id: Mapped[int] = mapped_column( + Integer, ForeignKey("keyword.id"), primary_key=True + ) + special_key: Mapped[str] = mapped_column(String(50), nullable=True) + + # Bidirectional attribute/collection of "user"/"user_keywords" + user: Mapped[User] = relationship( + User, backref=backref("user_keywords", cascade="all, delete-orphan") + ) + + # Reference to the "Keyword" object + keyword: Mapped["Keyword"] = relationship("Keyword") + # Reference to the "keyword" column inside the "Keyword" object. + keyword_value: AssociationProxy[list[str]] = association_proxy("keyword", "keyword") + + def __init__(self, keyword=None, user=None, special_key=None): + self.user = user + self.keyword = keyword + self.special_key = special_key + + +class Keyword(db.Model): + __tablename__ = "keyword" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + keyword: Mapped[str] = mapped_column("keyword", String(64)) + + def __init__(self, keyword=None): + self.keyword = keyword + + def __repr__(self): + return f"Keyword({repr(self.keyword)})" + + +class UserAdmin(ModelView): + """Flask-admin can not automatically find a association_proxy yet. You will + need to manually define the column in list_view/filters/sorting/etc. + Moreover, support for association proxies to association proxies + (e.g.: keywords_values) is currently limited to column_list only.""" + + column_list = ("id", "name", "keywords", "keywords_values") + column_sortable_list = ("id", "name") + column_filters = ("id", "name", "keywords") + form_columns = ("name", "keywords") + + +class KeywordAdmin(ModelView): + column_list = ("id", "keyword") + + +if __name__ == "__main__": + admin.add_view(UserAdmin(User, db)) + admin.add_view(KeywordAdmin(Keyword, db)) + + with app.app_context(): + db.create_all() + user = User("log") + + for kw in (Keyword("new_from_blammo"), Keyword("its_big")): + user.keywords.append(kw) + + db.session.add(user) + db.session.commit() + + app.run(debug=True) diff --git a/examples/sqla_association_proxy/pyproject.toml b/examples/sqla_association_proxy/pyproject.toml new file mode 100644 index 0000000000..ec0b2f409a --- /dev/null +++ b/examples/sqla_association_proxy/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "example-sqla-association-proxy" +version = "0.1.0" +description = "SQLAlchemy Association Proxy Example." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin[sqlalchemy-with-utils]" +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/examples/sqla_custom_inline_forms/.python-version b/examples/sqla_custom_inline_forms/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/sqla_custom_inline_forms/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/sqla_custom_inline_forms/README.md b/examples/sqla_custom_inline_forms/README.md new file mode 100644 index 0000000000..f106947b5d --- /dev/null +++ b/examples/sqla_custom_inline_forms/README.md @@ -0,0 +1,20 @@ +# SQLAlchemy Custom Inline Forms Example + +This example shows how to use inline forms when working with related models. + +## How to run this example + +Clone the repository and navigate to this example: + +```shell +git clone https://github.com/pallets-eco/flask-admin.git +cd flask-admin/examples/sqla-custom-inline-forms +``` + +> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment. + +Run the example using `uv`, which will manage the environment and dependencies automatically: + +```shell +uv run main.py +``` diff --git a/examples/sqla_custom_inline_forms/__init__.py b/examples/sqla_custom_inline_forms/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/sqla_custom_inline_forms/main.py b/examples/sqla_custom_inline_forms/main.py new file mode 100644 index 0000000000..f60253f8c4 --- /dev/null +++ b/examples/sqla_custom_inline_forms/main.py @@ -0,0 +1,184 @@ +import os +import os.path as op + +from flask import Flask +from flask import render_template +from flask import request +from flask_admin import Admin +from flask_admin.contrib.sqla import ModelView +from flask_admin.contrib.sqla.ajax import QueryAjaxModelLoader +from flask_admin.contrib.sqla.fields import InlineModelFormList +from flask_admin.contrib.sqla.form import InlineModelConverter +from flask_admin.form import RenderTemplateWidget +from flask_admin.model.form import InlineFormAdmin +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import event +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from sqlalchemy.orm import relationship +from werkzeug.utils import secure_filename +from wtforms import fields + +app = Flask(__name__) +app.config["SECRET_KEY"] = "secret" +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///db.sqlite" +app.config["SQLALCHEMY_ECHO"] = False +db = SQLAlchemy(app) +admin = Admin(app, name="Example: Custom Inline Forms") + +# Figure out base upload path +base_path = op.join(op.dirname(__file__), "static") + + +class Location(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64)) + + images = relationship( + "LocationImage", back_populates="location", cascade="all, delete-orphan" + ) + + +class ImageType(db.Model): + """ + Just so the LocationImage can have another foreign key, + so we can test the "form_ajax_refs" inside the "inline_models" + """ + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64)) + + def __repr__(self): + """ + Represent this model as a string + (e.g. in the Image Type list dropdown when creating an inline model) + """ + return self.name + + +class LocationImage(db.Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + alt: Mapped[str] = mapped_column(String(128)) + path: Mapped[str] = mapped_column(String(64)) + + location_id: Mapped[int] = mapped_column( + Integer, ForeignKey(Location.id), nullable=False + ) + location: Mapped[list[Location]] = relationship(Location, back_populates="images") + + image_type_id: Mapped[int] = mapped_column( + Integer, ForeignKey(ImageType.id), nullable=False + ) + image_type: Mapped[list[ImageType]] = relationship(ImageType, backref="images") + + +# Register after_delete handler which will delete image file after model gets deleted +@event.listens_for(Location, "after_delete") +def _handle_image_delete(mapper, conn, target): + for location_image in target.images: + try: + if location_image.path: + os.remove(op.join(base_path, location_image.path)) + except: # noqa: E722 + pass + + +# This widget uses custom template for inline field list +class CustomInlineFieldListWidget(RenderTemplateWidget): + def __init__(self): + super().__init__("field_list.html") + + +# This InlineModelFormList will use our custom widget and hide row controls +class CustomInlineModelFormList(InlineModelFormList): + widget = CustomInlineFieldListWidget() # type: ignore[assignment] + + def display_row_controls(self, field): + return False + + +# Create custom InlineModelConverter and tell it to use our InlineModelFormList +class CustomInlineModelConverter(InlineModelConverter): + inline_field_list_type = CustomInlineModelFormList + + +# Customized inline form handler +class LocationImageInlineModelForm(InlineFormAdmin): + form_excluded_columns = ("path",) + + form_label = "Image" + + # Setup AJAX lazy-loading for the ImageType inside the inline model + form_ajax_refs = { + "image_type": QueryAjaxModelLoader( + name="image_type", + session=db, + model=ImageType, + fields=("name",), + order_by="name", + placeholder=( + "Please use an AJAX query to select an image type for the image" + ), + minimum_input_length=0, + ) + } + + def __init__(self): + super().__init__(LocationImage) + + def postprocess_form(self, form_class): + form_class.upload = fields.FileField("Image") + return form_class + + def on_model_change(self, form, model, is_created): + file_data = request.files.get(form.upload.name) + + if file_data: + model.path = secure_filename(file_data.filename) # type: ignore[arg-type] + file_data.save(op.join(base_path, model.path)) + + +class LocationAdmin(ModelView): + inline_model_form_converter = CustomInlineModelConverter + + inline_models = (LocationImageInlineModelForm(),) + + def __init__(self): + super().__init__(Location, db, name="Locations") + + +@app.route("/") +def index(): + locations = db.session.query(Location).all() + return render_template("locations.html", locations=locations) + + +def first_time_setup(): + """Run this to setup the database for the first time""" + with app.app_context(): + db.drop_all() + db.create_all() + + # Add some image types for the form_ajax_refs inside the inline_model + image_types = ("JPEG", "PNG", "GIF") + for image_type in image_types: + model = ImageType(name=image_type) + db.session.add(model) + + db.session.commit() + + +if __name__ == "__main__": + try: + os.mkdir(base_path) + except OSError: + pass + + first_time_setup() + + admin.add_view(LocationAdmin()) + + app.run(debug=True) diff --git a/examples/sqla_custom_inline_forms/pyproject.toml b/examples/sqla_custom_inline_forms/pyproject.toml new file mode 100644 index 0000000000..6acd053469 --- /dev/null +++ b/examples/sqla_custom_inline_forms/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "example-sqla-custom-inline-forms" +version = "0.1.0" +description = "SQLAlchemy Custom Inline Forms Example." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flask-admin[sqlalchemy-with-utils]", +] + +[tool.uv.sources] +flask-admin = { path = "../../", editable = true } diff --git a/examples/sqla-custom-inline-forms/static/7b1468ff-019a-44d1-b4bb-729e5a252899.jpg b/examples/sqla_custom_inline_forms/static/7b1468ff-019a-44d1-b4bb-729e5a252899.jpg similarity index 100% rename from examples/sqla-custom-inline-forms/static/7b1468ff-019a-44d1-b4bb-729e5a252899.jpg rename to examples/sqla_custom_inline_forms/static/7b1468ff-019a-44d1-b4bb-729e5a252899.jpg diff --git a/examples/sqla-custom-inline-forms/templates/field_list.html b/examples/sqla_custom_inline_forms/templates/field_list.html similarity index 100% rename from examples/sqla-custom-inline-forms/templates/field_list.html rename to examples/sqla_custom_inline_forms/templates/field_list.html diff --git a/examples/sqla-custom-inline-forms/templates/locations.html b/examples/sqla_custom_inline_forms/templates/locations.html similarity index 100% rename from examples/sqla-custom-inline-forms/templates/locations.html rename to examples/sqla_custom_inline_forms/templates/locations.html diff --git a/examples/tinymongo/README.rst b/examples/tinymongo/README.rst deleted file mode 100644 index 8d6af7b8e9..0000000000 --- a/examples/tinymongo/README.rst +++ /dev/null @@ -1,24 +0,0 @@ -TinyMongo model backend integration example. - -TinyMongo is the Pymongo for TinyDB and it stores data in JSON files. - -To run this example: - -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 - source env/bin/activate - -3. Install requirements:: - - pip install -r 'examples/tinymongo/requirements.txt' - -4. Run the application:: - - python examples/tinymongo/app.py - diff --git a/examples/tinymongo/app.py b/examples/tinymongo/app.py deleted file mode 100644 index 7512c14ce0..0000000000 --- a/examples/tinymongo/app.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -Example of Flask-Admin using TinyDB with TinyMongo -refer to README.txt for instructions - -Author: Bruno Rocha <@rochacbruno> -Based in PyMongo Example and TinyMongo -""" -import flask_admin as admin -from flask import Flask -from flask_admin.contrib.pymongo import ModelView, filters -from flask_admin.form import Select2Widget -from flask_admin.model.fields import InlineFieldList, InlineFormField -from wtforms import fields, form - -from tinymongo import TinyMongoClient - -# Create application -app = Flask(__name__) - -# Create dummy secrey key so we can use sessions -app.config['SECRET_KEY'] = '123456790' - -# Create models in a JSON file localted at - -DATAFOLDER = '/tmp/flask_admin_test' - -conn = TinyMongoClient(DATAFOLDER) -db = conn.test - -# create some users for testing -# for i in range(30): -# db.user.insert({'name': 'Mike %s' % i}) - - -# User admin -class InnerForm(form.Form): - name = fields.StringField('Name') - test = fields.StringField('Test') - - -class UserForm(form.Form): - foo = fields.StringField('foo') - name = fields.StringField('Name') - email = fields.StringField('Email') - password = fields.StringField('Password') - - # Inner form - inner = InlineFormField(InnerForm) - - # Form list - form_list = InlineFieldList(InlineFormField(InnerForm)) - - -class UserView(ModelView): - column_list = ('name', 'email', 'password', 'foo') - column_sortable_list = ('name', 'email', 'password') - - form = UserForm - - page_size = 20 - can_set_page_size = True - - -# Tweet view -class TweetForm(form.Form): - name = fields.StringField('Name') - user_id = fields.SelectField('User', widget=Select2Widget()) - text = fields.StringField('Text') - - testie = fields.BooleanField('Test') - - -class TweetView(ModelView): - column_list = ('name', 'user_name', 'text') - column_sortable_list = ('name', 'text') - - column_filters = (filters.FilterEqual('name', 'Name'), - filters.FilterNotEqual('name', 'Name'), - filters.FilterLike('name', 'Name'), - filters.FilterNotLike('name', 'Name'), - filters.BooleanEqualFilter('testie', 'Testie')) - - # column_searchable_list = ('name', 'text') - - form = TweetForm - - def get_list(self, *args, **kwargs): - count, data = super(TweetView, self).get_list(*args, **kwargs) - - # Contribute user_name to the models - for item in data: - item['user_name'] = db.user.find_one( - {'_id': item['user_id']} - )['name'] - - return count, data - - # Contribute list of user choices to the forms - def _feed_user_choices(self, form): - users = db.user.find(fields=('name',)) - form.user_id.choices = [(str(x['_id']), x['name']) for x in users] - return form - - def create_form(self): - form = super(TweetView, self).create_form() - return self._feed_user_choices(form) - - def edit_form(self, obj): - form = super(TweetView, self).edit_form(obj) - return self._feed_user_choices(form) - - # Correct user_id reference before saving - def on_model_change(self, form, model): - user_id = model.get('user_id') - model['user_id'] = user_id - - return model - - -# Flask views -@app.route('/') -def index(): - return 'Click me to get to Admin!' - - -if __name__ == '__main__': - # Create admin - admin = admin.Admin(app, name='Example: TinyMongo - TinyDB') - - # Add views - admin.add_view(UserView(db.user, 'User')) - admin.add_view(TweetView(db.tweet, 'Tweets')) - - # Start app - app.run(debug=True) diff --git a/examples/tinymongo/requirements.txt b/examples/tinymongo/requirements.txt deleted file mode 100644 index 67cf0d814e..0000000000 --- a/examples/tinymongo/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -Flask -Flask-Admin -pymongo==2.4.1 -git+https://github.com/schapman1974/tinymongo.git#egg=tinymongo diff --git a/flask_admin/__init__.py b/flask_admin/__init__.py index 97e5259747..4232d2e69c 100644 --- a/flask_admin/__init__.py +++ b/flask_admin/__init__.py @@ -1,6 +1,10 @@ -__version__ = '1.5.6' -__author__ = 'Flask-Admin team' -__email__ = 'serge.koval+github@gmail.com' +__version__ = "2.2.0" +__author__ = "Flask-Admin team" +__email__ = "contact@palletsproject.com" -from .base import expose, expose_plugview, Admin, BaseView, AdminIndexView # noqa: F401 +from .base import Admin # noqa: F401 +from .base import AdminIndexView # noqa: F401 +from .base import BaseView # noqa: F401 +from .base import expose # noqa: F401 +from .base import expose_plugview # noqa: F401 diff --git a/flask_admin/_backwards.py b/flask_admin/_backwards.py index 0f1a2a49be..6ee025227a 100644 --- a/flask_admin/_backwards.py +++ b/flask_admin/_backwards.py @@ -1,49 +1,48 @@ -# -*- coding: utf-8 -*- """ - flask_admin._backwards - ~~~~~~~~~~~~~~~~~~~~~~~~~~ +flask_admin._backwards +~~~~~~~~~~~~~~~~~~~~~~~~~~ - Backward compatibility helpers. +Backward compatibility helpers. """ + import sys +import typing as t import warnings - -try: - from wtforms.widgets import HTMLString as Markup -except ImportError: - from markupsafe import Markup # noqa: F401 +from types import ModuleType -def get_property(obj, name, old_name, default=None): +def get_property(obj: t.Any, name: str, old_name: str, default: t.Any = None) -> t.Any: """ - Check if old property name exists and if it does - show warning message - and return value. + Check if old property name exists and if it does - show warning message + and return value. - Otherwise, return new property value + Otherwise, return new property value - :param name: - New property name - :param old_name: - Old property name - :param default: - Default value + :param name: + New property name + :param old_name: + Old property name + :param default: + Default value """ if hasattr(obj, old_name): - warnings.warn('Property %s is obsolete, please use %s instead' % - (old_name, name), stacklevel=2) + warnings.warn( + f"Property {old_name} is obsolete, please use {name} instead", + stacklevel=2, + ) return getattr(obj, old_name) return getattr(obj, name, default) -class ObsoleteAttr(object): - def __init__(self, new_name, old_name, default): +class ObsoleteAttr: + def __init__(self, new_name: str, old_name: str, default: t.Any) -> None: self.new_name = new_name self.old_name = old_name - self.cache = '_cache_' + new_name + self.cache = "_cache_" + new_name self.default = default - def __get__(self, obj, objtype=None): + def __get__(self, obj: t.Any, objtype: type | None = None) -> "ObsoleteAttr": if obj is None: return self @@ -53,36 +52,44 @@ def __get__(self, obj, objtype=None): # Check if there's old attribute if hasattr(obj, self.old_name): - warnings.warn('Property %s is obsolete, please use %s instead' % - (self.old_name, self.new_name), stacklevel=2) + warnings.warn( + ( + f"Property {self.old_name} is obsolete, please use {self.new_name} " + f"instead" + ), + stacklevel=2, + ) return getattr(obj, self.old_name) # Return default otherwise return self.default - def __set__(self, obj, value): + def __set__(self, obj: t.Any, value: t.Any) -> None: setattr(obj, self.cache, value) -class ImportRedirect(object): - def __init__(self, prefix, target): +class ImportRedirect: + def __init__(self, prefix: str, target: str) -> None: self.prefix = prefix self.target = target - def find_module(self, fullname, path=None): + def find_module( + self, fullname: str, path: str | None = None + ) -> t.Optional["ImportRedirect"]: if fullname.startswith(self.prefix): return self + return None - def load_module(self, fullname): + def load_module(self, fullname: str) -> ModuleType: if fullname in sys.modules: return sys.modules[fullname] - path = self.target + fullname[len(self.prefix):] + path = self.target + fullname[len(self.prefix) :] __import__(path) module = sys.modules[fullname] = sys.modules[path] return module -def import_redirect(old, new): - sys.meta_path.append(ImportRedirect(old, new)) +def import_redirect(old: str, new: str) -> None: + sys.meta_path.append(ImportRedirect(old, new)) # type: ignore[arg-type] diff --git a/flask_admin/_compat.py b/flask_admin/_compat.py index 7cf9ce88d2..8cf295f098 100644 --- a/flask_admin/_compat.py +++ b/flask_admin/_compat.py @@ -1,92 +1,68 @@ -# -*- coding: utf-8 -*- # flake8: noqa """ - flask_admin._compat - ~~~~~~~~~~~~~~~~~~~~~~~ +flask_admin._compat +~~~~~~~~~~~~~~~~~~~~~~~ - Some py2/py3 compatibility support based on a stripped down - version of six so we don't have to depend on a specific version - of it. +Some py2/py3 compatibility support based on a stripped down +version of six so we don't have to depend on a specific version +of it. - :copyright: (c) 2013 by Armin Ronacher. - :license: BSD, see LICENSE for more details. +:copyright: (c) 2013 by Armin Ronacher. +:license: BSD, see LICENSE for more details. """ -import sys - -PY2 = sys.version_info[0] == 2 -VER = sys.version_info - -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 as_unicode(s): - if isinstance(s, bytes): - return s.decode('utf-8') - - return str(s) - - 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) - - 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') - - return unicode(s) - - def csv_encode(s): - ''' Returns byte string expected by Python 2's csv module ''' - return as_unicode(s).encode('utf-8') - - # 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 + +import typing as t +from types import MappingProxyType +from flask_admin._types import T_TRANSLATABLE, T_ITER_CHOICES + +text_type = str +string_types = (str,) + +K = t.TypeVar("K") +V = t.TypeVar("V") + + +def itervalues(d: dict[t.Any, V]) -> t.Iterator[V]: + return iter(d.values()) + + +def iteritems( + d: dict[K, V] | MappingProxyType[K, V] | t.Mapping[K, V], +) -> t.Iterator[tuple[K, V]]: + return iter(d.items()) + + +T = t.TypeVar("T") + + +def filter_list(f: t.Callable[[t.Any], bool], l: t.Sequence[T]) -> list[T]: + return list(filter(f, l)) + + +def as_unicode(s: t.Any) -> str: + if isinstance(s, bytes): + return s.decode("utf-8") + + return str(s) + + +def csv_encode(s: str | bytes) -> str: + """Returns unicode string expected by Python 3's csv module""" + return as_unicode(s) + + +def _iter_choices_wtforms_compat( + val: str, label: T_TRANSLATABLE, selected: bool +) -> T_ITER_CHOICES: + """Compatibility for 3-tuples and 4-tuples in iter_choices + + https://wtforms.readthedocs.io/en/3.2.x/changes/#version-3-2-0 + """ + import wtforms + + wtforms_version = tuple(int(part) for part in wtforms.__version__.split(".")[:2]) + + if wtforms_version >= (3, 2): + return val, label, selected, {} + + return val, label, selected diff --git a/flask_admin/_types.py b/flask_admin/_types.py new file mode 100644 index 0000000000..e3cdb3a11c --- /dev/null +++ b/flask_admin/_types.py @@ -0,0 +1,286 @@ +import sys +import typing as t +from enum import Enum +from os import PathLike + +import wtforms.widgets +from flask import Response +from markupsafe import Markup +from werkzeug.wrappers import Response as Wkzg_Response +from wtforms import Field +from wtforms.form import BaseForm +from wtforms.utils import UnsetValue +from wtforms.widgets import Input + +if sys.version_info >= (3, 11): + from typing import NotRequired +else: + from typing_extensions import NotRequired + +if t.TYPE_CHECKING: + # optional dependencies + from arrow import Arrow as T_ARROW # noqa + from flask_babel import LazyString as T_LAZY_STRING + from flask_sqlalchemy import Model as T_SQLALCHEMY_LEGACY_MODEL + from sqlalchemy.orm import DeclarativeBase as T_DECLARATIVE_BASE + + from flask_admin.base import BaseView as T_VIEW # noqa: F401 + from flask_admin.contrib.sqla.validators import InputRequired as T_INPUT_REQUIRED + from flask_admin.contrib.sqla.validators import ( + TimeZoneValidator as T_TIMEZONE_VALIDATOR, + ) + from flask_admin.contrib.sqla.validators import Unique as T_UNIQUE + from flask_admin.form import FormOpts as T_FORM_OPTS # noqa: F401 + from flask_admin.form.rules import BaseRule as T_BASE_RULE + from flask_admin.form.rules import Field as T_FLASK_ADMIN_FIELD + from flask_admin.form.rules import FieldSet as T_FIELD_SET + from flask_admin.form.rules import Header as T_HEADER + from flask_admin.form.rules import Macro as T_MACRO + from flask_admin.model import BaseModelView as T_MODEL_VIEW + from flask_admin.model.ajax import AjaxModelLoader as T_AJAX_MODEL_LOADER # noqa: F401 + from flask_admin.model.fields import AjaxSelectField as T_AJAX_SELECT_FIELD # noqa: F401 + from flask_admin.model.form import InlineBaseFormAdmin as T_INLINE_BASE_FORM_ADMIN # noqa: F401 + from flask_admin.model.form import InlineFormAdmin as T_INLINE_FORM_ADMIN + from flask_admin.model.widgets import ( + AjaxSelect2Widget as T_INLINE_AJAX_SELECT2_WIDGET, + ) + from flask_admin.model.widgets import ( + InlineFieldListWidget as T_INLINE_FIELD_LIST_WIDGET, + ) + from flask_admin.model.widgets import InlineFormWidget as T_INLINE_FORM_WIDGET + from flask_admin.model.widgets import XEditableWidget as T_INLINE_X_EDITABLE_WIDGET + + T_SQLALCHEMY_MODEL: t.TypeAlias = t.Union[ + T_SQLALCHEMY_LEGACY_MODEL, T_DECLARATIVE_BASE + ] + + from mongoengine import Document as T_MONGO_ENGINE_DOCUMENT + from peewee import Field as T_PEEWEE_FIELD + from peewee import Model as T_PEEWEE_MODEL + from pymongo import MongoClient + from sqlalchemy import Column + from sqlalchemy import FromClause + from sqlalchemy import Table + from sqlalchemy.orm import InstrumentedAttribute + from sqlalchemy_utils import Choice as T_CHOICE # noqa + from sqlalchemy_utils import ChoiceType as T_CHOICE_TYPE # noqa + + # Handle SQLAlchemy generic type changes + try: + T_INSTRUMENTED_ATTRIBUTE = InstrumentedAttribute[t.Any] + except TypeError: # Fall back to non-generic types for older SQLAlchemy + T_INSTRUMENTED_ATTRIBUTE = InstrumentedAttribute # type: ignore[misc] + + try: + T_SQLALCHEMY_COLUMN = Column[t.Any] + except TypeError: # Fall back to non-generic types for older SQLAlchemy + T_SQLALCHEMY_COLUMN = Column # type: ignore[misc] + + T_MONGO_CLIENT = MongoClient[t.Any] + from PIL.Image import Image as T_PIL_IMAGE # noqa: F401 + from redis import Redis as T_REDIS # noqa: F401 + + from flask_admin.contrib.peewee.ajax import ( + QueryAjaxModelLoader as T_PEEWEE_QUERY_AJAX_MODEL_LOADER, + ) + from flask_admin.contrib.sqla.ajax import ( + QueryAjaxModelLoader as T_SQLA_QUERY_AJAX_MODEL_LOADER, + ) + + T_ORM_MODEL: t.TypeAlias = t.Union[ + T_SQLALCHEMY_LEGACY_MODEL, + T_DECLARATIVE_BASE, + T_PEEWEE_MODEL, + T_MONGO_CLIENT, + T_MONGO_ENGINE_DOCUMENT, + ] + + T_SQLALCHEMY_TABLE: t.TypeAlias = Table | FromClause +else: + T_VIEW = "flask_admin.base.BaseView" + T_INPUT_REQUIRED = "InputRequired" + T_TIMEZONE_VALIDATOR = "TimeZoneValidator" + T_UNIQUE = "Unique" + T_FORM_OPTS = "flask_admin.form.FormOpts" + T_MODEL_VIEW = "flask_admin.model.BaseModelView" + T_AJAX_MODEL_LOADER = "flask_admin.model.ajax.AjaxModelLoader" + T_AJAX_SELECT_FIELD = "flask_admin.model.fields.AjaxSelectField" + T_INLINE_BASE_FORM_ADMIN = "flask_admin.model.form.InlineBaseFormAdmin" + T_INLINE_FORM_ADMIN = "flask_admin.model.form.InlineFormAdmin" + T_INLINE_FIELD_LIST_WIDGET = "flask_admin.model.widgets.InlineFieldListWidget" + T_INLINE_FORM_WIDGET = "flask_admin.model.widgets.InlineFormWidget" + T_INLINE_AJAX_SELECT2_WIDGET = "flask_admin.model.widgets.AjaxSelect2Widget" + T_INLINE_X_EDITABLE_WIDGET = "flask_admin.model.widgets.XEditableWidget" + + T_FIELD_SET = "flask_admin.form.rules.FieldSet" + T_BASE_RULE = "flask_admin.form.rules.BaseRule" + T_HEADER = "flask_admin.form.rules.Header" + T_FLASK_ADMIN_FIELD = "flask_admin.form.rules.Field" + T_MACRO = "flask_admin.form.rules.Macro" + + # optional dependencies + T_ARROW = "arrow.Arrow" + T_LAZY_STRING = "flask_babel.LazyString" + T_SQLALCHEMY_COLUMN = "sqlalchemy.Column[t.Any]" + T_SQLALCHEMY_LEGACY_MODEL = "flask_sqlalchemy.Model" + T_SQLALCHEMY_MODEL = t.Any + T_PEEWEE_FIELD = "peewee.Field" + T_PEEWEE_MODEL = t.Any + T_MONGO_CLIENT = "pymongo.MongoClient[t.Any]" + T_MONGO_ENGINE_DOCUMENT = "mongoengine.Document" + T_CHOICE_TYPE = "sqlalchemy_utils.ChoiceType" + T_CHOICE = "sqlalchemy_utils.Choice" + + T_INSTRUMENTED_ATTRIBUTE = t.TypeVar("T_INSTRUMENTED_ATTRIBUTE", bound=t.Any) + T_REDIS = "redis.Redis" + T_PEEWEE_QUERY_AJAX_MODEL_LOADER = ( + "flask_admin.contrib.peewee.ajax.QueryAjaxModelLoader" + ) + T_SQLA_QUERY_AJAX_MODEL_LOADER = ( + "flask_admin.contrib.sqla.ajax.QueryAjaxModelLoader" + ) + T_PIL_IMAGE = "PIL.Image.Image" + T_ORM_MODEL = t.Any + T_SQLALCHEMY_TABLE: t.TypeAlias = "Table | FromClause" + +T_COL_NO_STR: t.TypeAlias = t.Union[T_SQLALCHEMY_COLUMN, T_INSTRUMENTED_ATTRIBUTE] +T_COLUMN = t.Union[str, T_SQLALCHEMY_COLUMN, T_INSTRUMENTED_ATTRIBUTE] +T_FILTER = tuple[int, T_COLUMN, str] +T_ORM_COLUMN = t.Union[T_COLUMN, T_PEEWEE_FIELD] +T_COLUMN_LIST = t.Sequence[ + T_ORM_COLUMN | t.Iterable[T_ORM_COLUMN] | tuple[str, tuple[T_ORM_COLUMN, ...]] +] +T_TYPE_FORMATTER = t.Callable[[T_MODEL_VIEW, t.Any, str], str | Markup] +T_COLUMN_TYPE_FORMATTERS = dict[type, T_TYPE_FORMATTER] +T_TRANSLATABLE = t.Union[str, T_LAZY_STRING] +# Compatibility for 3-tuples and 4-tuples in iter_choices +# https://wtforms.readthedocs.io/en/3.2.x/changes/#version-3-2-0 +T_ITER_CHOICES = t.Union[ + tuple[t.Any, T_TRANSLATABLE, bool, dict[str, t.Any]], + tuple[t.Any, T_TRANSLATABLE, bool], +] +T_OPTION = tuple[str, T_TRANSLATABLE] +T_OPTION_LIST = t.Sequence[T_OPTION] +T_OPTIONS = t.Union[None, T_OPTION_LIST, t.Callable[[], T_OPTION_LIST]] +T_QUERY_AJAX_MODEL_LOADER = t.Union[ + T_PEEWEE_QUERY_AJAX_MODEL_LOADER, T_SQLA_QUERY_AJAX_MODEL_LOADER +] +T_RESPONSE = t.Union[Response, Wkzg_Response] + +T_SQLALCHEMY_INLINE_MODELS = t.Sequence[ + t.Union[ + T_INLINE_FORM_ADMIN, + type[T_SQLALCHEMY_MODEL], + tuple[type[T_SQLALCHEMY_MODEL]] | dict[str, t.Any], + ] +] + +T_RULES_SEQUENCE = t.Sequence[ + t.Union[str, T_FIELD_SET, T_BASE_RULE, T_HEADER, T_FLASK_ADMIN_FIELD, T_MACRO] +] +T_VALIDATOR = t.Union[ + t.Callable[[t.Any, t.Any], t.Any], + T_UNIQUE, + T_INPUT_REQUIRED, + wtforms.validators.Optional, + wtforms.validators.Length, + wtforms.validators.AnyOf, + wtforms.validators.Email, + wtforms.validators.URL, + wtforms.validators.IPAddress, + T_TIMEZONE_VALIDATOR, + wtforms.validators.NumberRange, + wtforms.validators.MacAddress, +] +T_PATH_LIKE = t.Union[str, bytes, PathLike[str], PathLike[bytes]] + + +class WidgetProtocol(t.Protocol): + def __call__(self, field: Field, **kwargs: t.Any) -> str | Markup: ... + + +T_WIDGET = t.Union[ + Input, + T_INLINE_FIELD_LIST_WIDGET, + T_INLINE_FORM_WIDGET, + T_INLINE_AJAX_SELECT2_WIDGET, + T_INLINE_X_EDITABLE_WIDGET, + WidgetProtocol, +] + +T_WIDGET_TYPE = t.Optional[ + t.Literal[ + "daterangepicker", + "datetimepicker", + "datetimerangepicker", + "datepicker", + "select2-tags", + "timepicker", + "timerangepicker", + "uuid", + ] + | str +] + + +class T_FIELD_ARGS_DESCRIPTION(t.TypedDict, total=False): + description: NotRequired[str] + + +class T_FIELD_ARGS_FILTERS(t.TypedDict): + filters: NotRequired[list[t.Callable[[t.Any], t.Any]]] + allow_blank: NotRequired[bool] + choices: NotRequired[list[tuple[int, str]] | list[Enum]] + validators: NotRequired[list[T_VALIDATOR]] + coerce: NotRequired[t.Callable[[t.Any], t.Any]] + + +class T_FIELD_ARGS_LABEL(t.TypedDict): + label: NotRequired[str] + + +class T_FIELD_ARGS_PLACES(t.TypedDict): + places: UnsetValue | None + + +class T_FIELD_ARGS_VALIDATORS(t.TypedDict, total=False): + label: NotRequired[str] + description: NotRequired[str] + filters: NotRequired[list[t.Callable[[t.Any], t.Any]]] + default: NotRequired[t.Any] + widget: NotRequired[Input] + validators: NotRequired[list[T_VALIDATOR]] + render_kw: NotRequired[dict[str, t.Any]] + name: NotRequired[str] + _form: NotRequired[BaseForm] + _prefix: NotRequired[str] + + +class T_FIELD_ARGS_VALIDATORS_ALLOW_BLANK(T_FIELD_ARGS_VALIDATORS): + allow_blank: NotRequired[bool] + + +class T_FIELD_ARGS_VALIDATORS_FILES(T_FIELD_ARGS_VALIDATORS): + base_path: NotRequired[str] + allow_overwrite: NotRequired[bool] + + +# wtfforms types +class _MultiDictLikeBase(t.Protocol): + def __iter__(self) -> t.Iterator[str]: ... + def __len__(self) -> int: ... + def __contains__(self, key: t.Any, /) -> bool: ... + + +class _MultiDictLikeWithGetlist(_MultiDictLikeBase, t.Protocol): + def getlist(self, key: str, /) -> list[t.Any]: ... + + +class _T_MONGOENGINE_FIELD_PROTOCOL(t.Protocol): + id: t.Any + data: t.Any + name: str + + +class T_FIELD_ARGS_VALIDATORS_COERCE(T_FIELD_ARGS_VALIDATORS, total=False): + coerce: t.Callable[[t.Any], t.Any] diff --git a/flask_admin/actions.py b/flask_admin/actions.py index f217fa0c48..97255c42d6 100644 --- a/flask_admin/actions.py +++ b/flask_admin/actions.py @@ -1,64 +1,73 @@ -from flask import request, redirect +import typing as t +from typing import Any +from flask import redirect +from flask import request from flask_admin import tools from flask_admin._compat import text_type -from flask_admin.helpers import get_redirect_target, flash_errors +from flask_admin._types import T_RESPONSE +from flask_admin.helpers import flash_errors +from flask_admin.helpers import get_redirect_target -def action(name, text, confirmation=None): +def action( + name: str, text: str, confirmation: str | None = None +) -> t.Callable[..., t.Any]: """ - Use this decorator to expose actions that span more than one - entity (model, file, etc) - - :param name: - Action name - :param text: - Action text. - :param confirmation: - Confirmation text. If not provided, action will be executed - unconditionally. + Use this decorator to expose actions that span more than one + entity (model, file, etc) + + :param name: + Action name + :param text: + Action text. + :param confirmation: + Confirmation text. If not provided, action will be executed + unconditionally. """ - def wrap(f): - f._action = (name, text, confirmation) + + def wrap(f: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + f._action = (name, text, confirmation) # type: ignore[attr-defined] return f return wrap -class ActionsMixin(object): +class ActionsMixin: """ - Actions mixin. + Actions mixin. - In some cases, you might work with more than one "entity" (model, file, etc) in - your admin view and will want to perform actions on a group of entities simultaneously. + In some cases, you might work with more than one "entity" (model, file, etc) in + your admin view and will want to perform actions on a group of entities + simultaneously. - In this case, you can add this functionality by doing this: - 1. Add this mixin to your administrative view class - 2. Call `init_actions` in your class constructor - 3. Expose actions view - 4. Import `actions.html` library and add call library macros in your template + In this case, you can add this functionality by doing this: + 1. Add this mixin to your administrative view class + 2. Call `init_actions` in your class constructor + 3. Expose actions view + 4. Import `actions.html` library and add call library macros in your template """ - def __init__(self): + def __init__(self) -> None: """ - Default constructor. + Default constructor. """ - self._actions = [] - self._actions_data = {} + self._actions: list[tuple[str, str]] = [] + self._actions_data: dict[str, tuple[Any, str, str | None]] = {} - def init_actions(self): + def init_actions(self) -> None: """ - Initialize list of actions for the current administrative view. + Initialize list of actions for the current administrative view. """ - self._actions = [] - self._actions_data = {} + self._actions: list[tuple[str, str]] = [] # type:ignore[no-redef] + self._actions_data: dict[str, tuple[Any, str, str | None]] = {} # type:ignore[no-redef] for p in dir(self): attr = tools.get_dict_attr(self, p) - if hasattr(attr, '_action'): - name, text, desc = attr._action + if hasattr(attr, "_action"): + name, text, desc = attr._action # type: ignore[union-attr] self._actions.append((name, text)) @@ -67,21 +76,21 @@ def init_actions(self): # bound to the object. self._actions_data[name] = (getattr(self, p), text, desc) - def is_action_allowed(self, name): + def is_action_allowed(self, name: str) -> bool: """ - Verify if action with `name` is allowed. + Verify if action with `name` is allowed. - :param name: - Action name + :param name: + Action name """ return True - def get_actions_list(self): + def get_actions_list(self) -> tuple[list[t.Any], dict[t.Any, t.Any]]: """ - Return a list and a dictionary of allowed actions. + Return a list and a dictionary of allowed actions. """ - actions = [] - actions_confirmation = {} + actions: list[tuple[str, str]] = [] + actions_confirmation: dict[str, str] = {} for act in self._actions: name, text = act @@ -95,20 +104,20 @@ def get_actions_list(self): return actions, actions_confirmation - def handle_action(self, return_view=None): + def handle_action(self, return_view: str | None = None) -> T_RESPONSE: """ - Handle action request. + Handle action request. - :param return_view: - Name of the view to return to after the request. - If not provided, will return user to the return url in the form - or the list view. + :param return_view: + Name of the view to return to after the request. + If not provided, will return user to the return url in the form + or the list view. """ - form = self.action_form() + form = self.action_form() # type: ignore[attr-defined] - if self.validate_form(form): + if self.validate_form(form): # type: ignore[attr-defined] # using getlist instead of FieldList for backward compatibility - ids = request.form.getlist('rowid') + ids = request.form.getlist("rowid") action = form.action.data handler = self._actions_data.get(action) @@ -119,11 +128,11 @@ def handle_action(self, return_view=None): if response is not None: return response else: - flash_errors(form, message='Failed to perform action. %(error)s') + flash_errors(form, message="Failed to perform action. %(error)s") if return_view: - url = self.get_url('.' + return_view) + url = self.get_url("." + return_view) # type: ignore[attr-defined] else: - url = get_redirect_target() or self.get_url('.index_view') + url = get_redirect_target() or self.get_url(".index_view") # type: ignore[attr-defined] return redirect(url) diff --git a/flask_admin/babel.py b/flask_admin/babel.py index 8ce5da5a16..54c109828c 100644 --- a/flask_admin/babel.py +++ b/flask_admin/babel.py @@ -1,40 +1,45 @@ +import typing as t + try: - from flask_babelex import Domain + from flask_babel import Domain except ImportError: - def gettext(string, **variables): - return string % variables - def ngettext(singular, plural, num, **variables): - variables.setdefault('num', num) - return (singular if num == 1 else plural) % variables + def gettext(string: str, **variables: str) -> str: + return string if not variables else string % variables + + def ngettext(singular: str, plural: str, num: int, **variables: t.Any) -> str: + variables.setdefault("num", num) + return gettext((singular if num == 1 else plural), **variables) - def lazy_gettext(string, **variables): + def lazy_gettext(string: str, **variables: t.Any) -> str: return gettext(string, **variables) - class Translations(object): - ''' dummy Translations class for WTForms, no translation support ''' - def gettext(self, string): + class Translations: + """dummy Translations class for WTForms, no translation support""" + + def gettext(self, string: str) -> str: return string - def ngettext(self, singular, plural, n): + def ngettext(self, singular: str, plural: str, n: int) -> str: return singular if n == 1 else plural else: from flask_admin import translations - class CustomDomain(Domain): - def __init__(self): - super(CustomDomain, self).__init__(translations.__path__[0], domain='admin') + class CustomDomain(Domain): # type: ignore[misc] + def __init__(self) -> None: + super().__init__(translations.__path__[0], domain="admin") - def get_translations_path(self, ctx): + @property + def translation_directories(self) -> list[str]: view = get_current_view() if view is not None: dirname = view.admin.translations_path if dirname is not None: - return dirname + return [dirname] + super().translation_directories - return super(CustomDomain, self).get_translations_path(ctx) + return super().translation_directories domain = CustomDomain() @@ -42,20 +47,18 @@ 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") - wtforms_domain = Domain(messages_path(), domain='wtforms') + class Translations: # type: ignore[no-redef] + """Fixes WTForms translation support and uses wtforms translations""" - class Translations(object): - ''' Fixes WTForms translation support and uses wtforms translations ''' - def gettext(self, string): + def gettext(self, string: str) -> str: t = wtforms_domain.get_translations() return t.ugettext(string) - def ngettext(self, singular, plural, n): + def ngettext(self, singular: str, plural: str, n: int) -> str: t = wtforms_domain.get_translations() return t.ungettext(singular, plural, n) diff --git a/flask_admin/base.py b/flask_admin/base.py index e32e8d0883..85f7c72fb8 100644 --- a/flask_admin/base.py +++ b/flask_admin/base.py @@ -1,49 +1,73 @@ import os.path as op +import typing as t import warnings - from functools import wraps -from flask import Blueprint, current_app, render_template, abort, g, url_for +from flask import abort +from flask import current_app +from flask import Flask +from flask import g +from flask import render_template +from flask import url_for +from flask.typing import ResponseReturnValue +from flask.views import MethodView +from flask.views import View +from markupsafe import Markup + from flask_admin import babel -from flask_admin._compat import with_metaclass, as_unicode from flask_admin import helpers as h +from flask_admin._compat import as_unicode +from flask_admin._types import T_VIEW # For compatibility reasons import MenuLink -from flask_admin.menu import MenuCategory, MenuView, MenuLink, SubMenuCategory # noqa: F401 - - -def expose(url='/', methods=('GET',)): +from flask_admin.blueprints import _BlueprintWithHostSupport as Blueprint +from flask_admin.consts import ADMIN_ROUTES_HOST_VARIABLE +from flask_admin.menu import BaseMenu +from flask_admin.menu import MenuCategory +from flask_admin.menu import MenuLink +from flask_admin.menu import MenuView +from flask_admin.menu import SubMenuCategory +from flask_admin.theme import Bootstrap4Theme +from flask_admin.theme import Theme + + +def expose( + url: str = "/", methods: t.Iterable[str] | None = ("GET",) +) -> t.Callable[[t.Any], t.Any]: """ - Use this decorator to expose views in your view classes. + Use this decorator to expose views in your view classes. - :param url: - Relative URL for the view - :param methods: - Allowed HTTP methods. By default only GET is allowed. + :param url: + Relative URL for the view + :param methods: + Allowed HTTP methods. By default only GET is allowed. """ - def wrap(f): - if not hasattr(f, '_urls'): + + def wrap(f: AdminViewMeta) -> AdminViewMeta: + if not hasattr(f, "_urls"): f._urls = [] - f._urls.append((url, methods)) + f._urls.append((url, methods)) # type: ignore[arg-type] return f + return wrap -def expose_plugview(url='/'): +def expose_plugview(url: str = "/") -> t.Callable[[t.Any], t.Any]: """ - Decorator to expose Flask's pluggable view classes - (``flask.views.View`` or ``flask.views.MethodView``). + Decorator to expose Flask's pluggable view classes + (``flask.views.View`` or ``flask.views.MethodView``). - :param url: - Relative URL for the view + :param url: + Relative URL for the view - .. versionadded:: 1.0.4 + .. versionadded:: 1.0.4 """ - def wrap(v): + + def wrap(v: View | MethodView) -> t.Any: handler = expose(url, v.methods) - if hasattr(v, 'as_view'): - return handler(v.as_view(v.__name__)) + if hasattr(v, "as_view"): + return handler(v.as_view(v.__name__)) # type:ignore[union-attr] else: return handler(v) @@ -51,13 +75,13 @@ def wrap(v): # Base views -def _wrap_view(f): +def _wrap_view(f: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: # Avoid wrapping view method twice - if hasattr(f, '_wrapped'): + if hasattr(f, "_wrapped"): return f @wraps(f) - def inner(self, *args, **kwargs): + def inner(self: t.Any, *args: t.Any, **kwargs: t.Any) -> t.Any: # Store current admin view h.set_current_view(self) @@ -66,126 +90,156 @@ def inner(self, *args, **kwargs): if abort is not None: return abort - return self._run_view(f, *args, **kwargs) + return self._run_view(current_app.ensure_sync(f), *args, **kwargs) - inner._wrapped = True + inner._wrapped = True # type:ignore[attr-defined] return inner class AdminViewMeta(type): """ - View metaclass. + View metaclass. - Does some precalculations (like getting list of view methods from the class) to avoid - calculating them for each view class instance. + Does some precalculations (like getting list of view methods from the class) to + avoid calculating them for each view class instance. """ - def __init__(cls, classname, bases, fields): + + def __init__( + cls, classname: str, bases: tuple[type, ...], fields: dict[str, t.Any] + ) -> None: type.__init__(cls, classname, bases, fields) # Gather exposed views - cls._urls = [] + cls._urls: list[ + tuple[str, t.Iterable[str]] | tuple[str, str, t.Iterable[str]] + ] = [] cls._default_view = None for p in dir(cls): attr = getattr(cls, p) - if hasattr(attr, '_urls'): + if hasattr(attr, "_urls"): # Collect methods for url, methods in attr._urls: cls._urls.append((url, p, methods)) - if url == '/': + if url == "/": cls._default_view = p # Wrap views setattr(cls, p, _wrap_view(attr)) -class BaseViewClass(object): +class BaseViewClass: pass -class BaseView(with_metaclass(AdminViewMeta, BaseViewClass)): +class BaseView(BaseViewClass, metaclass=AdminViewMeta): """ - Base administrative view. + Base administrative view. - Derive from this class to implement your administrative interface piece. For example:: + Derive from this class to implement your administrative interface piece. For + example:: - from flask_admin import BaseView, expose - class MyView(BaseView): - @expose('/') - def index(self): - return 'Hello World!' + from flask_admin import BaseView, expose + class MyView(BaseView): + @expose('/') + def index(self): + return 'Hello World!' - Icons can be added to the menu by using `menu_icon_type` and `menu_icon_value`. For example:: + Icons can be added to the menu by using `menu_icon_type` and `menu_icon_value`. For + example:: - admin.add_view(MyView(name='My View', menu_icon_type='glyph', menu_icon_value='glyphicon-home')) + admin.add_view( + MyView( + name='My View', menu_icon_type='glyph', menu_icon_value='glyphicon-home' + ) + ) """ + + extra_css: list[str] = [] + """Extra CSS files to include in the page""" + + extra_js: list[str] = [] + """Extra JavaScript files to include in the page""" + @property - def _template_args(self): + def _template_args(self) -> dict[str, str]: """ - Extra template arguments. + Extra template arguments. - If you need to pass some extra parameters to the template, - you can override particular view function, contribute - arguments you want to pass to the template and call parent view. + If you need to pass some extra parameters to the template, + you can override particular view function, contribute + arguments you want to pass to the template and call parent view. - These arguments are local for this request and will be discarded - in the next request. + These arguments are local for this request and will be discarded + in the next request. - Any value passed through ``_template_args`` will override whatever - parent view function passed to the template. + Any value passed through ``_template_args`` will override whatever + parent view function passed to the template. - For example:: + For example:: - class MyAdmin(ModelView): - @expose('/') - def index(self): - self._template_args['name'] = 'foobar' - self._template_args['code'] = '12345' - super(MyAdmin, self).index() + class MyAdmin(ModelView): + @expose('/') + def index(self): + self._template_args['name'] = 'foobar' + self._template_args['code'] = '12345' + super(MyAdmin, self).index() """ - args = getattr(g, '_admin_template_args', None) + args = getattr(g, "_admin_template_args", None) if args is None: args = g._admin_template_args = dict() return args - def __init__(self, name=None, category=None, endpoint=None, url=None, - static_folder=None, static_url_path=None, - menu_class_name=None, menu_icon_type=None, menu_icon_value=None): - """ - Constructor. - - :param name: - Name of this view. If not provided, will default to the class name. - :param category: - View category. If not provided, this view will be shown as a top-level menu item. Otherwise, it will - be in a submenu. - :param endpoint: - Base endpoint name for the view. For example, if there's a view method called "index" and - endpoint is set to "myadmin", you can use `url_for('myadmin.index')` to get the URL to the - view method. Defaults to the class name in lower case. - :param url: - Base URL. If provided, affects how URLs are generated. For example, if the url parameter - is "test", the resulting URL will look like "/admin/test/". If not provided, will - use endpoint as a base url. However, if URL starts with '/', absolute path is assumed - and '/admin/' prefix won't be applied. - :param static_url_path: - Static URL Path. If provided, this specifies the path to the static url directory. - :param menu_class_name: - Optional class name for the menu item. - :param menu_icon_type: - Optional icon. Possible icon types: - - - `flask_admin.consts.ICON_TYPE_GLYPH` - Bootstrap glyph icon - - `flask_admin.consts.ICON_TYPE_FONT_AWESOME` - Font Awesome icon - - `flask_admin.consts.ICON_TYPE_IMAGE` - Image relative to Flask static directory - - `flask_admin.consts.ICON_TYPE_IMAGE_URL` - Image with full URL - :param menu_icon_value: - Icon glyph name or URL, depending on `menu_icon_type` setting + def __init__( + self, + name: str | None = None, + category: str | None = None, + endpoint: str | None = None, + url: str | None = None, + static_folder: str | None = None, + static_url_path: str | None = None, + menu_class_name: str | None = None, + menu_icon_type: str | None = None, + menu_icon_value: str | None = None, + ) -> None: + """ + Constructor. + + :param name: + Name of this view. If not provided, will default to the class name. + :param category: + View category. If not provided, this view will be shown as a top-level menu + item. Otherwise, it will be in a submenu. + :param endpoint: + Base endpoint name for the view. For example, if there's a view method + called "index" and endpoint is set to "myadmin", you can use + `url_for('myadmin.index')` to get the URL to the view method. Defaults to + the class name in lower case. + :param url: + Base URL. If provided, affects how URLs are generated. For example, if the + url parameter is "test", the resulting URL will look like "/admin/test/". + If not provided, will use endpoint as a base url. However, if URL starts + with '/', absolute path is assumed and '/admin/' prefix won't be applied. + :param static_url_path: + Static URL Path. If provided, this specifies the path to the static url + directory. + :param menu_class_name: + Optional class name for the menu item. + :param menu_icon_type: + Optional icon. Possible icon types: + + - `flask_admin.consts.ICON_TYPE_GLYPH` - Bootstrap glyph icon + - `flask_admin.consts.ICON_TYPE_FONT_AWESOME` - Font Awesome icon + - `flask_admin.consts.ICON_TYPE_IMAGE` - Image relative to Flask static + directory + - `flask_admin.consts.ICON_TYPE_IMAGE_URL` - Image with full URL + :param menu_icon_value: + Icon glyph name or URL, depending on `menu_icon_type` setting """ self.name = name self.category = category @@ -193,51 +247,54 @@ def __init__(self, name=None, category=None, endpoint=None, url=None, self.url = url self.static_folder = static_folder self.static_url_path = static_url_path - self.menu = None + self.menu: MenuView | None = None self.menu_class_name = menu_class_name self.menu_icon_type = menu_icon_type self.menu_icon_value = menu_icon_value # Initialized from create_blueprint - self.admin = None - self.blueprint = None + self.admin: Admin | None = None + self.blueprint: Blueprint | None = None # Default view - if self._default_view is None: - raise Exception(u'Attempted to instantiate admin view %s without default view' % self.__class__.__name__) + if self._default_view is None: # type: ignore[attr-defined] + raise Exception( + f"Attempted to instantiate admin view {self.__class__.__name__} " + "without default view" + ) - def _get_endpoint(self, endpoint): + def _get_endpoint(self, endpoint: str | None) -> str: """ - Generate Flask endpoint name. By default converts class name to lower case if endpoint is - not explicitly provided. + Generate Flask endpoint name. By default converts class name to lower case if + endpoint is not explicitly provided. """ if endpoint: return endpoint return self.__class__.__name__.lower() - def _get_view_url(self, admin, url): + def _get_view_url(self, admin: "Admin", url: str | None) -> str: """ - Generate URL for the view. Override to change default behavior. + Generate URL for the view. Override to change default behavior. """ if url is None: - if admin.url != '/': - url = '%s/%s' % (admin.url, self.endpoint) + if admin.url != "/": + url = f"{admin.url}/{self.endpoint}" else: if self == admin.index_view: - url = '/' + url = "/" else: - url = '/%s' % self.endpoint + url = f"/{self.endpoint}" else: - if not url.startswith('/'): - url = '%s/%s' % (admin.url, url) + if not url.startswith("/"): + url = f"{admin.url}/{url}" return url - def create_blueprint(self, admin): + def create_blueprint(self, admin: "Admin") -> Blueprint: """ - Create Flask blueprint. + Create Flask blueprint. """ # Store admin instance self.admin = admin @@ -250,147 +307,167 @@ def create_blueprint(self, admin): self.url = self._get_view_url(admin, self.url) # If we're working from the root of the site, set prefix to None - if self.url == '/': + if self.url == "/": self.url = None # prevent admin static files from conflicting with flask static files if not self.static_url_path: - self.static_folder = 'static' - self.static_url_path = '/static/admin' + self.static_folder = "static" + self.static_url_path = "/static/admin" # If name is not provided, use capitalized endpoint name if self.name is None: self.name = self._prettify_class_name(self.__class__.__name__) # Create blueprint and register rules - self.blueprint = Blueprint(self.endpoint, __name__, - url_prefix=self.url, - subdomain=self.admin.subdomain, - template_folder=op.join('templates', self.admin.template_mode), - static_folder=self.static_folder, - static_url_path=self.static_url_path) - - for url, name, methods in self._urls: - self.blueprint.add_url_rule(url, - name, - getattr(self, name), - methods=methods) + self.blueprint = Blueprint( + self.endpoint, + __name__, + url_prefix=self.url, + subdomain=self.admin.subdomain, + template_folder=op.join("templates", self.admin.theme.folder), + static_folder=self.static_folder, + static_url_path=self.static_url_path, + ) + self.blueprint.attach_url_defaults_and_value_preprocessor( + app=self.admin.app, # type:ignore[arg-type] + host=self.admin.host, # type: ignore[arg-type] + ) + + for url, name, methods in self._urls: # type: ignore[attr-defined] + self.blueprint.add_url_rule(url, name, getattr(self, name), methods=methods) return self.blueprint - def render(self, template, **kwargs): + def render(self, template: str, **kwargs: t.Any) -> str: """ - Render template + Render template - :param template: - Template path to render - :param kwargs: - Template arguments + :param template: + Template path to render + :param kwargs: + Template arguments """ # Store self as admin_view - kwargs['admin_view'] = self - kwargs['admin_base_template'] = self.admin.base_template + kwargs["admin_view"] = self + kwargs["admin_base_template"] = self.admin.theme.base_template # type: ignore[union-attr] + kwargs["admin_csp_nonce_attribute"] = ( + Markup(f'nonce="{self.admin.csp_nonce_generator()}"') # type: ignore[union-attr] + if self.admin.csp_nonce_generator # type: ignore[union-attr] + else "" + ) # Provide i18n support even if flask-babel is not installed # or enabled. - kwargs['_gettext'] = babel.gettext - kwargs['_ngettext'] = babel.ngettext - kwargs['h'] = h + kwargs["_gettext"] = babel.gettext + kwargs["_ngettext"] = babel.ngettext + kwargs["h"] = h # Expose get_url helper - kwargs['get_url'] = self.get_url + kwargs["get_url"] = self.get_url # Expose config info - kwargs['config'] = current_app.config + kwargs["config"] = current_app.config + kwargs["theme"] = self.admin.theme # type: ignore[union-attr] # Contribute extra arguments kwargs.update(self._template_args) return render_template(template, **kwargs) - def _prettify_class_name(self, name): + def _prettify_class_name(self, name: str) -> str: """ - Split words in PascalCase string into separate words. + Split words in PascalCase string into separate words. - :param name: - String to prettify + :param name: + String to prettify """ return h.prettify_class_name(name) - def is_visible(self): + def is_visible(self) -> bool: """ - Override this method if you want dynamically hide or show administrative views - from Flask-Admin menu structure + Override this method if you want dynamically hide or show administrative views + from Flask-Admin menu structure - By default, item is visible in menu. + By default, item is visible in menu. - Please note that item should be both visible and accessible to be displayed in menu. + Please note that item should be both visible and accessible to be displayed in + menu. """ return True - def is_accessible(self): + def is_accessible(self) -> bool: """ - Override this method to add permission checks. + Override this method to add permission checks. - Flask-Admin does not make any assumptions about the authentication system used in your application, so it is - up to you to implement it. + Flask-Admin does not make any assumptions about the authentication system used + in your application, so it is up to you to implement it. - By default, it will allow access for everyone. + By default, it will allow access for everyone. """ return True - def _handle_view(self, name, **kwargs): + def _handle_view(self, name: str, **kwargs: t.Any) -> ResponseReturnValue | None: """ - This method will be executed before calling any view method. + This method will be executed before calling any view method. - It will execute the ``inaccessible_callback`` if the view is not - accessible. + It will execute the ``inaccessible_callback`` if the view is not + accessible. - :param name: - View function name - :param kwargs: - View function arguments + :param name: + View function name + :param kwargs: + View function arguments """ + # abort(403) if not self.is_accessible(): - return self.inaccessible_callback(name, **kwargs) + return self.inaccessible_callback(name, **kwargs) or abort(403) + return None - def _run_view(self, fn, *args, **kwargs): + def _run_view( + self, fn: t.Callable[..., t.Any], *args: t.Any, **kwargs: t.Any + ) -> t.Any: """ - This method will run actual view function. + This method will run actual view function. - While it is similar to _handle_view, can be used to change - arguments that are passed to the view. + While it is similar to _handle_view, can be used to change + arguments that are passed to the view. - :param fn: - View function - :param kwargs: - Arguments + :param fn: + View function + :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): + def inaccessible_callback( + self, name: t.Any, **kwargs: t.Any + ) -> ResponseReturnValue: """ - Handle the response to inaccessible views. + Handle the response to inaccessible views. - By default, it throw HTTP 403 error. Override this method to - customize the behaviour. + By default, it throw HTTP 403 error. Override this method to + customize the behaviour. """ return abort(403) - def get_url(self, endpoint, **kwargs): + def get_url(self, endpoint: str, **kwargs: t.Any) -> str: """ - Generate URL for the endpoint. If you want to customize URL generation - logic (persist some query string argument, for example), this is - right place to do it. + Generate URL for the endpoint. If you want to customize URL generation + logic (persist some query string argument, for example), this is + right place to do it. - :param endpoint: - Flask endpoint name - :param kwargs: - Arguments for `url_for` + :param endpoint: + Flask endpoint name + :param kwargs: + Arguments for `url_for` """ return url_for(endpoint, **kwargs) @property - def _debug(self): + def _debug(self) -> bool: if not self.admin or not self.admin.app: return False @@ -399,114 +476,132 @@ def _debug(self): class AdminIndexView(BaseView): """ - Default administrative interface index page when visiting the ``/admin/`` URL. + Default administrative interface index page when visiting the ``/admin/`` URL. - It can be overridden by passing your own view class to the ``Admin`` constructor:: + It can be overridden by passing your own view class to the ``Admin`` constructor:: - class MyHomeView(AdminIndexView): - @expose('/') - def index(self): - arg1 = 'Hello' - return self.render('admin/myhome.html', arg1=arg1) + class MyHomeView(AdminIndexView): + @expose('/') + def index(self): + arg1 = 'Hello' + return self.render('admin/myhome.html', arg1=arg1) - admin = Admin(index_view=MyHomeView()) + admin = Admin(index_view=MyHomeView()) - Also, you can change the root url from /admin to / with the following:: + Also, you can change the root url from /admin to / with the following:: - admin = Admin( - app, - index_view=AdminIndexView( - name='Home', - template='admin/myhome.html', - url='/' - ) + admin = Admin( + app, + index_view=AdminIndexView( + name='Home', + template='admin/myhome.html', + url='/' ) + ) - Default values for the index page are: + Default values for the index page are: - * If a name is not provided, 'Home' will be used. - * If an endpoint is not provided, will default to ``admin`` - * Default URL route is ``/admin``. - * Automatically associates with static folder. - * Default template is ``admin/index.html`` + * If a name is not provided, 'Home' will be used. + * If an endpoint is not provided, will default to ``admin`` + * Default URL route is ``/admin``. + * Automatically associates with static folder. + * Default template is ``admin/index.html`` """ - def __init__(self, name=None, category=None, - endpoint=None, url=None, - template='admin/index.html', - menu_class_name=None, - menu_icon_type=None, - menu_icon_value=None): - super(AdminIndexView, self).__init__(name or babel.lazy_gettext('Home'), - category, - endpoint or 'admin', - '/admin' if url is None else url, - 'static', - menu_class_name=menu_class_name, - menu_icon_type=menu_icon_type, - menu_icon_value=menu_icon_value) + + def __init__( + self, + name: str | None = None, + category: str | None = None, + endpoint: str | None = None, + url: str | None = None, + template: str = "admin/index.html", + menu_class_name: str | None = None, + menu_icon_type: str | None = None, + menu_icon_value: str | None = None, + ) -> None: + super().__init__( + name or babel.lazy_gettext("Home"), + category, + endpoint or "admin", + "/admin" if url is None else url, + "static", + menu_class_name=menu_class_name, + menu_icon_type=menu_icon_type, + menu_icon_value=menu_icon_value, + ) self._template = template @expose() - def index(self): + def index(self) -> str: return self.render(self._template) -class Admin(object): +class Admin: """ - Collection of the admin views. Also manages menu structure. + Collection of the admin views. Also manages menu structure. """ - def __init__(self, app=None, name=None, - url=None, subdomain=None, - index_view=None, - translations_path=None, - endpoint=None, - static_url_path=None, - base_template=None, - template_mode=None, - category_icon_classes=None): - """ - Constructor. - - :param app: - Flask application object - :param name: - Application name. Will be displayed in the main menu and as a page title. Defaults to "Admin" - :param url: - Base URL - :param subdomain: - Subdomain to use - :param index_view: - Home page view to use. Defaults to `AdminIndexView`. - :param translations_path: - Location of the translation message catalogs. By default will use the translations - shipped with Flask-Admin. - :param endpoint: - Base endpoint name for index view. If you use multiple instances of the `Admin` class with - a single Flask application, you have to set a unique endpoint name for each instance. - :param static_url_path: - Static URL Path. If provided, this specifies the default path to the static url directory for - all its views. Can be overridden in view configuration. - :param base_template: - Override base HTML template for all static views. Defaults to `admin/base.html`. - :param template_mode: - Base template path. Defaults to `bootstrap2`. If you want to use - Bootstrap 3 or 4 integration, change it to `bootstrap3` or `bootstrap4`. - :param category_icon_classes: - A dict of category names as keys and html classes as values to be added to menu category icons. - Example: {'Favorites': 'glyphicon glyphicon-star'} + + def __init__( + self, + app: Flask | None = None, + name: str | None = None, + url: str | None = None, + subdomain: str | None = None, + index_view: BaseView | None = None, + translations_path: str | None = None, + endpoint: str | None = None, + static_url_path: str | None = None, + theme: Theme | None = None, + category_icon_classes: dict[str, str] | None = None, + host: str | None = None, + csp_nonce_generator: t.Callable[[], t.Any] | None = None, + ) -> None: + """ + Constructor. + + :param app: + Flask application object + :param name: + Application name. Will be displayed in the main menu and as a page title. + Defaults to "Admin" + :param url: + Base URL + :param subdomain: + Subdomain to use + :param index_view: + Home page view to use. Defaults to `AdminIndexView`. + :param translations_path: + Location of the translation message catalogs. By default will use the + translations shipped with Flask-Admin. + :param endpoint: + Base endpoint name for index view. If you use multiple instances of the + `Admin` class with a single Flask application, you have to set a unique + endpoint name for each instance. + :param static_url_path: + Static URL Path. If provided, this specifies the default path to the static + url directory for all its views. Can be overridden in view configuration. + :param theme: + Base theme. Defaults to `Bootstrap4Theme()`. + :param category_icon_classes: + A dict of category names as keys and html classes as values to be added to + menu category icons. Example: {'Favorites': 'glyphicon glyphicon-star'} + :param host: + The host to register all admin views on. Mutually exclusive with `subdomain` + :param csp_nonce_generator: + A callable that returns a nonce to inject into Flask-Admin JS, CSS, etc. """ self.app = app self.translations_path = translations_path - self._views = [] - self._menu = [] - self._menu_categories = dict() - self._menu_links = [] + self._views: list[T_VIEW] = [] + self._menu: list[MenuView | MenuCategory | BaseMenu] = [] + self._menu_categories: dict[str, MenuCategory] = dict() + self._menu_links: list[MenuLink] = [] if name is None: - name = 'Admin' + name = "Admin" self.name = name self.index_view = index_view or AdminIndexView(endpoint=endpoint, url=url) @@ -514,10 +609,14 @@ def __init__(self, app=None, name=None, self.url = url or self.index_view.url self.static_url_path = static_url_path self.subdomain = subdomain - self.base_template = base_template or 'admin/base.html' - self.template_mode = template_mode or 'bootstrap2' + self.host = host + self.theme: Theme = theme or Bootstrap4Theme() self.category_icon_classes = category_icon_classes or dict() + self._validate_admin_host_and_subdomain() + + self.csp_nonce_generator = csp_nonce_generator + # Add index view self._set_admin_index_view(index_view=index_view, endpoint=endpoint, url=url) @@ -525,36 +624,68 @@ def __init__(self, app=None, name=None, if app is not None: self._init_extension() - def add_view(self, view): + def _validate_admin_host_and_subdomain(self) -> None: + if self.subdomain is not None and self.host is not None: + raise ValueError("`subdomain` and `host` are mutually-exclusive") + + if self.host is None: + return + + if self.app and not self.app.url_map.host_matching: + raise ValueError( + "`host` should only be set if your Flask app is using `host_matching`." + ) + + if self.host.strip() in {"*", ADMIN_ROUTES_HOST_VARIABLE}: + self.host = ADMIN_ROUTES_HOST_VARIABLE + + elif "<" in self.host and ">" in self.host: + raise ValueError( + "`host` must either be a host name with no variables, to serve all " + "Flask-Admin routes from a single host, or `*` to match the current " + "request's host." + ) + + def add_view(self, view: BaseView) -> None: """ - Add a view to the collection. + Add a view to the collection. - :param view: - View to add. + :param view: + View to add. """ # Add to views self._views.append(view) # If app was provided in constructor, register view with Flask app if self.app is not None: - self.app.register_blueprint(view.create_blueprint(self)) + self.app.register_blueprint( + view.create_blueprint(self), + host=self.host, + ) self._add_view_to_menu(view) - def _set_admin_index_view(self, index_view=None, - endpoint=None, url=None): - """ - Add the admin index view. - - :param index_view: - Home page view to use. Defaults to `AdminIndexView`. - :param url: - Base URL - :param endpoint: - Base endpoint name for index view. If you use multiple instances of the `Admin` class with - a single Flask application, you have to set a unique endpoint name for each instance. - """ - self.index_view = index_view or AdminIndexView(endpoint=endpoint, url=url) + def _set_admin_index_view( + self, + index_view: BaseView | None = None, + endpoint: str | None = None, + url: str | None = None, + ) -> None: + """ + Add the admin index view. + + :param index_view: + Home page view to use. Defaults to `AdminIndexView`. + :param url: + Base URL + :param endpoint: + Base endpoint name for index view. If you use multiple instances of the + `Admin` class with a single Flask application, you have to set a unique + endpoint name for each instance. + """ + self.index_view: BaseView = ( # type: ignore[no-redef] + index_view or AdminIndexView(endpoint=endpoint, url=url) + ) self.endpoint = endpoint or self.index_view.endpoint self.url = url or self.index_view.url @@ -562,36 +693,69 @@ def _set_admin_index_view(self, index_view=None, # assume index view is always the first element of views. if len(self._views) > 0: self._views[0] = self.index_view - self._menu[0] = MenuView(self.index_view.name, self.index_view) + self._menu[0] = MenuView( + self.index_view.name, # type: ignore[arg-type] + self.index_view, + ) else: self.add_view(self.index_view) - def add_views(self, *args): + def add_views(self, *args: t.Any) -> None: """ - Add one or more views to the collection. + Add one or more views to the collection. - Examples:: + Examples:: - admin.add_views(view1) - admin.add_views(view1, view2, view3, view4) - admin.add_views(*my_list) + admin.add_views(view1) + admin.add_views(view1, view2, view3, view4) + admin.add_views(*my_list) - :param args: - Argument list including the views to add. + :param args: + Argument list including the views to add. """ for view in args: self.add_view(view) - def add_sub_category(self, name, parent_name): + def add_category( + self, + name: str, + class_name: str | None = None, + icon_type: str | None = None, + icon_value: str | None = None, + ) -> None: + """ + Add a category of a given name + + :param name: + The name of the new menu category. + :param class_name: + The class name for the new menu category. + :param icon_type: + The icon name for the new menu category. + :param icon_value: + The icon value for the new menu category. + """ + cat_text = as_unicode(name) + + category = self.get_category_menu_item(name) + if category: + return + + category = MenuCategory( + name, class_name=class_name, icon_type=icon_type, icon_value=icon_value + ) + self._menu_categories[cat_text] = category + self._menu.append(category) + def add_sub_category(self, name: str, parent_name: str) -> None: """ - Add a category of a given name underneath - the category with parent_name. + Add a category of a given name underneath + the category with parent_name. - :param name: - The name of the new menu category. - :param parent_name: - The name of a parent_name category + :param name: + The name of the new menu category. + :param parent_name: + The name of a parent_name category """ name_text = as_unicode(name) @@ -603,42 +767,44 @@ def add_sub_category(self, name, parent_name): self._menu_categories[name_text] = category parent.add_child(category) - def add_link(self, link): + def add_link(self, link: MenuLink) -> None: """ - Add link to menu links collection. + Add link to menu links collection. - :param link: - Link to add. + :param link: + Link to add. """ if link.category: self.add_menu_item(link, link.category) else: self._menu_links.append(link) - def add_links(self, *args): + def add_links(self, *args: MenuLink) -> None: """ - Add one or more links to the menu links collection. + Add one or more links to the menu links collection. - Examples:: + Examples:: - admin.add_links(link1) - admin.add_links(link1, link2, link3, link4) - admin.add_links(*my_list) + admin.add_links(link1) + admin.add_links(link1, link2, link3, link4) + admin.add_links(*my_list) - :param args: - Argument list including the links to add. + :param args: + Argument list including the links to add. """ for link in args: self.add_link(link) - def add_menu_item(self, menu_item, target_category=None): + def add_menu_item( + self, menu_item: BaseMenu, target_category: str | None = None + ) -> None: """ - Add menu item to menu tree hierarchy. + Add menu item to menu tree hierarchy. - :param menu_item: - MenuItem class instance - :param target_category: - Target category name + :param menu_item: + MenuItem class instance + :param target_category: + Target category name """ if target_category: cat_text = as_unicode(target_category) @@ -648,7 +814,10 @@ def add_menu_item(self, menu_item, target_category=None): # create a new menu category if one does not exist already if category is None: category = MenuCategory(target_category) - category.class_name = self.category_icon_classes.get(cat_text) + category.class_name = self.category_icon_classes.get( + cat_text + # type:ignore[assignment] + ) self._menu_categories[cat_text] = category self._menu.append(category) @@ -657,72 +826,87 @@ def add_menu_item(self, menu_item, target_category=None): else: self._menu.append(menu_item) - def _add_menu_item(self, menu_item, target_category): - warnings.warn('Admin._add_menu_item is obsolete - use Admin.add_menu_item instead.') + def _add_menu_item( + self, menu_item: BaseMenu, target_category: str | None = None + ) -> None: + warnings.warn( + "Admin._add_menu_item is obsolete - use Admin.add_menu_item instead.", + stacklevel=1, + ) return self.add_menu_item(menu_item, target_category) - def _add_view_to_menu(self, view): + def _add_view_to_menu(self, view: BaseView) -> None: """ - Add a view to the menu tree + Add a view to the menu tree - :param view: - View to add + :param view: + View to add """ - self.add_menu_item(MenuView(view.name, view), view.category) + self.add_menu_item( + MenuView( + view.name, # type: ignore[arg-type] + view, + ), + view.category, + ) - def get_category_menu_item(self, name): + def get_category_menu_item(self, name: str) -> MenuCategory | None: return self._menu_categories.get(name) - def init_app(self, app, index_view=None, - endpoint=None, url=None): + def init_app( + self, + app: Flask, + index_view: BaseView | None = None, + endpoint: str | None = None, + url: str | None = None, + ) -> None: """ - Register all views with the Flask application. - - :param app: - Flask application instance + Register all views with the Flask application. """ self.app = app + self._validate_admin_host_and_subdomain() self._init_extension() # Register Index view if index_view is not None: self._set_admin_index_view( - index_view=index_view, - endpoint=endpoint, - url=url + index_view=index_view, endpoint=endpoint, url=url ) # Register views for view in self._views: - app.register_blueprint(view.create_blueprint(self)) + app.register_blueprint(view.create_blueprint(self), host=self.host) - def _init_extension(self): - if not hasattr(self.app, 'extensions'): - self.app.extensions = dict() + def _init_extension(self) -> None: + if not hasattr(self.app, "extensions"): + self.app.extensions = dict() # type: ignore[attr-defined] - admins = self.app.extensions.get('admin', []) + admins = self.app.extensions.get("admin", []) # type: ignore[union-attr] for p in admins: if p.endpoint == self.endpoint: - raise Exception(u'Cannot have two Admin() instances with same' - u' endpoint name.') + raise Exception( + "Cannot have two Admin() instances with same" " endpoint name." + ) if p.url == self.url and p.subdomain == self.subdomain: - raise Exception(u'Cannot assign two Admin() instances with same' - u' URL and subdomain to the same application.') + raise Exception( + "Cannot assign two Admin() instances with same" + " URL and subdomain to the same application." + ) admins.append(self) - self.app.extensions['admin'] = admins + self.app.extensions["admin"] = admins # type: ignore[union-attr] - def menu(self): + def menu(self) -> list[MenuView | MenuCategory | BaseMenu]: """ - Return the menu hierarchy. + Return the menu hierarchy. """ return self._menu - def menu_links(self): + def menu_links(self) -> list[MenuLink]: """ - Return menu links. + Return menu links. """ return self._menu_links diff --git a/flask_admin/blueprints.py b/flask_admin/blueprints.py new file mode 100644 index 0000000000..be575ca1a8 --- /dev/null +++ b/flask_admin/blueprints.py @@ -0,0 +1,82 @@ +import typing as t + +from flask import Flask +from flask import request +from flask.blueprints import Blueprint as FlaskBlueprint +from flask.blueprints import BlueprintSetupState as FlaskBlueprintSetupState + +try: + from flask.sansio.app import App # Flask >3.0 +except ImportError: + from flask import Flask as App # Flask < 3.0 +from flask.typing import RouteCallable + +from flask_admin.consts import ADMIN_ROUTES_HOST_VARIABLE +from flask_admin.consts import ADMIN_ROUTES_HOST_VARIABLE_NAME + + +class _BlueprintSetupStateWithHostSupport(FlaskBlueprintSetupState): + """Adds the ability to set a hostname on all routes when registering the + blueprint. + """ + + def __init__( + self, + blueprint: FlaskBlueprint, + app: App, + options: t.Any, + first_registration: bool, + ) -> None: + super().__init__(blueprint, app, options, first_registration) + self.host = self.options.get("host") + + def add_url_rule( + self, + rule: str, + endpoint: str | None = None, + view_func: RouteCallable | None = None, + **options: t.Any, + ) -> None: + # Ensure that every route registered by this blueprint has the host parameter + options.setdefault("host", self.host) + super().add_url_rule(rule, endpoint, view_func, **options) + + +class _BlueprintWithHostSupport(FlaskBlueprint): + def make_setup_state( + self, app: App, options: t.Any, first_registration: bool = False + ) -> _BlueprintSetupStateWithHostSupport: + return _BlueprintSetupStateWithHostSupport( + self, app, options, first_registration + ) + + def attach_url_defaults_and_value_preprocessor(self, app: Flask, host: str) -> None: + if host != ADMIN_ROUTES_HOST_VARIABLE: + return + + # Automatically inject `admin_routes_host` into `url_for` calls on admin + # endpoints. + @self.url_defaults + def inject_admin_routes_host_if_required( + endpoint: str, values: dict[str, t.Any] + ) -> None: + if app.url_map.is_endpoint_expecting( + endpoint, ADMIN_ROUTES_HOST_VARIABLE_NAME + ): + values.setdefault(ADMIN_ROUTES_HOST_VARIABLE_NAME, request.host) + + # Automatically strip `admin_routes_host` from the endpoint values so + # that the view methods don't receive that parameter, as it's not actually + # required by any of them. + @self.url_value_preprocessor + def strip_admin_routes_host_from_static_endpoint( + endpoint: str | None, values: dict[str, t.Any] | None + ) -> None: + if ( + endpoint + and values + and app.url_map.is_endpoint_expecting( + endpoint, ADMIN_ROUTES_HOST_VARIABLE_NAME + ) + ): + values.pop(ADMIN_ROUTES_HOST_VARIABLE_NAME, None) diff --git a/flask_admin/consts.py b/flask_admin/consts.py index be1b4667e5..8419864606 100644 --- a/flask_admin/consts.py +++ b/flask_admin/consts.py @@ -1,8 +1,12 @@ # bootstrap glyph icon -ICON_TYPE_GLYPH = 'glyph' +ICON_TYPE_GLYPH = "glyph" # font awesome glyph icon -ICON_TYPE_FONT_AWESOME = 'fa' +ICON_TYPE_FONT_AWESOME = "fa" # image relative to Flask static folder -ICON_TYPE_IMAGE = 'image' +ICON_TYPE_IMAGE = "image" # external image -ICON_TYPE_IMAGE_URL = 'image-url' +ICON_TYPE_IMAGE_URL = "image-url" + + +ADMIN_ROUTES_HOST_VARIABLE = "" +ADMIN_ROUTES_HOST_VARIABLE_NAME = "admin_routes_host" diff --git a/flask_admin/contrib/__init__.py b/flask_admin/contrib/__init__.py index 42e33a76c0..5f7329600b 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/appengine/__init__.py b/flask_admin/contrib/appengine/__init__.py deleted file mode 100644 index 737b65c856..0000000000 --- a/flask_admin/contrib/appengine/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# flake8: noqa -try: - import wtforms_appengine -except ImportError: - raise Exception('Please install wtforms_appengine in order to use appengine backend') - -from .view import ModelView diff --git a/flask_admin/contrib/appengine/fields.py b/flask_admin/contrib/appengine/fields.py deleted file mode 100644 index e75213f744..0000000000 --- a/flask_admin/contrib/appengine/fields.py +++ /dev/null @@ -1,18 +0,0 @@ -from wtforms.fields import StringField -from google.appengine.ext import ndb - -import decimal - - -class GeoPtPropertyField(StringField): - def process_formdata(self, valuelist): - if valuelist: - try: - lat, lon = valuelist[0].split(',') - self.data = ndb.GeoPt( - decimal.Decimal(lat.strip()), - decimal.Decimal(lon.strip()) - ) - - except (decimal.InvalidOperation, ValueError): - raise ValueError('Not a valid coordinate location') diff --git a/flask_admin/contrib/appengine/form.py b/flask_admin/contrib/appengine/form.py deleted file mode 100644 index fa5f490a77..0000000000 --- a/flask_admin/contrib/appengine/form.py +++ /dev/null @@ -1,10 +0,0 @@ -from wtforms_appengine.ndb import ModelConverter -from .fields import GeoPtPropertyField -from flask_admin.model.form import converts - - -class AdminModelConverter(ModelConverter): - @converts('GeoPt') - def convert_GeoPtProperty(self, model, prop, kwargs): - """Returns a form field for a ``ndb.GeoPtProperty``.""" - return GeoPtPropertyField(**kwargs) diff --git a/flask_admin/contrib/appengine/view.py b/flask_admin/contrib/appengine/view.py deleted file mode 100644 index 010e4bdab6..0000000000 --- a/flask_admin/contrib/appengine/view.py +++ /dev/null @@ -1,235 +0,0 @@ -import logging - -from flask_admin.model import BaseModelView -from wtforms_appengine import db as wt_db -from wtforms_appengine import ndb as wt_ndb - -from google.appengine.ext import db -from google.appengine.ext import ndb - -from flask_wtf import Form -from flask_admin.model.form import create_editable_list_form -from .form import AdminModelConverter - - -class NdbModelView(BaseModelView): - """ - AppEngine NDB model scaffolding. - """ - - def get_pk_value(self, model): - return model.key.urlsafe() - - def scaffold_list_columns(self): - return sorted([k for (k, v) in self.model.__dict__.iteritems() if isinstance(v, ndb.Property)]) - - def scaffold_sortable_columns(self): - return [k for (k, v) in self.model.__dict__.iteritems() if isinstance(v, ndb.Property) and v._indexed] - - def init_search(self): - return None - - def is_valid_filter(self): - pass - - def scaffold_filters(self): - # TODO: implement - pass - - form_args = None - - model_form_converter = AdminModelConverter - """ - Model form conversion class. Use this to implement custom field conversion logic. - - For example:: - - class MyModelConverter(AdminModelConverter): - pass - - - class MyAdminView(ModelView): - model_form_converter = MyModelConverter - """ - - def scaffold_form(self): - form_class = wt_ndb.model_form( - self.model(), - base_class=Form, - only=self.form_columns, - exclude=self.form_excluded_columns, - field_args=self.form_args, - converter=self.model_form_converter(), - ) - return form_class - - def scaffold_list_form(self, widget=None, validators=None): - form_class = wt_ndb.model_form( - self.model(), - base_class=Form, - only=self.column_editable_list, - field_args=self.form_args, - converter=self.model_form_converter(), - ) - result = create_editable_list_form(Form, form_class, widget) - return result - - def get_list(self, page, sort_field, sort_desc, search, filters, - page_size=None): - # TODO: implement filters (don't think search can work here) - - q = self.model.query() - - if sort_field: - order_field = getattr(self.model, sort_field) - if sort_desc: - order_field = -order_field - q = q.order(order_field) - - if not page_size: - page_size = self.page_size - - results = q.fetch(page_size, offset=page * page_size) - - return q.count(), results - - def get_one(self, urlsafe_key): - return ndb.Key(urlsafe=urlsafe_key).get() - - def create_model(self, form): - try: - model = self.model() - form.populate_obj(model) - model.put() - except Exception as ex: - if not self.handle_view_exception(ex): - # flash(gettext('Failed to create record. %(error)s', - # error=ex), 'error') - logging.exception('Failed to create record.') - return False - else: - self.after_model_change(form, model, True) - - return model - - def update_model(self, form, model): - try: - form.populate_obj(model) - model.put() - except Exception as ex: - if not self.handle_view_exception(ex): - # flash(gettext('Failed to update record. %(error)s', - # error=ex), 'error') - logging.exception('Failed to update record.') - return False - else: - self.after_model_change(form, model, False) - - return True - - def delete_model(self, model): - try: - model.key.delete() - except Exception as ex: - if not self.handle_view_exception(ex): - # flash(gettext('Failed to delete record. %(error)s', - # error=ex), - # 'error') - logging.exception('Failed to delete record.') - return False - else: - self.after_model_delete(model) - - return True - - -class DbModelView(BaseModelView): - """ - AppEngine DB model scaffolding. - """ - - def get_pk_value(self, model): - return str(model.key()) - - def scaffold_list_columns(self): - return sorted([k for (k, v) in self.model.__dict__.iteritems() if isinstance(v, db.Property)]) - - def scaffold_sortable_columns(self): - # We use getattr() because ReferenceProperty does not specify a 'indexed' field - return [k for (k, v) in self.model.__dict__.iteritems() - if isinstance(v, db.Property) and getattr(v, 'indexed', None)] - - def init_search(self): - return None - - def is_valid_filter(self): - pass - - def scaffold_filters(self): - # TODO: implement - pass - - def scaffold_form(self): - return wt_db.model_form(self.model()) - - def get_list(self, page, sort_field, sort_desc, search, filters): - # TODO: implement filters (don't think search can work here) - - q = self.model.all() - - if sort_field: - if sort_desc: - sort_field = "-" + sort_field - q.order(sort_field) - - results = q.fetch(self.page_size, offset=page * self.page_size) - return q.count(), results - - def get_one(self, encoded_key): - return db.get(db.Key(encoded=encoded_key)) - - def create_model(self, form): - try: - model = self.model() - form.populate_obj(model) - model.put() - return model - except Exception as ex: - if not self.handle_view_exception(ex): - # flash(gettext('Failed to create record. %(error)s', - # error=ex), 'error') - logging.exception('Failed to create record.') - return False - - def update_model(self, form, model): - try: - form.populate_obj(model) - model.put() - return True - except Exception as ex: - if not self.handle_view_exception(ex): - # flash(gettext('Failed to update record. %(error)s', - # error=ex), 'error') - logging.exception('Failed to update record.') - return False - - def delete_model(self, model): - try: - model.delete() - return True - except Exception as ex: - if not self.handle_view_exception(ex): - # flash(gettext('Failed to delete record. %(error)s', - # error=ex), - # 'error') - logging.exception('Failed to delete record.') - return False - - -def ModelView(model): - if issubclass(model, ndb.Model): - return NdbModelView(model) - elif issubclass(model, db.Model): - return DbModelView(model) - else: - raise ValueError("Unsupported model: %s" % model) diff --git a/flask_admin/contrib/fileadmin/__init__.py b/flask_admin/contrib/fileadmin/__init__.py index bc1dedfa24..80ba05faeb 100644 --- a/flask_admin/contrib/fileadmin/__init__.py +++ b/flask_admin/contrib/fileadmin/__init__.py @@ -1,67 +1,128 @@ -import warnings -from datetime import datetime import os import os.path as op import platform +import posixpath import re import shutil +import sys +import typing as t +import warnings +from datetime import datetime +from functools import partial from operator import itemgetter - -from flask import flash, redirect, abort, request, send_file +from urllib.parse import quote +from urllib.parse import urljoin + +from flask import abort +from flask import flash +from flask import redirect +from flask import request +from flask import send_file +from werkzeug.datastructures import FileStorage from werkzeug.utils import secure_filename -from wtforms import fields, validators +from wtforms import Field +from wtforms import fields +from wtforms import validators + +from flask_admin import form +from flask_admin import helpers +from flask_admin._compat import as_unicode +from flask_admin._types import T_PATH_LIKE +from flask_admin._types import T_RESPONSE +from flask_admin._types import T_TRANSLATABLE +from flask_admin.actions import action +from flask_admin.actions import ActionsMixin +from flask_admin.babel import gettext +from flask_admin.babel import lazy_gettext +from flask_admin.base import BaseView +from flask_admin.base import expose + +if sys.version_info >= (3, 11): + from datetime import UTC + + utc_fromtimestamp = partial(datetime.fromtimestamp, tz=UTC) +else: + utc_fromtimestamp = datetime.utcfromtimestamp + + +class BaseFileStorage: + def __init__(self, on_windows: bool) -> None: + """ + Constructor. -from flask_admin import form, helpers -from flask_admin._compat import urljoin, as_unicode, quote -from flask_admin.base import BaseView, expose -from flask_admin.actions import action, ActionsMixin -from flask_admin.babel import gettext, lazy_gettext + :param on_windows: + True for Windows storage, this parameter is needed only if your storage + is placed externally on a platform different than the one of you are + hosting your flask project on. e.g if Flask app runs on Windows with + S3 storage (on Linux), then on_windows must be set to False. + """ + self.on_windows = on_windows + def normpath(self, path: str) -> str: + """ + returns the correct normalized path based on the platform of the storage. -class LocalFileStorage(object): - def __init__(self, base_path): + :param path: + path to be normalized. """ - Constructor. + if self.on_windows: + return op.normpath(path) + else: + return posixpath.normpath(path) - :param base_path: - Base file storage location + +class LocalFileStorage(BaseFileStorage): + def __init__(self, base_path: str | bytes) -> None: """ + Constructor. + + :param base_path: + Base file storage location + """ + + on_windows = platform.system() == "Windows" + super().__init__(on_windows=on_windows) + self.base_path = as_unicode(base_path) self.separator = os.sep if not self.path_exists(self.base_path): - raise IOError('FileAdmin path "%s" does not exist or is not accessible' % self.base_path) + raise OSError( + f'FileAdmin path "{self.base_path}" does not exist or is not accessible' + ) - def get_base_path(self): + def get_base_path(self) -> str: """ - Return base path. Override to customize behavior (per-user - directories, etc) + Return base path. Override to customize behavior (per-user + directories, etc) """ return op.normpath(self.base_path) - def make_dir(self, path, directory): + def make_dir(self, path: str, directory: str) -> None: """ - Creates a directory `directory` under the `path` + Creates a directory `directory` under the `path` """ os.mkdir(op.join(path, directory)) - def get_files(self, path, directory): + def get_files( + self, path: str, directory: str + ) -> list[tuple[str, str, bool, int, float]]: """ - Gets a list of tuples representing the files in the `directory` - under the `path` + Gets a list of tuples representing the files in the `directory` + under the `path` - :param path: - The path up to the directory + :param path: + The path up to the directory - :param directory: - The directory that will have its files listed + :param directory: + The directory that will have its files listed - Each tuple represents a file and it should contain the file name, - the relative path, a flag signifying if it is a directory, the file - size in bytes and the time last modified in seconds since the epoch + Each tuple represents a file and it should contain the file name, + the relative path, a flag signifying if it is a directory, the file + size in bytes and the time last modified in seconds since the epoch """ - items = [] + items: list[tuple[str, str, bool, int, float]] = [] for f in os.listdir(directory): fp = op.join(directory, f) rel_path = op.join(path, f) @@ -71,70 +132,69 @@ def get_files(self, path, directory): items.append((f, rel_path, is_dir, size, last_modified)) return items - def delete_tree(self, directory): + def delete_tree(self, directory: str) -> None: """ - Deletes the directory `directory` and all its files and subdirectories + Deletes the directory `directory` and all its files and subdirectories """ shutil.rmtree(directory) - def delete_file(self, file_path): + def delete_file(self, file_path: T_PATH_LIKE) -> None: """ - Deletes the file located at `file_path` + Deletes the file located at `file_path` """ os.remove(file_path) - def path_exists(self, path): + def path_exists(self, path: T_PATH_LIKE) -> bool: """ - Check if `path` exists + Check if `path` exists """ return op.exists(path) - def rename_path(self, src, dst): + def rename_path(self, src: T_PATH_LIKE, dst: T_PATH_LIKE) -> None: """ - Renames `src` to `dst` + Renames `src` to `dst` """ os.rename(src, dst) - def is_dir(self, path): + def is_dir(self, path: T_PATH_LIKE) -> bool: """ - Check if `path` is a directory + Check if `path` is a directory """ return op.isdir(path) - def send_file(self, file_path): + def send_file(self, file_path: T_PATH_LIKE) -> T_RESPONSE: """ - Sends the file located at `file_path` to the user + Sends the file located at `file_path` to the user """ - return send_file(file_path) + return send_file(file_path) # type: ignore[arg-type, type-var] - def read_file(self, path): + def read_file(self, path: T_PATH_LIKE) -> bytes: """ - Reads the content of the file located at `file_path`. + Reads the content of the file located at `file_path`. """ - with open(path, 'rb') as f: + with open(path, "rb") as f: return f.read() - def write_file(self, path, content): + def write_file(self, path: T_PATH_LIKE, content: str) -> int: """ - Writes `content` to the file located at `file_path`. + Writes `content` to the file located at `file_path`. """ - with open(path, 'w') as f: + with open(path, "w", encoding="utf-8") as f: return f.write(content) - def save_file(self, path, file_data): + def save_file(self, path: str, file_data: FileStorage) -> None: """ - Save uploaded file to the disk + Save uploaded file to the disk - :param path: - Path to save to - :param file_data: - Werkzeug `FileStorage` object + :param path: + Path to save to + :param file_data: + Werkzeug `FileStorage` object """ file_data.save(path) class BaseFileAdmin(BaseView, ActionsMixin): - can_upload = True """ Is file upload allowed. @@ -175,7 +235,7 @@ class MyAdmin(FileAdmin): allowed_extensions = ('swf', 'jpg', 'gif', 'png') """ - editable_extensions = tuple() + editable_extensions: t.Collection[str] = tuple() """ List of editable extensions, in lower case. @@ -185,54 +245,55 @@ class MyAdmin(FileAdmin): editable_extensions = ('md', 'html', 'txt') """ - list_template = 'admin/file/list.html' + list_template = "admin/file/list.html" """ File list template """ - upload_template = 'admin/file/form.html' + upload_template = "admin/file/form.html" """ File upload template """ - upload_modal_template = 'admin/file/modals/form.html' + upload_modal_template = "admin/file/modals/form.html" """ File upload template for modal dialog """ - mkdir_template = 'admin/file/form.html' + mkdir_template = "admin/file/form.html" """ Directory creation (mkdir) template """ - mkdir_modal_template = 'admin/file/modals/form.html' + mkdir_modal_template = "admin/file/modals/form.html" """ Directory creation (mkdir) template for modal dialog """ - rename_template = 'admin/file/form.html' + rename_template = "admin/file/form.html" """ Rename template """ - rename_modal_template = 'admin/file/modals/form.html' + rename_modal_template = "admin/file/modals/form.html" """ Rename template for modal dialog """ - edit_template = 'admin/file/form.html' + edit_template = "admin/file/form.html" """ Edit template """ - edit_modal_template = 'admin/file/modals/form.html' + edit_modal_template = "admin/file/modals/form.html" """ Edit template for modal dialog """ form_base_class = form.BaseForm """ - Base form class. Will be used to create the upload, rename, edit, and delete form. + Base form class. Will be used to create the upload, rename, edit, and delete + form. Allows enabling CSRF validation and useful if you want to have custom constructor or override some fields. @@ -262,10 +323,10 @@ class MyAdmin(FileAdmin): """Setting this to true will display the edit view as a modal dialog.""" # List view - possible_columns = 'name', 'rel_path', 'is_dir', 'size', 'date' + possible_columns = "name", "rel_path", "is_dir", "size", "date" """A list of possible columns to display.""" - column_list = 'name', 'size', 'date' + column_list = "name", "size", "date" """A list of columns to display.""" column_sortable_list = column_list @@ -277,181 +338,208 @@ class MyAdmin(FileAdmin): default_desc = 0 """The default desc value.""" - column_labels = dict((column, column.capitalize()) for column in column_list) + column_labels: dict[str, T_TRANSLATABLE] = dict( + (column, column.capitalize()) for column in column_list + ) """A dict from column names to their labels.""" - date_format = '%Y-%m-%d %H:%M:%S' + date_format = "%Y-%m-%d %H:%M:%S" """Date column display format.""" - def __init__(self, base_url=None, name=None, category=None, endpoint=None, - url=None, verify_path=True, menu_class_name=None, - menu_icon_type=None, menu_icon_value=None, storage=None): - """ - Constructor. - - :param base_url: - Base URL for the files - :param name: - Name of this view. If not provided, will default to the class name. - :param category: - View category - :param endpoint: - Endpoint name for the view - :param url: - URL for view - :param verify_path: - Verify if path exists. If set to `True` and path does not exist - will raise an exception. - :param storage: - The storage backend that the `BaseFileAdmin` will use to operate on the files. + def __init__( + self, + base_url: str | None = None, + name: str | None = None, + category: str | None = None, + endpoint: str | None = None, + url: str | None = None, + verify_path: bool = True, + menu_class_name: str | None = None, + menu_icon_type: str | None = None, + menu_icon_value: str | None = None, + storage: BaseFileStorage | None = None, + ) -> None: + """ + Constructor. + + :param base_url: + Base URL for the files + :param name: + Name of this view. If not provided, will default to the class name. + :param category: + View category + :param endpoint: + Endpoint name for the view + :param url: + URL for view + :param verify_path: + Verify if path exists. If set to `True` and path does not exist + will raise an exception. + :param storage: + The storage backend that the `BaseFileAdmin` will use to operate on the + files. """ self.base_url = base_url self.storage = storage self.init_actions() - self._on_windows = platform.system() == 'Windows' + self._on_windows = platform.system() == "Windows" # Convert allowed_extensions to set for quick validation - if (self.allowed_extensions and - not isinstance(self.allowed_extensions, set)): + if self.allowed_extensions and not isinstance(self.allowed_extensions, set): self.allowed_extensions = set(self.allowed_extensions) # Convert editable_extensions to set for quick validation - if (self.editable_extensions and - not isinstance(self.editable_extensions, set)): + if self.editable_extensions and not isinstance(self.editable_extensions, set): self.editable_extensions = set(self.editable_extensions) - super(BaseFileAdmin, self).__init__(name, category, endpoint, url, - menu_class_name=menu_class_name, - menu_icon_type=menu_icon_type, - menu_icon_value=menu_icon_value) + super().__init__( + name, + category, + endpoint, + url, + menu_class_name=menu_class_name, + menu_icon_type=menu_icon_type, + menu_icon_value=menu_icon_value, + ) - def is_accessible_path(self, path): + def is_accessible_path(self, path: str) -> bool: """ - Verify if the provided path is accessible for the current user. + Verify if the provided path is accessible for the current user. - Override to customize behavior. + Override to customize behavior. - :param path: - Relative path to the root + :param path: + Relative path to the root """ return True - def get_base_path(self): + def get_base_path(self) -> str: """ - Return base path. Override to customize behavior (per-user - directories, etc) + Return base path. Override to customize behavior (per-user + directories, etc) """ - return self.storage.get_base_path() + return self.storage.get_base_path() # type: ignore[union-attr] - def get_base_url(self): + def get_base_url(self) -> str | None: """ - Return base URL. Override to customize behavior (per-user - directories, etc) + Return base URL. Override to customize behavior (per-user + directories, etc) """ return self.base_url - def get_upload_form(self): + def get_upload_form(self) -> type[form.BaseForm]: """ - Upload form class for file upload view. + Upload form class for file upload view. - Override to implement customized behavior. + Override to implement customized behavior. """ - class UploadForm(self.form_base_class): + + class UploadForm(self.form_base_class): # type: ignore[name-defined, misc] """ - File upload form. Works with FileAdmin instance to check if it - is allowed to upload file with given extension. + File upload form. Works with FileAdmin instance to check if it + is allowed to upload file with given extension. """ - upload = fields.FileField(lazy_gettext('File to upload')) - def __init__(self, *args, **kwargs): - super(UploadForm, self).__init__(*args, **kwargs) - self.admin = kwargs['admin'] + upload = fields.FileField(lazy_gettext("File to upload")) + + def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: + super().__init__(*args, **kwargs) + self.admin = kwargs["admin"] - def validate_upload(self, field): + def validate_upload(self, field: Field) -> None: if not self.upload.data: - raise validators.ValidationError(gettext('File required.')) + raise validators.ValidationError(gettext("File required.")) filename = self.upload.data.filename if not self.admin.is_file_allowed(filename): - raise validators.ValidationError(gettext('Invalid file type.')) + raise validators.ValidationError(gettext("Invalid file type.")) return UploadForm - def get_edit_form(self): + def get_edit_form(self) -> type[form.BaseForm]: """ - Create form class for file editing view. + Create form class for file editing view. - Override to implement customized behavior. + Override to implement customized behavior. """ - class EditForm(self.form_base_class): - content = fields.TextAreaField(lazy_gettext('Content'), - (validators.required(),)) + + class EditForm(self.form_base_class): # type: ignore[name-defined, misc] + content = fields.TextAreaField( + lazy_gettext("Content"), (validators.InputRequired(),) + ) return EditForm - def get_name_form(self): + def get_name_form(self) -> type[form.BaseForm]: """ - Create form class for renaming and mkdir views. + Create form class for renaming and mkdir views. - Override to implement customized behavior. + Override to implement customized behavior. """ - def validate_name(self, field): - regexp = re.compile(r'^(?!^(PRN|AUX|CLOCK\$|NUL|CON|COM\d|LPT\d|\..*)(\..+)?$)[^\x00-\x1f\\?*:\";|/]+$') + + def validate_name(self: type[form.BaseForm], field: Field) -> None: + regexp = re.compile( + r"^(?!^(PRN|AUX|CLOCK\$|NUL|CON|COM\d|LPT\d|\..*)(\..+)?$)[^\x00-\x1f\\?*:\";|/]+$" + ) if not regexp.match(field.data): - raise validators.ValidationError(gettext('Invalid name')) + raise validators.ValidationError(gettext("Invalid name")) - class NameForm(self.form_base_class): + class NameForm(self.form_base_class): # type: ignore[name-defined, misc] """ - Form with a filename input field. + Form with a filename input field. - Validates if provided name is valid for *nix and Windows systems. + Validates if provided name is valid for *nix and Windows systems. """ - name = fields.StringField(lazy_gettext('Name'), - validators=[validators.Required(), - validate_name]) + + name = fields.StringField( + lazy_gettext("Name"), + validators=[validators.InputRequired(), validate_name], + ) path = fields.HiddenField() return NameForm - def get_delete_form(self): + def get_delete_form(self) -> type[form.BaseForm]: """ - Create form class for model delete view. + Create form class for model delete view. - Override to implement customized behavior. + Override to implement customized behavior. """ - class DeleteForm(self.form_base_class): - path = fields.HiddenField(validators=[validators.Required()]) + + class DeleteForm(self.form_base_class): # type: ignore[name-defined, misc] + path = fields.HiddenField(validators=[validators.InputRequired()]) return DeleteForm - def get_action_form(self): + def get_action_form(self) -> type[form.BaseForm]: """ - Create form class for model action. + Create form class for model action. - Override to implement customized behavior. + Override to implement customized behavior. """ - class ActionForm(self.form_base_class): + + class ActionForm(self.form_base_class): # type: ignore[name-defined, misc] action = fields.HiddenField() url = fields.HiddenField() # rowid is retrieved using getlist, for backward compatibility return ActionForm - def upload_form(self): + def upload_form(self) -> form.BaseForm: """ - Instantiate file upload form and return it. + Instantiate file upload form and return it. - Override to implement custom behavior. + Override to implement custom behavior. """ upload_form_class = self.get_upload_form() if request.form: # Workaround for allowing both CSRF token + FileField to be submitted # https://bitbucket.org/danjac/flask-wtf/issue/12/fieldlist-filefield-does-not-follow formdata = request.form.copy() # as request.form is immutable - formdata.update(request.files) + formdata.update(request.files) # type: ignore[arg-type] # admin=self allows the form to use self.is_file_allowed return upload_form_class(formdata, admin=self) @@ -460,11 +548,11 @@ def upload_form(self): else: return upload_form_class(admin=self) - def name_form(self): + def name_form(self) -> form.BaseForm: """ - Instantiate form used in rename and mkdir then return it. + Instantiate form used in rename and mkdir then return it. - Override to implement custom behavior. + Override to implement custom behavior. """ name_form_class = self.get_name_form() if request.form: @@ -474,11 +562,11 @@ def name_form(self): else: return name_form_class() - def edit_form(self): + def edit_form(self) -> form.BaseForm: """ - Instantiate file editing form and return it. + Instantiate file editing form and return it. - Override to implement custom behavior. + Override to implement custom behavior. """ edit_form_class = self.get_edit_form() if request.form: @@ -486,11 +574,11 @@ def edit_form(self): else: return edit_form_class() - def delete_form(self): + def delete_form(self) -> form.BaseForm: """ - Instantiate file delete form and return it. + Instantiate file delete form and return it. - Override to implement custom behavior. + Override to implement custom behavior. """ delete_form_class = self.get_delete_form() if request.form: @@ -498,11 +586,11 @@ def delete_form(self): else: return delete_form_class() - def action_form(self): + def action_form(self) -> form.BaseForm: """ - Instantiate action form and return it. + Instantiate action form and return it. - Override to implement custom behavior. + Override to implement custom behavior. """ action_form_class = self.get_action_form() if request.form: @@ -510,18 +598,18 @@ def action_form(self): else: return action_form_class() - def is_file_allowed(self, filename): + def is_file_allowed(self, filename: str) -> bool: """ - Verify if file can be uploaded. + Verify if file can be uploaded. - Override to customize behavior. + Override to customize behavior. - :param filename: - Source file name + :param filename: + Source file name """ ext = op.splitext(filename)[1].lower() - if ext.startswith('.'): + if ext.startswith("."): ext = ext[1:] if self.allowed_extensions and ext not in self.allowed_extensions: @@ -529,18 +617,18 @@ def is_file_allowed(self, filename): return True - def is_file_editable(self, filename): + def is_file_editable(self, filename: str) -> bool: """ - Determine if the file can be edited. + Determine if the file can be edited. - Override to customize behavior. + Override to customize behavior. - :param filename: - Source file name + :param filename: + Source file name """ ext = op.splitext(filename)[1].lower() - if ext.startswith('.'): + if ext.startswith("."): ext = ext[1:] if not self.editable_extensions or ext not in self.editable_extensions: @@ -548,193 +636,195 @@ def is_file_editable(self, filename): return True - def is_in_folder(self, base_path, directory): + def is_in_folder(self, base_path: str, directory: T_PATH_LIKE) -> bool: """ - Verify that `directory` is in `base_path` folder + Verify that `directory` is in `base_path` folder - :param base_path: - Base directory path - :param directory: - Directory path to check + :param base_path: + Base directory path + :param directory: + Directory path to check """ - return op.normpath(directory).startswith(base_path) + return self.normpath(directory).startswith(base_path) # type: ignore[arg-type] - def save_file(self, path, file_data): + def save_file(self, path: str, file_data: FileStorage) -> None: """ - Save uploaded file to the storage + Save uploaded file to the storage - :param path: - Path to save to - :param file_data: - Werkzeug `FileStorage` object + :param path: + Path to save to + :param file_data: + Werkzeug `FileStorage` object """ - self.storage.save_file(path, file_data) + self.storage.save_file(path, file_data) # type: ignore[union-attr] - def validate_form(self, form): + def validate_form(self, form: form.BaseForm) -> bool: """ - Validate the form on submit. + Validate the form on submit. - :param form: - Form to validate + :param form: + Form to validate """ return helpers.validate_form_on_submit(form) - def _get_dir_url(self, endpoint, path=None, **kwargs): + def _get_dir_url( + self, endpoint: str, path: str | None = None, **kwargs: t.Any + ) -> str: """ - Return prettified URL + Return prettified URL - :param endpoint: - Endpoint name - :param path: - Directory path - :param kwargs: - Additional arguments + :param endpoint: + Endpoint name + :param path: + Directory path + :param kwargs: + Additional arguments """ if not path: return self.get_url(endpoint, **kwargs) else: if self._on_windows: - path = path.replace('\\', '/') + path = path.replace("\\", "/") - kwargs['path'] = path + kwargs["path"] = path return self.get_url(endpoint, **kwargs) - def _get_file_url(self, path, **kwargs): + def _get_file_url(self, path: str, **kwargs: t.Any) -> str: """ - Return static file url + Return static file url - :param path: - Static file path + :param path: + Static file path """ if self._on_windows: - path = path.replace('\\', '/') + path = path.replace("\\", "/") if self.is_file_editable(path): - route = '.edit' + route = ".edit" else: - route = '.download' + route = ".download" return self.get_url(route, path=path, **kwargs) - def _normalize_path(self, path): + def _normalize_path(self, path: str | None) -> tuple[str, str, str]: """ - Verify and normalize path. + Verify and normalize path. - If the path is not relative to the base directory, will raise a 404 exception. + If the path is not relative to the base directory, will raise a 404 exception. - If the path does not exist, this will also raise a 404 exception. + If the path does not exist, this will also raise a 404 exception. """ base_path = self.get_base_path() if path is None: directory = base_path - path = '' + path = "" else: - path = op.normpath(path) + path = self.normpath(path) if base_path: directory = self._separator.join([base_path, path]) else: directory = path - directory = op.normpath(directory) + directory = self.normpath(directory) if not self.is_in_folder(base_path, directory): abort(404) - if not self.storage.path_exists(directory): + if not self.storage.path_exists(directory): # type: ignore[union-attr] abort(404) return base_path, directory, path - def is_action_allowed(self, name): - if name == 'delete' and not self.can_delete: + def is_action_allowed(self, name: str) -> bool: + if name == "delete" and not self.can_delete: return False - elif name == 'edit' and len(self.editable_extensions) == 0: + elif name == "edit" and len(self.editable_extensions) == 0: return False return True - def on_rename(self, full_path, dir_base, filename): + def on_rename(self, full_path: str, dir_base: T_PATH_LIKE, filename: str) -> None: """ - Perform some actions after a file or directory has been renamed. + Perform some actions after a file or directory has been renamed. - Called from rename method + Called from rename method - By default do nothing. + By default do nothing. """ pass - def on_edit_file(self, full_path, path): + def on_edit_file(self, full_path: str, path: str) -> None: """ - Perform some actions after a file has been successfully changed. + Perform some actions after a file has been successfully changed. - Called from edit method + Called from edit method - By default do nothing. + By default do nothing. """ pass - def on_file_upload(self, directory, path, filename): + def on_file_upload(self, directory: T_PATH_LIKE, path: str, filename: str) -> None: """ - Perform some actions after a file has been successfully uploaded. + Perform some actions after a file has been successfully uploaded. - Called from upload method + Called from upload method - By default do nothing. + By default do nothing. """ pass - def on_mkdir(self, parent_dir, dir_name): + def on_mkdir(self, parent_dir: str, dir_name: str) -> None: """ - Perform some actions after a directory has successfully been created. + Perform some actions after a directory has successfully been created. - Called from mkdir method + Called from mkdir method - By default do nothing. + By default do nothing. """ pass - def before_directory_delete(self, full_path, dir_name): + def before_directory_delete(self, full_path: str, dir_name: str) -> None: """ - Perform some actions before a directory has successfully been deleted. + Perform some actions before a directory has successfully been deleted. - Called from delete method + Called from delete method - By default do nothing. + By default do nothing. """ pass - def before_file_delete(self, full_path, filename): + def before_file_delete(self, full_path: str, filename: str) -> None: """ - Perform some actions before a file has successfully been deleted. + Perform some actions before a file has successfully been deleted. - Called from delete method + Called from delete method - By default do nothing. + By default do nothing. """ pass - def on_directory_delete(self, full_path, dir_name): + def on_directory_delete(self, full_path: str, dir_name: str) -> None: """ - Perform some actions after a directory has successfully been deleted. + Perform some actions after a directory has successfully been deleted. - Called from delete method + Called from delete method - By default do nothing. + By default do nothing. """ pass - def on_file_delete(self, full_path, filename): + def on_file_delete(self, full_path: str, filename: str) -> None: """ - Perform some actions after a file has successfully been deleted. + Perform some actions after a file has successfully been deleted. - Called from delete method + Called from delete method - By default do nothing. + By default do nothing. """ pass - def is_column_visible(self, column): + def is_column_visible(self, column: str) -> bool: """ Determines if the given column is visible. :param column: The column to query. @@ -742,7 +832,7 @@ def is_column_visible(self, column): """ return column in self.column_list - def is_column_sortable(self, column): + def is_column_sortable(self, column: str) -> bool: """ Determines if the given column is sortable. :param column: The column to query. @@ -750,7 +840,7 @@ def is_column_sortable(self, column): """ return column in self.column_sortable_list - def column_label(self, column): + def column_label(self, column: str) -> T_TRANSLATABLE: """ Gets the column's label. :param column: The column to query. @@ -758,55 +848,68 @@ def column_label(self, column): """ return self.column_labels[column] - def timestamp_format(self, timestamp): + def timestamp_format(self, timestamp: float) -> str: """ Formats the timestamp to a date format. :param timestamp: The timestamp to format. :return: A formatted date. """ + if timestamp == 0: + return "" return datetime.fromtimestamp(timestamp).strftime(self.date_format) - def _save_form_files(self, directory, path, form): - filename = self._separator.join([directory, secure_filename(form.upload.data.filename)]) - - if self.storage.path_exists(filename): - secure_name = self._separator.join([path, secure_filename(form.upload.data.filename)]) - raise Exception(gettext('File "%(name)s" already exists.', - name=secure_name)) + def _save_form_files(self, directory: str, path: str, form: t.Any) -> None: + filename = self._separator.join( + [directory, secure_filename(form.upload.data.filename)] + ) + + if self.storage.path_exists(filename): # type: ignore[union-attr] + secure_name = self._separator.join( + [path, secure_filename(form.upload.data.filename)] + ) + raise Exception( + gettext('File "%(name)s" already exists.', name=secure_name) + ) else: self.save_file(filename, form.upload.data) self.on_file_upload(directory, path, filename) + def normpath(self, path: str) -> str: + return self.storage.normpath(path) # type: ignore[union-attr] + @property - def _separator(self): - return self.storage.separator + def _separator(self) -> str: + return self.storage.separator # type: ignore[union-attr] - def _get_breadcrumbs(self, path): + def _get_breadcrumbs(self, path: str) -> list[tuple[str, str]]: """ - Returns a list of tuples with each tuple containing the folder and - the tree up to that folder when traversing down the `path` + Returns a list of tuples with each tuple containing the folder and + the tree up to that folder when traversing down the `path` """ - accumulator = [] - breadcrumbs = [] + accumulator: list[str] = [] + breadcrumbs: list[tuple[str, str]] = [] + for n in path.split(self._separator): accumulator.append(n) breadcrumbs.append((n, self._separator.join(accumulator))) return breadcrumbs - @expose('/old_index') - @expose('/old_b/') - def index(self, path=None): - warnings.warn('deprecated: use index_view instead.', DeprecationWarning) - return redirect(self.get_url('.index_view', path=path)) + @expose("/old_index") + @expose("/old_b/") + def index(self, path: str | None = None) -> T_RESPONSE: + warnings.warn( + "deprecated: use index_view instead.", DeprecationWarning, stacklevel=1 + ) + return redirect(self.get_url(".index_view", path=path)) - @expose('/') - @expose('/b/') - def index_view(self, path=None): + @expose("/") + @expose("/b/") + def index_view(self, path: str | None = None) -> T_RESPONSE | str: """ - Index view method + Index view method - :param path: - Optional directory path. If not provided, will use the base directory + :param path: + Optional directory path. If not provided, will use the base directory """ if self.can_delete: delete_form = self.delete_form() @@ -816,27 +919,29 @@ def index_view(self, path=None): # Get path and verify if it is valid base_path, directory, path = self._normalize_path(path) if not self.is_accessible_path(path): - flash(gettext('Permission denied.'), 'error') - return redirect(self._get_dir_url('.index_view')) + flash(gettext("Permission denied."), "error") + return redirect(self._get_dir_url(".index_view")) # Get directory listing - items = [] + items: list[tuple[str, str | None, bool, int, int]] = [] # Parent directory if directory != base_path: - parent_path = op.normpath(self._separator.join([path, '..'])) - if parent_path == '.': + parent_path: str | None = self.normpath(self._separator.join([path, ".."])) + if parent_path == ".": parent_path = None - items.append(('..', parent_path, True, 0, 0)) + items.append(("..", parent_path, True, 0, 0)) - for item in self.storage.get_files(path, directory): + for item in self.storage.get_files(path, directory): # type: ignore[union-attr] file_name, rel_path, is_dir, size, last_modified = item if self.is_accessible_path(rel_path): items.append(item) - sort_column = request.args.get('sort', None, type=str) or self.default_sort_column - sort_desc = request.args.get('desc', 0, type=int) or self.default_desc + sort_column = ( + request.args.get("sort", None, type=str) or self.default_sort_column + ) + sort_desc = request.args.get("desc", 0, type=int) or self.default_desc if sort_column is None: if self.default_sort_column: @@ -856,9 +961,15 @@ 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) + items.sort( + key=itemgetter(column_index), # type: ignore[call-overload] + reverse=sort_desc, + ) # Generate breadcrumbs breadcrumbs = self._get_breadcrumbs(path) @@ -870,7 +981,7 @@ def index_view(self, path=None): else: action_form = None - def sort_url(column, path, invert=False): + def sort_url(column: str, path: str | None, invert: bool = False) -> str: desc = None if not path: @@ -879,69 +990,87 @@ def sort_url(column, path, invert=False): if invert and not sort_desc: desc = 1 - return self.get_url('.index_view', path=path, sort=column, desc=desc) - - return self.render(self.list_template, - dir_path=path, - breadcrumbs=breadcrumbs, - get_dir_url=self._get_dir_url, - get_file_url=self._get_file_url, - items=items, - actions=actions, - actions_confirmation=actions_confirmation, - action_form=action_form, - delete_form=delete_form, - sort_column=sort_column, - sort_desc=sort_desc, - sort_url=sort_url, - timestamp_format=self.timestamp_format) - - @expose('/upload/', methods=('GET', 'POST')) - @expose('/upload/', methods=('GET', 'POST')) - def upload(self, path=None): - """ - Upload view method - - :param path: - Optional directory path. If not provided, will use the base directory + return self.get_url(".index_view", path=path, sort=column, desc=desc) + + return self.render( + self.list_template, + dir_path=path, + breadcrumbs=breadcrumbs, + get_dir_url=self._get_dir_url, + get_file_url=self._get_file_url, + items=items, + actions=actions, + actions_confirmation=actions_confirmation, + action_form=action_form, + delete_form=delete_form, + sort_column=sort_column, + sort_desc=sort_desc, + sort_url=sort_url, + timestamp_format=self.timestamp_format, + ) + + @expose("/upload/", methods=("GET", "POST")) + @expose("/upload/", methods=("GET", "POST")) + def upload(self, path: str | None = None) -> T_RESPONSE | str: + """ + Upload view method + + :param path: + Optional directory path. If not provided, will use the base directory """ # Get path and verify if it is valid base_path, directory, path = self._normalize_path(path) if not self.can_upload: - flash(gettext('File uploading is disabled.'), 'error') - return redirect(self._get_dir_url('.index_view', path)) + flash(gettext("File uploading is disabled."), "error") + return redirect(self._get_dir_url(".index_view", path)) if not self.is_accessible_path(path): - flash(gettext('Permission denied.'), 'error') - return redirect(self._get_dir_url('.index_view')) + flash(gettext("Permission denied."), "error") + return redirect(self._get_dir_url(".index_view")) form = self.upload_form() if self.validate_form(form): try: self._save_form_files(directory, path, form) - flash(gettext('Successfully saved file: %(name)s', - name=form.upload.data.filename), 'success') - return redirect(self._get_dir_url('.index_view', path)) + flash( + gettext( + "Successfully saved file: %(name)s", + name=form.upload.data.filename, # type: ignore[attr-defined] + ), + "success", + ) + return redirect(self._get_dir_url(".index_view", path)) except Exception as ex: - flash(gettext('Failed to save file: %(error)s', error=ex), 'error') + flash( + gettext( + "Failed to save file: %(error)s", + error=ex, # type: ignore[arg-type] + ), + "error", + ) + else: + helpers.flash_errors(form, message="Failed to upload file: %(error)s") - if self.upload_modal and request.args.get('modal'): + if self.upload_modal and request.args.get("modal"): template = self.upload_modal_template else: template = self.upload_template - return self.render(template, form=form, - header_text=gettext('Upload File'), - modal=request.args.get('modal')) + return self.render( + template, + form=form, + header_text=gettext("Upload File"), + modal=request.args.get("modal"), + ) - @expose('/download/') - def download(self, path=None): + @expose("/download/") + def download(self, path: str | None = None) -> T_RESPONSE: """ - Download view method. + Download view method. - :param path: - File path. + :param path: + File path. """ if not self.can_download: abort(404) @@ -951,249 +1080,335 @@ def download(self, path=None): # backward compatibility with base_url base_url = self.get_base_url() if base_url: - base_url = urljoin(self.get_url('.index_view'), base_url) + base_url = urljoin(self.get_url(".index_view"), base_url) return redirect(urljoin(quote(base_url), quote(path))) - return self.storage.send_file(directory) + return self.storage.send_file(directory) # type: ignore[union-attr] - @expose('/mkdir/', methods=('GET', 'POST')) - @expose('/mkdir/', methods=('GET', 'POST')) - def mkdir(self, path=None): + @expose("/mkdir/", methods=("GET", "POST")) + @expose("/mkdir/", methods=("GET", "POST")) + def mkdir(self, path: str | None = None) -> T_RESPONSE | str: """ - Directory creation view method + Directory creation view method - :param path: - Optional directory path. If not provided, will use the base directory + :param path: + Optional directory path. If not provided, will use the base directory """ # Get path and verify if it is valid base_path, directory, path = self._normalize_path(path) - dir_url = self._get_dir_url('.index_view', path) + dir_url = self._get_dir_url(".index_view", path) if not self.can_mkdir: - flash(gettext('Directory creation is disabled.'), 'error') + flash(gettext("Directory creation is disabled."), "error") return redirect(dir_url) if not self.is_accessible_path(path): - flash(gettext('Permission denied.'), 'error') - return redirect(self._get_dir_url('.index_view')) + flash(gettext("Permission denied."), "error") + return redirect(self._get_dir_url(".index_view")) form = self.name_form() if self.validate_form(form): try: - self.storage.make_dir(directory, form.name.data) - self.on_mkdir(directory, form.name.data) - flash(gettext('Successfully created directory: %(directory)s', - directory=form.name.data), 'success') + self.storage.make_dir( # type: ignore[union-attr] + directory, + form.name.data, # type: ignore[attr-defined] + ) + self.on_mkdir( + directory, + form.name.data, # type: ignore[attr-defined] + ) + flash( + gettext( + "Successfully created directory: %(directory)s", + directory=form.name.data, # type: ignore[attr-defined] + ), + "success", + ) return redirect(dir_url) except Exception as ex: - flash(gettext('Failed to create directory: %(error)s', error=ex), 'error') + flash( + gettext( + "Failed to create directory: %(error)s", + error=ex, # type: ignore[arg-type] + ), + "error", + ) else: - helpers.flash_errors(form, message='Failed to create directory: %(error)s') + helpers.flash_errors(form, message="Failed to create directory: %(error)s") - if self.mkdir_modal and request.args.get('modal'): + if self.mkdir_modal and request.args.get("modal"): template = self.mkdir_modal_template else: template = self.mkdir_template - return self.render(template, form=form, dir_url=dir_url, - header_text=gettext('Create Directory')) + return self.render( + template, + form=form, + dir_url=dir_url, + header_text=gettext("Create Directory"), + ) - def delete_file(self, file_path): + def delete_file(self, file_path: str) -> None: """ - Deletes the file located at `file_path` + Deletes the file located at `file_path` """ - self.storage.delete_file(file_path) + self.storage.delete_file(file_path) # type: ignore[union-attr] - @expose('/delete/', methods=('POST',)) - def delete(self): + @expose("/delete/", methods=("POST",)) + def delete(self) -> T_RESPONSE: """ - Delete view method + Delete view method """ form = self.delete_form() - path = form.path.data + path = form.path.data # type: ignore[attr-defined] if path: - return_url = self._get_dir_url('.index_view', op.dirname(path)) + return_url = self._get_dir_url(".index_view", op.dirname(path)) else: - return_url = self.get_url('.index_view') + return_url = self.get_url(".index_view") if self.validate_form(form): # Get path and verify if it is valid base_path, full_path, path = self._normalize_path(path) if not self.can_delete: - flash(gettext('Deletion is disabled.'), 'error') + flash(gettext("Deletion is disabled."), "error") return redirect(return_url) if not self.is_accessible_path(path): - flash(gettext('Permission denied.'), 'error') - return redirect(self._get_dir_url('.index_view')) + flash(gettext("Permission denied."), "error") + return redirect(self._get_dir_url(".index_view")) - if self.storage.is_dir(full_path): + if self.storage.is_dir(full_path): # type: ignore[union-attr] if not self.can_delete_dirs: - flash(gettext('Directory deletion is disabled.'), 'error') + flash(gettext("Directory deletion is disabled."), "error") return redirect(return_url) try: self.before_directory_delete(full_path, path) - self.storage.delete_tree(full_path) + self.storage.delete_tree(full_path) # type: ignore[union-attr] self.on_directory_delete(full_path, path) - flash(gettext('Directory "%(path)s" was successfully deleted.', path=path), 'success') + flash( + gettext( + 'Directory "%(path)s" was successfully deleted.', path=path + ), + "success", + ) except Exception as ex: - flash(gettext('Failed to delete directory: %(error)s', error=ex), 'error') + flash( + gettext( + "Failed to delete directory: %(error)s", + error=ex, # type: ignore[arg-type] + ), + "error", + ) else: try: self.before_file_delete(full_path, path) self.delete_file(full_path) self.on_file_delete(full_path, path) - flash(gettext('File "%(name)s" was successfully deleted.', name=path), 'success') + flash( + gettext('File "%(name)s" was successfully deleted.', name=path), + "success", + ) except Exception as ex: - flash(gettext('Failed to delete file: %(name)s', name=ex), 'error') + flash( + gettext( + "Failed to delete file: %(name)s", + name=ex, # type: ignore[arg-type] + ), + "error", + ) else: - helpers.flash_errors(form, message='Failed to delete file. %(error)s') + helpers.flash_errors(form, message="Failed to delete file. %(error)s") return redirect(return_url) - @expose('/rename/', methods=('GET', 'POST')) - def rename(self): + @expose("/rename/", methods=("GET", "POST")) + def rename(self) -> T_RESPONSE | str: """ - Rename view method + Rename view method """ form = self.name_form() - path = form.path.data + path = form.path.data # type: ignore[attr-defined] + + if request.method == "GET" and hasattr(form, "name"): + form.name.data = op.basename(path) + if path: base_path, full_path, path = self._normalize_path(path) - return_url = self._get_dir_url('.index_view', op.dirname(path)) + return_url = self._get_dir_url(".index_view", op.dirname(path)) else: - return redirect(self.get_url('.index_view')) + return redirect(self.get_url(".index_view")) if not self.can_rename: - flash(gettext('Renaming is disabled.'), 'error') + flash(gettext("Renaming is disabled."), "error") return redirect(return_url) if not self.is_accessible_path(path): - flash(gettext('Permission denied.'), 'error') - return redirect(self._get_dir_url('.index_view')) + flash(gettext("Permission denied."), "error") + return redirect(self._get_dir_url(".index_view")) - if not self.storage.path_exists(full_path): - flash(gettext('Path does not exist.'), 'error') + if not self.storage.path_exists(full_path): # type: ignore[union-attr] + flash(gettext("Path does not exist."), "error") return redirect(return_url) if self.validate_form(form): try: dir_base = op.dirname(full_path) - filename = secure_filename(form.name.data) - self.storage.rename_path(full_path, self._separator.join([dir_base, filename])) + filename = secure_filename(form.name.data) # type: ignore[attr-defined] + self.storage.rename_path( # type: ignore[union-attr] + full_path, self._separator.join([dir_base, filename]) + ) self.on_rename(full_path, dir_base, filename) - flash(gettext('Successfully renamed "%(src)s" to "%(dst)s"', - src=op.basename(path), - dst=filename), 'success') + flash( + gettext( + 'Successfully renamed "%(src)s" to "%(dst)s"', + src=op.basename(path), + dst=filename, + ), + "success", + ) except Exception as ex: - flash(gettext('Failed to rename: %(error)s', error=ex), 'error') + flash( + gettext( + "Failed to rename: %(error)s", + error=ex, # type: ignore[arg-type] + ), + "error", + ) return redirect(return_url) else: - helpers.flash_errors(form, message='Failed to rename: %(error)s') + helpers.flash_errors(form, message="Failed to rename: %(error)s") + if hasattr(form, "name"): + form.name.data = op.basename(path) - if self.rename_modal and request.args.get('modal'): + if self.rename_modal and request.args.get("modal"): template = self.rename_modal_template else: template = self.rename_template - return self.render(template, form=form, path=op.dirname(path), - name=op.basename(path), dir_url=return_url, - header_text=gettext('Rename %(name)s', - name=op.basename(path))) + return self.render( + template, + form=form, + path=op.dirname(path), + name=op.basename(path), + dir_url=return_url, + header_text=gettext("Rename %(name)s", name=op.basename(path)), + ) - @expose('/edit/', methods=('GET', 'POST')) - def edit(self): + @expose("/edit/", methods=("GET", "POST")) + def edit(self) -> T_RESPONSE | str: """ - Edit view method + Edit view method """ next_url = None - path = request.args.getlist('path') + path: str | list[str] = request.args.getlist("path") if not path: - return redirect(self.get_url('.index_view')) + return redirect(self.get_url(".index_view")) if len(path) > 1: - next_url = self.get_url('.edit', path=path[1:]) + next_url = self.get_url(".edit", path=path[1:]) path = path[0] base_path, full_path, path = self._normalize_path(path) if not self.is_accessible_path(path) or not self.is_file_editable(path): - flash(gettext('Permission denied.'), 'error') - return redirect(self._get_dir_url('.index_view')) + flash(gettext("Permission denied."), "error") + return redirect(self._get_dir_url(".index_view")) - dir_url = self._get_dir_url('.index_view', op.dirname(path)) + dir_url = self._get_dir_url(".index_view", op.dirname(path)) next_url = next_url or dir_url form = self.edit_form() error = False if self.validate_form(form): - form.process(request.form, content='') + form.process(request.form, content="") if form.validate(): try: - self.storage.write_file(full_path, request.form['content']) - except IOError: - flash(gettext("Error saving changes to %(name)s.", name=path), 'error') + self.storage.write_file( # type: ignore[union-attr] + full_path, request.form["content"] + ) + except OSError: + flash( + gettext("Error saving changes to %(name)s.", name=path), "error" + ) error = True else: self.on_edit_file(full_path, path) - flash(gettext("Changes to %(name)s saved successfully.", name=path), 'success') + flash( + gettext("Changes to %(name)s saved successfully.", name=path), + "success", + ) return redirect(next_url) else: - helpers.flash_errors(form, message='Failed to edit file. %(error)s') + helpers.flash_errors(form, message="Failed to edit file. %(error)s") try: - content = self.storage.read_file(full_path) - except IOError: - flash(gettext("Error reading %(name)s.", name=path), 'error') + content = self.storage.read_file(full_path) # type: ignore[union-attr] + except OSError: + flash(gettext("Error reading %(name)s.", name=path), "error") error = True - except: - flash(gettext("Unexpected error while reading from %(name)s", name=path), 'error') + except: # noqa: E722 + flash( + gettext("Unexpected error while reading from %(name)s", name=path), + "error", + ) error = True else: try: - content = content.decode('utf8') + content = content.decode("utf-8") except UnicodeDecodeError: - flash(gettext("Cannot edit %(name)s.", name=path), 'error') + flash(gettext("Cannot edit %(name)s.", name=path), "error") error = True - except: - flash(gettext("Unexpected error while reading from %(name)s", name=path), 'error') + except: # noqa: E722 + flash( + gettext( + "Unexpected error while reading from %(name)s", name=path + ), + "error", + ) error = True else: - form.content.data = content + form.content.data = content # type: ignore[attr-defined] if error: return redirect(next_url) - if self.edit_modal and request.args.get('modal'): + if self.edit_modal and request.args.get("modal"): template = self.edit_modal_template else: template = self.edit_template - return self.render(template, dir_url=dir_url, path=path, - form=form, error=error, - header_text=gettext('Editing %(path)s', path=path)) - - @expose('/action/', methods=('POST',)) - def action_view(self): + return self.render( + template, + dir_url=dir_url, + path=path, + form=form, + error=error, + header_text=gettext("Editing %(path)s", path=path), + ) + + @expose("/action/", methods=("POST",)) + def action_view(self) -> T_RESPONSE: return self.handle_action() # Actions - @action('delete', - lazy_gettext('Delete'), - lazy_gettext('Are you sure you want to delete these files?')) - def action_delete(self, items): + @action( + "delete", + lazy_gettext("Delete"), + lazy_gettext("Are you sure you want to delete these files?"), + ) + def action_delete(self, items: t.Iterable[str]) -> None: if not self.can_delete: - flash(gettext('File deletion is disabled.'), 'error') + flash(gettext("File deletion is disabled."), "error") return for path in items: @@ -1202,39 +1417,48 @@ def action_delete(self, items): if self.is_accessible_path(path): try: self.delete_file(full_path) - flash(gettext('File "%(name)s" was successfully deleted.', name=path), 'success') + flash( + gettext('File "%(name)s" was successfully deleted.', name=path), + "success", + ) except Exception as ex: - flash(gettext('Failed to delete file: %(name)s', name=ex), 'error') + flash( + gettext( + "Failed to delete file: %(name)s", + name=ex, # type: ignore[arg-type] + ), + "error", + ) - @action('edit', lazy_gettext('Edit')) - def action_edit(self, items): - return redirect(self.get_url('.edit', path=items)) + @action("edit", lazy_gettext("Edit")) + def action_edit(self, items: t.Iterable[str]) -> T_RESPONSE: + return redirect(self.get_url(".edit", path=items)) class FileAdmin(BaseFileAdmin): """ - Simple file-management interface. + Simple file-management interface. - :param base_path: - Path to the directory which will be managed - :param base_url: - Optional base URL for the directory. Will be used to generate - static links to the files. If not defined, a route will be created - to serve uploaded files. + :param base_path: + Path to the directory which will be managed + :param base_url: + Optional base URL for the directory. Will be used to generate + static links to the files. If not defined, a route will be created + to serve uploaded files. - Sample usage:: + Sample usage:: - import os.path as op + import os.path as op - from flask_admin import Admin - from flask_admin.contrib.fileadmin import FileAdmin + from flask_admin import Admin + from flask_admin.contrib.fileadmin import FileAdmin - admin = Admin() + admin = Admin() - path = op.join(op.dirname(__file__), 'static') - admin.add_view(FileAdmin(path, '/static/', name='Static Files')) + path = op.join(op.dirname(__file__), 'static') + admin.add_view(FileAdmin(path, '/static/', name='Static Files')) """ - def __init__(self, base_path, *args, **kwargs): + def __init__(self, base_path: str | bytes, *args: t.Any, **kwargs: t.Any) -> None: storage = LocalFileStorage(base_path) - super(FileAdmin, self).__init__(*args, storage=storage, **kwargs) + super().__init__(*args, storage=storage, **kwargs) # type: ignore[misc] diff --git a/flask_admin/contrib/fileadmin/azure.py b/flask_admin/contrib/fileadmin/azure.py index 8e0fb44e38..82444d91f7 100644 --- a/flask_admin/contrib/fileadmin/azure.py +++ b/flask_admin/contrib/fileadmin/azure.py @@ -1,99 +1,116 @@ -from __future__ import absolute_import +import io +import os.path as op +import time +import typing as t from datetime import datetime from datetime import timedelta -from time import sleep -import os.path as op try: - from azure.storage.blob import BlobPermissions - from azure.storage.blob import BlockBlobService -except ImportError: - BlobPermissions = BlockBlobService = None - -from flask import redirect + from azure.core.exceptions import ResourceExistsError + from azure.storage.blob import BlobProperties + from azure.storage.blob import BlobServiceClient + from azure.storage.blob import ContainerClient +except ImportError as e: + raise Exception( + "Could not import `azure.storage.blob`. " + "Enable `azure-blob-storage` integration " + "by installing `flask-admin[azure-blob-storage]`" + ) from e + +import flask from . import BaseFileAdmin +from . import BaseFileStorage -class AzureStorage(object): +class AzureStorage(BaseFileStorage): """ - Storage object representing files on an Azure Storage container. + Storage object representing files on an Azure Storage container. - Usage:: + Usage:: - from flask_admin.contrib.fileadmin import BaseFileAdmin - from flask_admin.contrib.fileadmin.azure import AzureStorage + from flask_admin.contrib.fileadmin import BaseFileAdmin + from flask_admin.contrib.fileadmin.azure import AzureStorage - class MyAzureAdmin(BaseFileAdmin): - # Configure your class however you like - pass + class MyAzureAdmin(BaseFileAdmin): + # Configure your class however you like + pass - fileadmin_view = MyAzureAdmin(storage=AzureStorage(...)) + fileadmin_view = MyAzureAdmin(storage=AzureStorage(...)) """ - _fakedir = '.dir' + + _fakedir = ".dir" _copy_poll_interval_seconds = 1 _send_file_lookback = timedelta(minutes=15) _send_file_validity = timedelta(hours=1) - separator = '/' - - def __init__(self, container_name, connection_string): + separator = "/" + + def __init__( + self, + blob_service_client: BlobServiceClient, + container_name: str, + on_windows: bool = False, + ) -> None: """ - Constructor + Constructor - :param container_name: - Name of the container that the files are on. + :param blob_service_client: + BlobServiceClient for the Azure Blob Storage account - :param connection_string: - Azure Blob Storage Connection String - """ - - if not BlockBlobService: - raise ValueError('Could not import Azure Blob Storage SDK. ' - 'You can install the SDK using ' - 'pip install azure-storage-blob') + :param container_name: + Name of the container that the files are on. + :param on_windows: + True for Windows storage, this parameter is needed only if your + azure storage on Windows while your host is Linux and vice + versa. + """ + self._client = blob_service_client self._container_name = container_name - self._connection_string = connection_string - self.__client = None + self.on_windows = on_windows + try: + self._client.create_container(self._container_name) + except ResourceExistsError: + pass @property - def _client(self): - if not self.__client: - self.__client = BlockBlobService( - connection_string=self._connection_string) - self.__client.create_container( - self._container_name, fail_on_exist=False) - return self.__client + def _container_client(self) -> ContainerClient: + return self._client.get_container_client(self._container_name) @classmethod - def _get_blob_last_modified(cls, blob): - last_modified = blob.properties.last_modified + def _get_blob_last_modified(cls, blob: BlobProperties) -> float: + last_modified = blob.last_modified tzinfo = last_modified.tzinfo epoch = last_modified - datetime(1970, 1, 1, tzinfo=tzinfo) return epoch.total_seconds() @classmethod - def _ensure_blob_path(cls, path): + def _ensure_blob_path(cls, path: str | None) -> str | None: if path is None: return None path_parts = path.split(op.sep) return cls.separator.join(path_parts).lstrip(cls.separator) - def get_files(self, path, directory): + def get_files( + self, path: str, directory: str | None + ) -> list[tuple[str, str, bool, int, float]]: if directory and path != directory: path = op.join(path, directory) - path = self._ensure_blob_path(path) + path = self._ensure_blob_path(path) # type: ignore[assignment] directory = self._ensure_blob_path(directory) path_parts = path.split(self.separator) if path else [] num_path_parts = len(path_parts) + folders = set() - files = [] + files: list[tuple[str, str, bool, int, float]] = [] + + container_client = self._client.get_container_client(self._container_name) - for blob in self._client.list_blobs(self._container_name, path): + for blob in container_client.list_blobs(path): blob_path_parts = blob.name.split(self.separator) name = blob_path_parts.pop() @@ -103,13 +120,13 @@ def get_files(self, path, directory): if blob_is_file_at_current_level and not blob_is_directory_file: rel_path = blob.name is_dir = False - size = blob.properties.content_length + size = blob.size last_modified = self._get_blob_last_modified(blob) files.append((name, rel_path, is_dir, size, last_modified)) else: - next_level_folder = blob_path_parts[:num_path_parts + 1] - folder_name = self.separator.join(next_level_folder) - folders.add(folder_name) + next_level_folder = blob_path_parts[: num_path_parts + 1] + folder = self.separator.join(next_level_folder) + folders.add(folder) folders.discard(directory) for folder in folders: @@ -122,119 +139,137 @@ def get_files(self, path, directory): return files - def is_dir(self, path): + def is_dir(self, path: str | None) -> bool: path = self._ensure_blob_path(path) - num_blobs = 0 - for blob in self._client.list_blobs(self._container_name, path): - blob_path_parts = blob.name.split(self.separator) - is_explicit_directory = blob_path_parts[-1] == self._fakedir - if is_explicit_directory: - return True - - num_blobs += 1 - path_cannot_be_leaf = num_blobs >= 2 - if path_cannot_be_leaf: + blobs = self._container_client.list_blobs(name_starts_with=path) + for blob in blobs: + if blob.name != path: return True - return False - def path_exists(self, path): + def path_exists(self, path: str | None) -> bool: path = self._ensure_blob_path(path) if path == self.get_base_path(): return True - try: - next(iter(self._client.list_blobs(self._container_name, path))) - except StopIteration: + if path is None: return False - else: + + # Return true if it exists as either a directory or a file + for _ in self._container_client.list_blobs(name_starts_with=path): return True + return False - def get_base_path(self): - return '' + def get_base_path(self) -> str: + return "" - def get_breadcrumbs(self, path): + def get_breadcrumbs(self, path: str | None) -> list[tuple[str, str]]: path = self._ensure_blob_path(path) - accumulator = [] - breadcrumbs = [] - for folder in path.split(self.separator): - accumulator.append(folder) - breadcrumbs.append((folder, self.separator.join(accumulator))) + accumulator: list[str] = [] + breadcrumbs: list[tuple[str, str]] = [] + if path is not None: + for folder in path.split(self.separator): + accumulator.append(folder) + breadcrumbs.append((folder, self.separator.join(accumulator))) return breadcrumbs - def send_file(self, file_path): - file_path = self._ensure_blob_path(file_path) - - if not self._client.exists(self._container_name, file_path): - raise ValueError() - - now = datetime.utcnow() - url = self._client.make_blob_url(self._container_name, file_path) - sas = self._client.generate_blob_shared_access_signature( - self._container_name, file_path, - BlobPermissions.READ, - expiry=now + self._send_file_validity, - start=now - self._send_file_lookback) - return redirect('%s?%s' % (url, sas)) - - def read_file(self, path): + def send_file(self, file_path: str) -> flask.Response: + path = self._ensure_blob_path(file_path) + if path is None: + raise ValueError("No path provided") + blob = self._container_client.get_blob_client(path).download_blob() + if not blob.properties or not blob.properties.has_key("content_settings"): # type: ignore[no-untyped-call] + raise ValueError("Blob has no properties") + mime_type = blob.properties["content_settings"]["content_type"] + blob_file = io.BytesIO() + blob.readinto(blob_file) + blob_file.seek(0) + return flask.send_file( + blob_file, + mimetype=mime_type, + as_attachment=True, + download_name=path, + ) + + def read_file(self, path: str | None) -> bytes: path = self._ensure_blob_path(path) + if path is None: + raise ValueError("No path provided") + blob = self._container_client.get_blob_client(path).download_blob() + return blob.readall() - blob = self._client.get_blob_to_bytes(self._container_name, path) - return blob.content - - def write_file(self, path, content): + def write_file(self, path: str | None, content: t.Any) -> None: path = self._ensure_blob_path(path) + if path is None: + raise ValueError("No path provided") + self._container_client.upload_blob(path, content, overwrite=True) - self._client.create_blob_from_text(self._container_name, path, content) - - def save_file(self, path, file_data): + def save_file(self, path: str | None, file_data: t.Any) -> None: path = self._ensure_blob_path(path) + if path is None: + raise ValueError("No path provided") + self._container_client.upload_blob(path, file_data.stream) - self._client.create_blob_from_stream(self._container_name, path, - file_data.stream) - - def delete_tree(self, directory): + def delete_tree(self, directory: str | None) -> None: directory = self._ensure_blob_path(directory) - for blob in self._client.list_blobs(self._container_name, directory): - self._client.delete_blob(self._container_name, blob.name) + for blob in self._container_client.list_blobs(directory): + self._container_client.delete_blob(blob.name) - def delete_file(self, file_path): + def delete_file(self, file_path: str | None) -> None: file_path = self._ensure_blob_path(file_path) + if file_path is None: + raise ValueError("No path provided") + self._container_client.delete_blob(file_path) - self._client.delete_blob(self._container_name, file_path) - - def make_dir(self, path, directory): + def make_dir(self, path: str | None, directory: str | None) -> None: path = self._ensure_blob_path(path) directory = self._ensure_blob_path(directory) - + if path is None or directory is None: + raise ValueError("No path provided") blob = self.separator.join([path, directory, self._fakedir]) blob = blob.lstrip(self.separator) - self._client.create_blob_from_text(self._container_name, blob, '') - - def _copy_blob(self, src, dst): - src_url = self._client.make_blob_url(self._container_name, src) - copy = self._client.copy_blob(self._container_name, dst, src_url) - while copy.status != 'success': - sleep(self._copy_poll_interval_seconds) - copy = self._client.get_blob_properties( - self._container_name, dst).properties.copy - - def _rename_file(self, src, dst): + self._container_client.upload_blob(blob, b"") + + def _copy_blob(self, src: str, dst: str) -> None: + src_blob_client = self._container_client.get_blob_client(src) + dst_blob_client = self._container_client.get_blob_client(dst) + copy_result = dst_blob_client.start_copy_from_url(src_blob_client.url) + if copy_result.get("copy_status") == "success": + return + + for _ in range(10): + props = dst_blob_client.get_blob_properties() + status = props.copy.status + if status == "success": + return + time.sleep(1) + + if status != "success": + props = dst_blob_client.get_blob_properties() + copy_id = props.copy.id + if copy_id is not None: + dst_blob_client.abort_copy(copy_id) + raise Exception(f"Copy operation failed: {status}") + + def _rename_file(self, src: str, dst: str) -> None: self._copy_blob(src, dst) self.delete_file(src) - def _rename_directory(self, src, dst): - for blob in self._client.list_blobs(self._container_name, src): + def _rename_directory(self, src: str, dst: str) -> None: + for blob in self._container_client.list_blobs(src): self._rename_file(blob.name, blob.name.replace(src, dst, 1)) - def rename_path(self, src, dst): - src = self._ensure_blob_path(src) - dst = self._ensure_blob_path(dst) + def rename_path( + self, + src: str, + dst: str, + ) -> None: + src = t.cast(str, self._ensure_blob_path(src)) + dst = t.cast(str, self._ensure_blob_path(dst)) if self.is_dir(src): self._rename_directory(src, dst) @@ -244,24 +279,33 @@ def rename_path(self, src, dst): class AzureFileAdmin(BaseFileAdmin): """ - Simple Azure Blob Storage file-management interface. - - :param container_name: - Name of the container that the files are on. - - :param connection_string: - Azure Blob Storage Connection String + Simple Azure Blob Storage file-management interface. - Sample usage:: + :param container_name: + Name of the container that the files are on. - from flask_admin import Admin - from flask_admin.contrib.fileadmin.azure import AzureFileAdmin + :param connection_string: + Azure Blob Storage Connection String - admin = Admin() + Sample usage:: + from azure.storage.blob import BlobServiceClient + from flask_admin import Admin + from flask_admin.contrib.fileadmin.azure import AzureFileAdmin - admin.add_view(AzureFileAdmin('files_container', 'my-connection-string') + admin = Admin() + client = BlobServiceClient.from_connection_string("my-connection-string") + admin.add_view(AzureFileAdmin(client, 'files_container') """ - def __init__(self, container_name, connection_string, *args, **kwargs): - storage = AzureStorage(container_name, connection_string) - super(AzureFileAdmin, self).__init__(*args, storage=storage, **kwargs) + def __init__( + self, + blob_service_client: BlobServiceClient, + container_name: str, + on_windows: bool = False, + *args: t.Any, + **kwargs: t.Any, + ) -> None: + storage = AzureStorage( + blob_service_client, container_name, on_windows=on_windows + ) + super().__init__(*args, storage=storage, **kwargs) # type: ignore[misc] diff --git a/flask_admin/contrib/fileadmin/s3.py b/flask_admin/contrib/fileadmin/s3.py index aa33485ca2..7a4bd5eef5 100644 --- a/flask_admin/contrib/fileadmin/s3.py +++ b/flask_admin/contrib/fileadmin/s3.py @@ -1,208 +1,312 @@ -import time - -try: - from boto import s3 - from boto.s3.prefix import Prefix - from boto.s3.key import Key -except ImportError: - s3 = None +import functools +import typing as t +from botocore.client import BaseClient +from botocore.exceptions import ClientError from flask import redirect +from werkzeug import Response + from flask_admin.babel import gettext +from ..._types import T_RESPONSE from . import BaseFileAdmin +from . import BaseFileStorage + +P = t.ParamSpec("P") +R = t.TypeVar("R") + +def _strip_leading_slash_from( + arg_name: str, +) -> t.Callable[[t.Callable[P, R]], t.Callable[P, R]]: + """Strips leading slashes from the specified argument of the decorated function. -class S3Storage(object): + This is used to clean S3 object/key names because the base FileAdmin layers passes + paths with leading slashes, but S3 doesn't want and doesn't handle this. """ - Storage object representing files on an Amazon S3 bucket. - Usage:: + def decorator(func: t.Callable[P, R]) -> t.Callable[P, R]: + @functools.wraps(func) + def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Any: + args: list[t.Any] = list(args) # type: ignore[no-redef] + arg_names = func.__code__.co_varnames[: func.__code__.co_argcount] + + if arg_name in arg_names: + index = arg_names.index(arg_name) + + # Positional argument found + if index < len(args): + args[index] = args[index].lstrip("/") # type: ignore[index] + + # Keyword argument found + elif arg_name in kwargs: + kwargs[arg_name] = kwargs[arg_name].lstrip("/") + + return func(*args, **kwargs) - from flask_admin.contrib.fileadmin import BaseFileAdmin - from flask_admin.contrib.fileadmin.s3 import S3Storage + return wrapper - class MyS3Admin(BaseFileAdmin): - # Configure your class however you like - pass + return decorator - fileadmin_view = MyS3Admin(storage=S3Storage(...)) +class S3Storage(BaseFileStorage): """ + Storage object representing files on an Amazon S3 bucket. - def __init__(self, bucket_name, region, aws_access_key_id, - aws_secret_access_key): - """ - Constructor + Usage:: + + from flask_admin.contrib.fileadmin import BaseFileAdmin + from flask_admin.contrib.fileadmin.s3 import S3Storage - :param bucket_name: - Name of the bucket that the files are on. + class MyS3Admin(BaseFileAdmin): + # Configure your class however you like + pass + + fileadmin_view = MyS3Admin(storage=S3Storage(...)) + """ - :param region: - Region that the bucket is located + def __init__(self, s3_client: BaseClient, bucket_name: str) -> None: + """ + Constructor - :param aws_access_key_id: - AWS Access Key ID + :param s3_client: + An instance of boto3 S3 client. - :param aws_secret_access_key: - AWS Secret Access Key + :param bucket_name: + Name of the bucket that the files are on. - Make sure the credentials have the correct permissions set up on - Amazon or else S3 will return a 403 FORBIDDEN error. + Make sure the credentials have the correct permissions set up on + Amazon or else S3 will return a 403 FORBIDDEN error. """ - if not s3: - raise ValueError('Could not import boto. You can install boto by ' - 'using pip install boto') + # S3 Storage always uses Unix based path format. + super().__init__(on_windows=False) - connection = s3.connect_to_region( - region, - aws_access_key_id=aws_access_key_id, - aws_secret_access_key=aws_secret_access_key, - ) - self.bucket = connection.get_bucket(bucket_name) - self.separator = '/' + self.s3_client = s3_client + self.bucket_name = bucket_name + self.separator = "/" - def get_files(self, path, directory): - def _strip_path(name, path): + @_strip_leading_slash_from("path") + def get_files(self, path: str, directory: str) -> list[t.Any]: + def _strip_path(name: str, path: str) -> str: if name.startswith(path): - return name.replace(path, '', 1) + return name.replace(path, "", 1) return name - def _remove_trailing_slash(name): + def _remove_trailing_slash(name: str) -> str: return name[:-1] - def _iso_to_epoch(timestamp): - dt = time.strptime(timestamp.split(".")[0], "%Y-%m-%dT%H:%M:%S") - return int(time.mktime(dt)) - files = [] - directories = [] + directories: list[tuple[str, str, bool, int, int]] = [] if path and not path.endswith(self.separator): path += self.separator - for key in self.bucket.list(path, self.separator): - if key.name == path: - continue - if isinstance(key, Prefix): - name = _remove_trailing_slash(_strip_path(key.name, path)) - key_name = _remove_trailing_slash(key.name) - directories.append((name, key_name, True, 0, 0)) - else: - last_modified = _iso_to_epoch(key.last_modified) - name = _strip_path(key.name, path) - files.append((name, key.name, False, key.size, last_modified)) + + try: + paginator = self.s3_client.get_paginator("list_objects_v2") + for page in paginator.paginate( + Bucket=self.bucket_name, Prefix=path, Delimiter=self.separator + ): + for common_prefix in page.get("CommonPrefixes", []): + name = _remove_trailing_slash( + _strip_path(common_prefix["Prefix"], path) + ) + key_name = _remove_trailing_slash(common_prefix["Prefix"]) + directories.append((name, key_name, True, 0, 0)) + + for obj in page.get("Contents", []): + if obj["Key"] == path: + continue + + last_modified = int(obj["LastModified"].timestamp()) + name = _strip_path(obj["Key"], path) + files.append((name, obj["Key"], False, obj["Size"], last_modified)) + + except ClientError as e: + raise ValueError(f"Failed to list files: {e}") from e + return directories + files - def _get_bucket_list_prefix(self, path): + def _get_bucket_list_prefix(self, path: str) -> str: parts = path.split(self.separator) if len(parts) == 1: - search = '' + search = "" else: search = self.separator.join(parts[:-1]) + self.separator return search - def _get_path_keys(self, path): - search = self._get_bucket_list_prefix(path) - return {key.name for key in self.bucket.list(search, self.separator)} + def _get_path_keys(self, path: str) -> set[str]: + prefix = self._get_bucket_list_prefix(path) + try: + path_keys = set() + + paginator = self.s3_client.get_paginator("list_objects_v2") + for page in paginator.paginate( + Bucket=self.bucket_name, Prefix=prefix, Delimiter=self.separator + ): + for common_prefix in page.get("CommonPrefixes", []): + path_keys.add(common_prefix["Prefix"]) + + for obj in page.get("Contents", []): + if obj["Key"] == prefix: + continue + path_keys.add(obj["Key"]) - def is_dir(self, path): + return path_keys + + except ClientError as e: + raise ValueError(f"Failed to get path keys: {e}") from e + + @_strip_leading_slash_from("path") + def is_dir(self, path: str) -> bool: keys = self._get_path_keys(path) return path + self.separator in keys - def path_exists(self, path): - if path == '': + @_strip_leading_slash_from("path") + def path_exists(self, path: str) -> bool: + if path == "": return True keys = self._get_path_keys(path) return path in keys or (path + self.separator) in keys - def get_base_path(self): - return '' + def get_base_path(self) -> str: + return "" - def get_breadcrumbs(self, path): - accumulator = [] - breadcrumbs = [] + @_strip_leading_slash_from("path") + def get_breadcrumbs(self, path: str) -> list[tuple[str, str]]: + accumulator: list[str] = [] + breadcrumbs: list[tuple[str, str]] = [] for n in path.split(self.separator): accumulator.append(n) breadcrumbs.append((n, self.separator.join(accumulator))) return breadcrumbs - def send_file(self, file_path): - key = self.bucket.get_key(file_path) - if key is None: - raise ValueError() - return redirect(key.generate_url(3600)) - - def save_file(self, path, file_data): - key = Key(self.bucket, path) - key.set_contents_from_file(file_data.stream) - - def delete_tree(self, directory): + @_strip_leading_slash_from("file_path") + def send_file(self, file_path: str) -> Response: + try: + response = self.s3_client.generate_presigned_url( # type: ignore[attr-defined] + "get_object", + Params={"Bucket": self.bucket_name, "Key": file_path}, + ExpiresIn=3600, + ) + return redirect(response) + except ClientError as e: + raise ValueError(f"Failed to generate presigned URL: {e}") from e + + @_strip_leading_slash_from("path") + def save_file(self, path: str, file_data: t.Any) -> None: + try: + self.s3_client.upload_fileobj( # type: ignore[attr-defined] + file_data.stream, + self.bucket_name, + path, + ExtraArgs={"ContentType": file_data.content_type}, + ) + except ClientError as e: + raise ValueError(f"Failed to upload file: {e}") from e + + @_strip_leading_slash_from("directory") + def delete_tree(self, directory: str) -> None: self._check_empty_directory(directory) - self.bucket.delete_key(directory + self.separator) - - def delete_file(self, file_path): - self.bucket.delete_key(file_path) + self.delete_file(directory + self.separator) + + @_strip_leading_slash_from("file_path") + def delete_file(self, file_path: str) -> None: + try: + self.s3_client.delete_object( # type: ignore[attr-defined] + Bucket=self.bucket_name, Key=file_path + ) + except ClientError as e: + raise ValueError(f"Failed to delete file: {e}") from e + + @_strip_leading_slash_from("path") + @_strip_leading_slash_from("directory") + def make_dir(self, path: str, directory: str) -> None: + if path: + dir_path = self.separator.join([path, (directory + self.separator)]) + else: + dir_path = directory + self.separator - def make_dir(self, path, directory): - dir_path = self.separator.join([path, (directory + self.separator)]) - key = Key(self.bucket, dir_path) - key.set_contents_from_string('') + try: + self.s3_client.put_object( # type: ignore[attr-defined] + Bucket=self.bucket_name, Key=dir_path, Body="" + ) + except ClientError as e: + raise ValueError(f"Failed to create directory: {e}") from e - def _check_empty_directory(self, path): + def _check_empty_directory(self, path: str) -> bool: if not self._is_directory_empty(path): - raise ValueError(gettext('Cannot operate on non empty ' - 'directories')) + raise ValueError(gettext("Cannot operate on non empty directories")) return True - def rename_path(self, src, dst): + @_strip_leading_slash_from("src") + @_strip_leading_slash_from("dst") + def rename_path(self, src: str, dst: str) -> None: if self.is_dir(src): self._check_empty_directory(src) src += self.separator dst += self.separator - self.bucket.copy_key(dst, self.bucket.name, src) - self.delete_file(src) - - def _is_directory_empty(self, path): + try: + copy_source = {"Bucket": self.bucket_name, "Key": src} + self.s3_client.copy_object( # type: ignore[attr-defined] + CopySource=copy_source, Bucket=self.bucket_name, Key=dst + ) + self.delete_file(src) + except ClientError as e: + raise ValueError(f"Failed to rename path: {e}") from e + + def _is_directory_empty(self, path: str) -> bool: keys = self._get_path_keys(path + self.separator) - return len(keys) == 1 - - def read_file(self, path): - key = Key(self.bucket, path) - return key.get_contents_as_string() - - def write_file(self, path, content): - key = Key(self.bucket, path) - key.set_contents_from_file(content) + return len(keys) == 0 + + @_strip_leading_slash_from("path") + def read_file(self, path: str) -> T_RESPONSE: + try: + response = self.s3_client.get_object( # type: ignore[attr-defined] + Bucket=self.bucket_name, Key=path + ) + return response["Body"].read().decode("utf-8") + except ClientError as e: + raise ValueError(f"Failed to read file: {e}") from e + + @_strip_leading_slash_from("path") + def write_file(self, path: str, content: str) -> None: + try: + self.s3_client.put_object( # type: ignore[attr-defined] + Bucket=self.bucket_name, Key=path, Body=content + ) + except ClientError as e: + raise ValueError(f"Failed to write file: {e}") from e class S3FileAdmin(BaseFileAdmin): """ - Simple Amazon Simple Storage Service file-management interface. - - :param bucket_name: - Name of the bucket that the files are on. + Simple Amazon Simple Storage Service file-management interface. - :param region: - Region that the bucket is located + :param s3_client: + An instance of boto3 S3 client. - :param aws_access_key_id: - AWS Access Key ID + :param bucket_name: + Name of the bucket that the files are on. - :param aws_secret_access_key: - AWS Secret Access Key + Sample usage:: - Sample usage:: + from flask_admin import Admin + from flask_admin.contrib.fileadmin.s3 import S3FileAdmin - from flask_admin import Admin - from flask_admin.contrib.fileadmin.s3 import S3FileAdmin + import boto3 + s3_client = boto3.client('s3') - admin = Admin() + admin = Admin() - admin.add_view(S3FileAdmin('files_bucket', 'us-east-1', 'key_id', 'secret_key') + admin.add_view(S3FileAdmin(s3_client, 'files_bucket')) """ - def __init__(self, bucket_name, region, aws_access_key_id, - aws_secret_access_key, *args, **kwargs): - storage = S3Storage(bucket_name, region, aws_access_key_id, - aws_secret_access_key) - super(S3FileAdmin, self).__init__(*args, storage=storage, **kwargs) + def __init__( + self, + s3_client: BaseClient, + bucket_name: str, + *args: t.Any, + **kwargs: t.Any, + ) -> None: + storage = S3Storage(s3_client, bucket_name) + super().__init__(*args, storage=storage, **kwargs) # type: ignore[misc] diff --git a/flask_admin/contrib/geoa/__init__.py b/flask_admin/contrib/geoa/__init__.py index d802ae3086..39797f7eab 100644 --- a/flask_admin/contrib/geoa/__init__.py +++ b/flask_admin/contrib/geoa/__init__.py @@ -3,6 +3,9 @@ import geoalchemy2 import shapely except ImportError: - raise Exception('Please install geoalchemy2 and shapely in order to use geoalchemy integration') + raise Exception( + "Could not import `geoalchemy2` or `shapely`. " + "Enable `geoalchemy` integration by installing `flask-admin[geoalchemy]`" + ) from .view import ModelView diff --git a/flask_admin/contrib/geoa/fields.py b/flask_admin/contrib/geoa/fields.py index 0e9a303711..295b8b4821 100644 --- a/flask_admin/contrib/geoa/fields.py +++ b/flask_admin/contrib/geoa/fields.py @@ -1,22 +1,33 @@ +import typing as t + import geoalchemy2 from shapely.geometry import shape from sqlalchemy import func from flask_admin.form import JSONField +from ..._types import T_VALIDATOR +from ..sqla._compat import _get_deprecated_session +from ..sqla._types import T_SESSION_OR_DB from .widgets import LeafletWidget class GeoJSONField(JSONField): - - def __init__(self, label=None, validators=None, geometry_type="GEOMETRY", - srid='-1', session=None, tile_layer_url=None, - tile_layer_attribution=None, **kwargs): + def __init__( + self, + label: str | None = None, + validators: list[T_VALIDATOR] | None = None, + geometry_type: str = "GEOMETRY", + srid: int = -1, + session: T_SESSION_OR_DB | None = None, + tile_layer_url: str | None = None, + tile_layer_attribution: str | None = None, + **kwargs: t.Any, + ) -> None: self.widget = LeafletWidget( - tile_layer_url=tile_layer_url, - tile_layer_attribution=tile_layer_attribution + tile_layer_url=tile_layer_url, tile_layer_attribution=tile_layer_attribution ) - super(GeoJSONField, self).__init__(label, validators, **kwargs) + super().__init__(label, validators, **kwargs) self.web_srid = 4326 self.srid = srid if self.srid == -1: @@ -26,35 +37,35 @@ def __init__(self, label=None, validators=None, geometry_type="GEOMETRY", self.geometry_type = geometry_type.upper() self.session = session - def _value(self): + def _value(self) -> t.Any: if self.raw_data: return self.raw_data[0] - if type(self.data) is geoalchemy2.elements.WKBElement: + if type(self.data) is geoalchemy2.elements.WKBElement: # type: ignore[comparison-overlap] + session = _get_deprecated_session(self.session) + if self.srid == -1: - return self.session.scalar(func.ST_AsGeoJSON(self.data)) + return session.scalar( # pyright: ignore[reportOptionalMemberAccess] + func.ST_AsGeoJSON(self.data) + ) else: - return self.session.scalar( - func.ST_AsGeoJSON( - func.ST_Transform(self.data, self.web_srid) - ) + return session.scalar( # pyright: ignore[reportOptionalMemberAccess] + func.ST_AsGeoJSON(func.ST_Transform(self.data, self.web_srid)) ) else: - return '' + return "" - def process_formdata(self, valuelist): - super(GeoJSONField, self).process_formdata(valuelist) - if str(self.data) == '': + def process_formdata(self, valuelist: t.Sequence[str] | None) -> None: + super().process_formdata(valuelist) + if str(self.data) == "": self.data = None if self.data is not None: - web_shape = self.session.scalar( + session = _get_deprecated_session(self.session) + web_shape = session.scalar( # type: ignore[union-attr] func.ST_AsText( func.ST_Transform( - func.ST_GeomFromText( - shape(self.data).wkt, - self.web_srid - ), - self.transform_srid + func.ST_GeomFromText(shape(self.data).wkt, self.web_srid), # type: ignore[arg-type] + self.transform_srid, ) ) ) - self.data = 'SRID=' + str(self.srid) + ';' + str(web_shape) + self.data = "SRID=" + str(self.srid) + ";" + str(web_shape) diff --git a/flask_admin/contrib/geoa/form.py b/flask_admin/contrib/geoa/form.py index 4cec1f5dc0..11fb8ca9bc 100644 --- a/flask_admin/contrib/geoa/form.py +++ b/flask_admin/contrib/geoa/form.py @@ -1,14 +1,20 @@ -from flask_admin.model.form import converts +import typing as t + from flask_admin.contrib.sqla.form import AdminModelConverter as SQLAAdminConverter +from flask_admin.model.form import converts + +from ..._types import T_COL_NO_STR from .fields import GeoJSONField class AdminModelConverter(SQLAAdminConverter): - @converts('Geography', 'Geometry') - def convert_geom(self, column, field_args, **extra): - field_args['geometry_type'] = column.type.geometry_type - field_args['srid'] = column.type.srid - field_args['session'] = self.session - field_args['tile_layer_url'] = self.view.tile_layer_url - field_args['tile_layer_attribution'] = self.view.tile_layer_attribution + @converts("Geography", "Geometry") + def convert_geom( + self, column: T_COL_NO_STR, field_args: dict[str, t.Any], **extra: t.Any + ) -> GeoJSONField: + field_args["geometry_type"] = column.type.geometry_type # type: ignore[union-attr] + field_args["srid"] = column.type.srid # type: ignore[union-attr] + field_args["session"] = self.session + field_args["tile_layer_url"] = self.view.tile_layer_url # type: ignore[attr-defined] + field_args["tile_layer_attribution"] = self.view.tile_layer_attribution # type: ignore[attr-defined] return GeoJSONField(**field_args) diff --git a/flask_admin/contrib/geoa/typefmt.py b/flask_admin/contrib/geoa/typefmt.py index e304072692..4cb0a57ce7 100644 --- a/flask_admin/contrib/geoa/typefmt.py +++ b/flask_admin/contrib/geoa/typefmt.py @@ -1,29 +1,44 @@ -from flask_admin.contrib.sqla.typefmt import DEFAULT_FORMATTERS as BASE_FORMATTERS -from jinja2 import Markup -from wtforms.widgets import html_params -from geoalchemy2.shape import to_shape +from typing import TYPE_CHECKING + from geoalchemy2.elements import WKBElement +from geoalchemy2.shape import to_shape +from markupsafe import Markup from sqlalchemy import func +from wtforms.widgets import html_params + +from flask_admin._types import T_COLUMN_TYPE_FORMATTERS +from flask_admin.contrib.sqla._compat import _get_deprecated_session +from flask_admin.contrib.sqla.typefmt import DEFAULT_FORMATTERS as BASE_FORMATTERS +if TYPE_CHECKING: + from flask_admin.contrib.geoa import ModelView -def geom_formatter(view, value): - params = html_params(**{ + +def geom_formatter(view: "ModelView", value: WKBElement, name: str) -> 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 - - geojson = view.session.query(view.model).with_entities(func.ST_AsGeoJSON(value)).scalar() - return Markup('' % (params, geojson)) + session = _get_deprecated_session(view.session) + geojson = session.query(view.model).with_entities(func.ST_AsGeoJSON(value)).scalar() + return Markup(f"") -DEFAULT_FORMATTERS = BASE_FORMATTERS.copy() -DEFAULT_FORMATTERS[WKBElement] = geom_formatter +DEFAULT_FORMATTERS: T_COLUMN_TYPE_FORMATTERS = BASE_FORMATTERS.copy() +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 3ad8da5835..8379e69ab9 100644 --- a/flask_admin/contrib/geoa/view.py +++ b/flask_admin/contrib/geoa/view.py @@ -1,9 +1,12 @@ +from flask_admin.contrib.geoa import form +from flask_admin.contrib.geoa import typefmt from flask_admin.contrib.sqla import ModelView as SQLAModelView -from flask_admin.contrib.geoa import form, typefmt class ModelView(SQLAModelView): model_form_converter = form.AdminModelConverter column_type_formatters = typefmt.DEFAULT_FORMATTERS - tile_layer_url = None - tile_layer_attribution = None + # 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: str | None = None + tile_layer_attribution: str | None = None diff --git a/flask_admin/contrib/geoa/widgets.py b/flask_admin/contrib/geoa/widgets.py index 6e83f25aaa..9fe561ade1 100644 --- a/flask_admin/contrib/geoa/widgets.py +++ b/flask_admin/contrib/geoa/widgets.py @@ -1,19 +1,23 @@ +import typing as t + +from markupsafe import Markup +from wtforms import StringField from wtforms.widgets import TextArea -def lat(pt): +def lat(pt: t.Any) -> t.Any: return getattr(pt, "lat", getattr(pt, "x", pt[0])) -def lng(pt): +def lng(pt: t.Any) -> t.Any: return getattr(pt, "lng", getattr(pt, "y", pt[1])) class LeafletWidget(TextArea): - data_role = 'leaflet' + data_role = "leaflet" """ - `Leaflet `_ styled map widget. Inherits from + `Leaflet `_ styled map widget. Inherits from `TextArea` so that geographic data can be stored via the ",h.noCloneChecked=!!e.cloneNode(!0).lastChild.defaultValue}();var xe=r.documentElement,we=/^key/,Ce=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Te=/^([^.]*)(?:\.(.+)|)/;function Ee(){return!0}function Ne(){return!1}function ke(){try{return r.activeElement}catch(e){}}function Ae(e,t,n,r,i,o){var a,u;if("object"==typeof t){"string"!=typeof n&&(r=r||n,n=void 0);for(u in t)Ae(e,u,n,r,t[u],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=Ne;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return w().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=w.guid++)),e.each(function(){w.event.add(this,t,i,r,n)})}w.event={global:{},add:function(e,t,n,r,i){var o,a,u,s,l,c,f,d,p,h,g,v=K.get(e);if(v){n.handler&&(n=(o=n).handler,i=o.selector),i&&w.find.matchesSelector(xe,i),n.guid||(n.guid=w.guid++),(s=v.events)||(s=v.events={}),(a=v.handle)||(a=v.handle=function(t){return"undefined"!=typeof w&&w.event.triggered!==t.type?w.event.dispatch.apply(e,arguments):void 0}),l=(t=(t||"").match(I)||[""]).length;while(l--)p=g=(u=Te.exec(t[l])||[])[1],h=(u[2]||"").split(".").sort(),p&&(f=w.event.special[p]||{},p=(i?f.delegateType:f.bindType)||p,f=w.event.special[p]||{},c=w.extend({type:p,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&w.expr.match.needsContext.test(i),namespace:h.join(".")},o),(d=s[p])||((d=s[p]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(e,r,h,a)||e.addEventListener&&e.addEventListener(p,a)),f.add&&(f.add.call(e,c),c.handler.guid||(c.handler.guid=n.guid)),i?d.splice(d.delegateCount++,0,c):d.push(c),w.event.global[p]=!0)}},remove:function(e,t,n,r,i){var o,a,u,s,l,c,f,d,p,h,g,v=K.hasData(e)&&K.get(e);if(v&&(s=v.events)){l=(t=(t||"").match(I)||[""]).length;while(l--)if(u=Te.exec(t[l])||[],p=g=u[1],h=(u[2]||"").split(".").sort(),p){f=w.event.special[p]||{},d=s[p=(r?f.delegateType:f.bindType)||p]||[],u=u[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=d.length;while(o--)c=d[o],!i&&g!==c.origType||n&&n.guid!==c.guid||u&&!u.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(d.splice(o,1),c.selector&&d.delegateCount--,f.remove&&f.remove.call(e,c));a&&!d.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||w.removeEvent(e,p,v.handle),delete s[p])}else for(p in s)w.event.remove(e,p+t[l],n,r,!0);w.isEmptyObject(s)&&K.remove(e,"handle events")}},dispatch:function(e){var t=w.event.fix(e),n,r,i,o,a,u,s=new Array(arguments.length),l=(K.get(this,"events")||{})[t.type]||[],c=w.event.special[t.type]||{};for(s[0]=t,n=1;n=1))for(;l!==this;l=l.parentNode||this)if(1===l.nodeType&&("click"!==e.type||!0!==l.disabled)){for(o=[],a={},n=0;n-1:w.find(i,this,null,[l]).length),a[i]&&o.push(r);o.length&&u.push({elem:l,handlers:o})}return l=this,s\x20\t\r\n\f]*)[^>]*)\/>/gi,Se=/\s*$/g;function qe(e,t){return D(e,"table")&&D(11!==t.nodeType?t:t.firstChild,"tr")?w(e).children("tbody")[0]||e:e}function Oe(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Pe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function He(e,t){var n,r,i,o,a,u,s,l;if(1===t.nodeType){if(K.hasData(e)&&(o=K.access(e),a=K.set(t,o),l=o.events)){delete a.handle,a.events={};for(i in l)for(n=0,r=l[i].length;n1&&"string"==typeof v&&!h.checkClone&&Le.test(v))return e.each(function(i){var o=e.eq(i);y&&(t[0]=v.call(this,i,o.html())),Re(o,t,n,r)});if(d&&(i=be(t,e[0].ownerDocument,!1,e,r),o=i.firstChild,1===i.childNodes.length&&(i=o),o||r)){for(s=(u=w.map(ve(i,"script"),Oe)).length;f")},clone:function(e,t,n){var r,i,o,a,u=e.cloneNode(!0),s=w.contains(e.ownerDocument,e);if(!(h.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||w.isXMLDoc(e)))for(a=ve(u),r=0,i=(o=ve(e)).length;r0&&ye(a,!s&&ve(e,"script")),u},cleanData:function(e){for(var t,n,r,i=w.event.special,o=0;void 0!==(n=e[o]);o++)if(Y(n)){if(t=n[K.expando]){if(t.events)for(r in t.events)i[r]?w.event.remove(n,r):w.removeEvent(n,r,t.handle);n[K.expando]=void 0}n[J.expando]&&(n[J.expando]=void 0)}}}),w.fn.extend({detach:function(e){return Be(this,e,!0)},remove:function(e){return Be(this,e)},text:function(e){return _(this,function(e){return void 0===e?w.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=e)})},null,e,arguments.length)},append:function(){return Re(this,arguments,function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||qe(this,e).appendChild(e)})},prepend:function(){return Re(this,arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=qe(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return Re(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return Re(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(w.cleanData(ve(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map(function(){return w.clone(this,e,t)})},html:function(e){return _(this,function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!Se.test(e)&&!ge[(pe.exec(e)||["",""])[1].toLowerCase()]){e=w.htmlPrefilter(e);try{for(;n=0&&(s+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-o-s-u-.5))),s}function et(e,t,n){var r=We(e),i=Fe(e,t,r),o="border-box"===w.css(e,"boxSizing",!1,r),a=o;if(Me.test(i)){if(!n)return i;i="auto"}return a=a&&(h.boxSizingReliable()||i===e.style[t]),("auto"===i||!parseFloat(i)&&"inline"===w.css(e,"display",!1,r))&&(i=e["offset"+t[0].toUpperCase()+t.slice(1)],a=!0),(i=parseFloat(i)||0)+Ze(e,t,n||(o?"border":"content"),a,r,i)+"px"}w.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Fe(e,"opacity");return""===n?"1":n}}}},cssNumber:{animationIterationCount:!0,columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,a,u=Q(t),s=Ue.test(t),l=e.style;if(s||(t=Ke(u)),a=w.cssHooks[t]||w.cssHooks[u],void 0===n)return a&&"get"in a&&void 0!==(i=a.get(e,!1,r))?i:l[t];"string"==(o=typeof n)&&(i=ie.exec(n))&&i[1]&&(n=se(e,t,i),o="number"),null!=n&&n===n&&("number"===o&&(n+=i&&i[3]||(w.cssNumber[u]?"":"px")),h.clearCloneStyle||""!==n||0!==t.indexOf("background")||(l[t]="inherit"),a&&"set"in a&&void 0===(n=a.set(e,n,r))||(s?l.setProperty(t,n):l[t]=n))}},css:function(e,t,n,r){var i,o,a,u=Q(t);return Ue.test(t)||(t=Ke(u)),(a=w.cssHooks[t]||w.cssHooks[u])&&"get"in a&&(i=a.get(e,!0,n)),void 0===i&&(i=Fe(e,t,r)),"normal"===i&&t in Xe&&(i=Xe[t]),""===n||n?(o=parseFloat(i),!0===n||isFinite(o)?o||0:i):i}}),w.each(["height","width"],function(e,t){w.cssHooks[t]={get:function(e,n,r){if(n)return!_e.test(w.css(e,"display"))||e.getClientRects().length&&e.getBoundingClientRect().width?et(e,t,r):ue(e,Ve,function(){return et(e,t,r)})},set:function(e,n,r){var i,o=We(e),a="border-box"===w.css(e,"boxSizing",!1,o),u=r&&Ze(e,t,r,a,o);return a&&h.scrollboxSize()===o.position&&(u-=Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-parseFloat(o[t])-Ze(e,t,"border",!1,o)-.5)),u&&(i=ie.exec(n))&&"px"!==(i[3]||"px")&&(e.style[t]=n,n=w.css(e,t)),Je(e,n,u)}}}),w.cssHooks.marginLeft=ze(h.reliableMarginLeft,function(e,t){if(t)return(parseFloat(Fe(e,"marginLeft"))||e.getBoundingClientRect().left-ue(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px"}),w.each({margin:"",padding:"",border:"Width"},function(e,t){w.cssHooks[e+t]={expand:function(n){for(var r=0,i={},o="string"==typeof n?n.split(" "):[n];r<4;r++)i[e+oe[r]+t]=o[r]||o[r-2]||o[0];return i}},"margin"!==e&&(w.cssHooks[e+t].set=Je)}),w.fn.extend({css:function(e,t){return _(this,function(e,t,n){var r,i,o={},a=0;if(Array.isArray(t)){for(r=We(e),i=t.length;a1)}}),w.fn.delay=function(t,n){return t=w.fx?w.fx.speeds[t]||t:t,n=n||"fx",this.queue(n,function(n,r){var i=e.setTimeout(n,t);r.stop=function(){e.clearTimeout(i)}})},function(){var e=r.createElement("input"),t=r.createElement("select").appendChild(r.createElement("option"));e.type="checkbox",h.checkOn=""!==e.value,h.optSelected=t.selected,(e=r.createElement("input")).value="t",e.type="radio",h.radioValue="t"===e.value}();var tt,nt=w.expr.attrHandle;w.fn.extend({attr:function(e,t){return _(this,w.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){w.removeAttr(this,e)})}}),w.extend({attr:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return"undefined"==typeof e.getAttribute?w.prop(e,t,n):(1===o&&w.isXMLDoc(e)||(i=w.attrHooks[t.toLowerCase()]||(w.expr.match.bool.test(t)?tt:void 0)),void 0!==n?null===n?void w.removeAttr(e,t):i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:(e.setAttribute(t,n+""),n):i&&"get"in i&&null!==(r=i.get(e,t))?r:null==(r=w.find.attr(e,t))?void 0:r)},attrHooks:{type:{set:function(e,t){if(!h.radioValue&&"radio"===t&&D(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},removeAttr:function(e,t){var n,r=0,i=t&&t.match(I);if(i&&1===e.nodeType)while(n=i[r++])e.removeAttribute(n)}}),tt={set:function(e,t,n){return!1===t?w.removeAttr(e,n):e.setAttribute(n,n),n}},w.each(w.expr.match.bool.source.match(/\w+/g),function(e,t){var n=nt[t]||w.find.attr;nt[t]=function(e,t,r){var i,o,a=t.toLowerCase();return r||(o=nt[a],nt[a]=i,i=null!=n(e,t,r)?a:null,nt[a]=o),i}});var rt=/^(?:input|select|textarea|button)$/i,it=/^(?:a|area)$/i;w.fn.extend({prop:function(e,t){return _(this,w.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each(function(){delete this[w.propFix[e]||e]})}}),w.extend({prop:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return 1===o&&w.isXMLDoc(e)||(t=w.propFix[t]||t,i=w.propHooks[t]),void 0!==n?i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:e[t]=n:i&&"get"in i&&null!==(r=i.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){var t=w.find.attr(e,"tabindex");return t?parseInt(t,10):rt.test(e.nodeName)||it.test(e.nodeName)&&e.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),h.optSelected||(w.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),w.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){w.propFix[this.toLowerCase()]=this});function ot(e){return(e.match(I)||[]).join(" ")}function at(e){return e.getAttribute&&e.getAttribute("class")||""}function ut(e){return Array.isArray(e)?e:"string"==typeof e?e.match(I)||[]:[]}w.fn.extend({addClass:function(e){var t,n,r,i,o,a,u,s=0;if(g(e))return this.each(function(t){w(this).addClass(e.call(this,t,at(this)))});if((t=ut(e)).length)while(n=this[s++])if(i=at(n),r=1===n.nodeType&&" "+ot(i)+" "){a=0;while(o=t[a++])r.indexOf(" "+o+" ")<0&&(r+=o+" ");i!==(u=ot(r))&&n.setAttribute("class",u)}return this},removeClass:function(e){var t,n,r,i,o,a,u,s=0;if(g(e))return this.each(function(t){w(this).removeClass(e.call(this,t,at(this)))});if(!arguments.length)return this.attr("class","");if((t=ut(e)).length)while(n=this[s++])if(i=at(n),r=1===n.nodeType&&" "+ot(i)+" "){a=0;while(o=t[a++])while(r.indexOf(" "+o+" ")>-1)r=r.replace(" "+o+" "," ");i!==(u=ot(r))&&n.setAttribute("class",u)}return this},toggleClass:function(e,t){var n=typeof e,r="string"===n||Array.isArray(e);return"boolean"==typeof t&&r?t?this.addClass(e):this.removeClass(e):g(e)?this.each(function(n){w(this).toggleClass(e.call(this,n,at(this),t),t)}):this.each(function(){var t,i,o,a;if(r){i=0,o=w(this),a=ut(e);while(t=a[i++])o.hasClass(t)?o.removeClass(t):o.addClass(t)}else void 0!==e&&"boolean"!==n||((t=at(this))&&K.set(this,"__className__",t),this.setAttribute&&this.setAttribute("class",t||!1===e?"":K.get(this,"__className__")||""))})},hasClass:function(e){var t,n,r=0;t=" "+e+" ";while(n=this[r++])if(1===n.nodeType&&(" "+ot(at(n))+" ").indexOf(t)>-1)return!0;return!1}});var st=/\r/g;w.fn.extend({val:function(e){var t,n,r,i=this[0];{if(arguments.length)return r=g(e),this.each(function(n){var i;1===this.nodeType&&(null==(i=r?e.call(this,n,w(this).val()):e)?i="":"number"==typeof i?i+="":Array.isArray(i)&&(i=w.map(i,function(e){return null==e?"":e+""})),(t=w.valHooks[this.type]||w.valHooks[this.nodeName.toLowerCase()])&&"set"in t&&void 0!==t.set(this,i,"value")||(this.value=i))});if(i)return(t=w.valHooks[i.type]||w.valHooks[i.nodeName.toLowerCase()])&&"get"in t&&void 0!==(n=t.get(i,"value"))?n:"string"==typeof(n=i.value)?n.replace(st,""):null==n?"":n}}}),w.extend({valHooks:{option:{get:function(e){var t=w.find.attr(e,"value");return null!=t?t:ot(w.text(e))}},select:{get:function(e){var t,n,r,i=e.options,o=e.selectedIndex,a="select-one"===e.type,u=a?null:[],s=a?o+1:i.length;for(r=o<0?s:a?o:0;r-1)&&(n=!0);return n||(e.selectedIndex=-1),o}}}}),w.each(["radio","checkbox"],function(){w.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=w.inArray(w(e).val(),t)>-1}},h.checkOn||(w.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})}),h.focusin="onfocusin"in e;var lt=/^(?:focusinfocus|focusoutblur)$/,ct=function(e){e.stopPropagation()};w.extend(w.event,{trigger:function(t,n,i,o){var a,u,s,l,c,d,p,h,y=[i||r],m=f.call(t,"type")?t.type:t,b=f.call(t,"namespace")?t.namespace.split("."):[];if(u=h=s=i=i||r,3!==i.nodeType&&8!==i.nodeType&&!lt.test(m+w.event.triggered)&&(m.indexOf(".")>-1&&(m=(b=m.split(".")).shift(),b.sort()),c=m.indexOf(":")<0&&"on"+m,t=t[w.expando]?t:new w.Event(m,"object"==typeof t&&t),t.isTrigger=o?2:3,t.namespace=b.join("."),t.rnamespace=t.namespace?new RegExp("(^|\\.)"+b.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,t.result=void 0,t.target||(t.target=i),n=null==n?[t]:w.makeArray(n,[t]),p=w.event.special[m]||{},o||!p.trigger||!1!==p.trigger.apply(i,n))){if(!o&&!p.noBubble&&!v(i)){for(l=p.delegateType||m,lt.test(l+m)||(u=u.parentNode);u;u=u.parentNode)y.push(u),s=u;s===(i.ownerDocument||r)&&y.push(s.defaultView||s.parentWindow||e)}a=0;while((u=y[a++])&&!t.isPropagationStopped())h=u,t.type=a>1?l:p.bindType||m,(d=(K.get(u,"events")||{})[t.type]&&K.get(u,"handle"))&&d.apply(u,n),(d=c&&u[c])&&d.apply&&Y(u)&&(t.result=d.apply(u,n),!1===t.result&&t.preventDefault());return t.type=m,o||t.isDefaultPrevented()||p._default&&!1!==p._default.apply(y.pop(),n)||!Y(i)||c&&g(i[m])&&!v(i)&&((s=i[c])&&(i[c]=null),w.event.triggered=m,t.isPropagationStopped()&&h.addEventListener(m,ct),i[m](),t.isPropagationStopped()&&h.removeEventListener(m,ct),w.event.triggered=void 0,s&&(i[c]=s)),t.result}},simulate:function(e,t,n){var r=w.extend(new w.Event,n,{type:e,isSimulated:!0});w.event.trigger(r,null,t)}}),w.fn.extend({trigger:function(e,t){return this.each(function(){w.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];if(n)return w.event.trigger(e,t,n,!0)}}),h.focusin||w.each({focus:"focusin",blur:"focusout"},function(e,t){var n=function(e){w.event.simulate(t,e.target,w.event.fix(e))};w.event.special[t]={setup:function(){var r=this.ownerDocument||this,i=K.access(r,t);i||r.addEventListener(e,n,!0),K.access(r,t,(i||0)+1)},teardown:function(){var r=this.ownerDocument||this,i=K.access(r,t)-1;i?K.access(r,t,i):(r.removeEventListener(e,n,!0),K.remove(r,t))}}});var ft=/\[\]$/,dt=/\r?\n/g,pt=/^(?:submit|button|image|reset|file)$/i,ht=/^(?:input|select|textarea|keygen)/i;function gt(e,t,n,r){var i;if(Array.isArray(t))w.each(t,function(t,i){n||ft.test(e)?r(e,i):gt(e+"["+("object"==typeof i&&null!=i?t:"")+"]",i,n,r)});else if(n||"object"!==b(t))r(e,t);else for(i in t)gt(e+"["+i+"]",t[i],n,r)}w.param=function(e,t){var n,r=[],i=function(e,t){var n=g(t)?t():t;r[r.length]=encodeURIComponent(e)+"="+encodeURIComponent(null==n?"":n)};if(Array.isArray(e)||e.jquery&&!w.isPlainObject(e))w.each(e,function(){i(this.name,this.value)});else for(n in e)gt(n,e[n],t,i);return r.join("&")},w.fn.extend({serialize:function(){return w.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=w.prop(this,"elements");return e?w.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!w(this).is(":disabled")&&ht.test(this.nodeName)&&!pt.test(e)&&(this.checked||!de.test(e))}).map(function(e,t){var n=w(this).val();return null==n?null:Array.isArray(n)?w.map(n,function(e){return{name:t.name,value:e.replace(dt,"\r\n")}}):{name:t.name,value:n.replace(dt,"\r\n")}}).get()}}),w.fn.extend({wrapAll:function(e){var t;return this[0]&&(g(e)&&(e=e.call(this[0])),t=w(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstElementChild)e=e.firstElementChild;return e}).append(this)),this},wrapInner:function(e){return g(e)?this.each(function(t){w(this).wrapInner(e.call(this,t))}):this.each(function(){var t=w(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=g(e);return this.each(function(n){w(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(e){return this.parent(e).not("body").each(function(){w(this).replaceWith(this.childNodes)}),this}}),w.expr.pseudos.hidden=function(e){return!w.expr.pseudos.visible(e)},w.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},h.createHTMLDocument=function(){var e=r.implementation.createHTMLDocument("").body;return e.innerHTML="
",2===e.childNodes.length}(),w.parseHTML=function(e,t,n){if("string"!=typeof e)return[];"boolean"==typeof t&&(n=t,t=!1);var i,o,a;return t||(h.createHTMLDocument?((i=(t=r.implementation.createHTMLDocument("")).createElement("base")).href=r.location.href,t.head.appendChild(i)):t=r),o=S.exec(e),a=!n&&[],o?[t.createElement(o[1])]:(o=be([e],t,a),a&&a.length&&w(a).remove(),w.merge([],o.childNodes))},w.offset={setOffset:function(e,t,n){var r,i,o,a,u,s,l,c=w.css(e,"position"),f=w(e),d={};"static"===c&&(e.style.position="relative"),u=f.offset(),o=w.css(e,"top"),s=w.css(e,"left"),(l=("absolute"===c||"fixed"===c)&&(o+s).indexOf("auto")>-1)?(a=(r=f.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(s)||0),g(t)&&(t=t.call(e,n,w.extend({},u))),null!=t.top&&(d.top=t.top-u.top+a),null!=t.left&&(d.left=t.left-u.left+i),"using"in t?t.using.call(e,d):f.css(d)}},w.fn.extend({offset:function(e){if(arguments.length)return void 0===e?this:this.each(function(t){w.offset.setOffset(this,e,t)});var t,n,r=this[0];if(r)return r.getClientRects().length?(t=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:t.top+n.pageYOffset,left:t.left+n.pageXOffset}):{top:0,left:0}},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===w.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===w.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=w(e).offset()).top+=w.css(e,"borderTopWidth",!0),i.left+=w.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-w.css(r,"marginTop",!0),left:t.left-i.left-w.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===w.css(e,"position"))e=e.offsetParent;return e||xe})}}),w.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(e,t){var n="pageYOffset"===t;w.fn[e]=function(r){return _(this,function(e,r,i){var o;if(v(e)?o=e:9===e.nodeType&&(o=e.defaultView),void 0===i)return o?o[t]:e[r];o?o.scrollTo(n?o.pageXOffset:i,n?i:o.pageYOffset):e[r]=i},e,r,arguments.length)}}),w.each(["top","left"],function(e,t){w.cssHooks[t]=ze(h.pixelPosition,function(e,n){if(n)return n=Fe(e,t),Me.test(n)?w(e).position()[t]+"px":n})}),w.each({Height:"height",Width:"width"},function(e,t){w.each({padding:"inner"+e,content:t,"":"outer"+e},function(n,r){w.fn[r]=function(i,o){var a=arguments.length&&(n||"boolean"!=typeof i),u=n||(!0===i||!0===o?"margin":"border");return _(this,function(t,n,i){var o;return v(t)?0===r.indexOf("outer")?t["inner"+e]:t.document.documentElement["client"+e]:9===t.nodeType?(o=t.documentElement,Math.max(t.body["scroll"+e],o["scroll"+e],t.body["offset"+e],o["offset"+e],o["client"+e])):void 0===i?w.css(t,n,u):w.style(t,n,i,u)},t,a?i:void 0,a)}})}),w.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,t){w.fn[t]=function(e,n){return arguments.length>0?this.on(t,null,e,n):this.trigger(t)}}),w.fn.extend({hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),w.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)}}),w.proxy=function(e,t){var n,r,i;if("string"==typeof t&&(n=e[t],t=e,e=n),g(e))return r=o.call(arguments,2),i=function(){return e.apply(t||this,r.concat(o.call(arguments)))},i.guid=e.guid=e.guid||w.guid++,i},w.holdReady=function(e){e?w.readyWait++:w.ready(!0)},w.isArray=Array.isArray,w.parseJSON=JSON.parse,w.nodeName=D,w.isFunction=g,w.isWindow=v,w.camelCase=Q,w.type=b,w.now=Date.now,w.isNumeric=function(e){var t=w.type(e);return("number"===t||"string"===t)&&!isNaN(e-parseFloat(e))},"function"==typeof define&&define.amd&&define("jquery",[],function(){return w});var vt=e.jQuery,yt=e.$;return w.noConflict=function(t){return e.$===w&&(e.$=yt),t&&e.jQuery===w&&(e.jQuery=vt),w},t||(e.jQuery=e.$=w),w}); diff --git a/flask_admin/static/vendor/jquery-3.5.1.slim.min.js b/flask_admin/static/vendor/jquery-3.5.1.slim.min.js new file mode 100644 index 0000000000..36b4e1a137 --- /dev/null +++ b/flask_admin/static/vendor/jquery-3.5.1.slim.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.5.1 -ajax,-ajax/jsonp,-ajax/load,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-deprecated/ajax-event-alias,-effects,-effects/Tween,-effects/animatedSelector | (c) JS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(g,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,v=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,y=n.hasOwnProperty,a=y.toString,l=a.call(Object),m={},b=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},w=g.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function C(e,t,n){var r,i,o=(n=n||w).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function T(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.5.1 -ajax,-ajax/jsonp,-ajax/load,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-deprecated/ajax-event-alias,-effects,-effects/Tween,-effects/animatedSelector",E=function(e,t){return new E.fn.init(e,t)};function d(e){var t=!!e&&"length"in e&&e.length,n=T(e);return!b(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+R+")"+R+"*"),U=new RegExp(R+"|>"),V=new RegExp(W),X=new RegExp("^"+B+"$"),Q={ID:new RegExp("^#("+B+")"),CLASS:new RegExp("^\\.("+B+")"),TAG:new RegExp("^("+B+"|[*])"),ATTR:new RegExp("^"+M),PSEUDO:new RegExp("^"+W),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+R+"*(even|odd|(([+-]|)(\\d*)n|)"+R+"*(?:([+-]|)"+R+"*(\\d+)|))"+R+"*\\)|)","i"),bool:new RegExp("^(?:"+I+")$","i"),needsContext:new RegExp("^"+R+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+R+"*((?:-\\d)?\\d*)"+R+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,G=/^(?:input|select|textarea|button)$/i,K=/^h\d$/i,J=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+R+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){C()},ae=xe(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{O.apply(t=P.call(d.childNodes),d.childNodes),t[d.childNodes.length].nodeType}catch(e){O={apply:t.length?function(e,t){q.apply(e,P.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,d=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==d&&9!==d&&11!==d)return n;if(!r&&(C(e),e=e||T,E)){if(11!==d&&(u=Z.exec(t)))if(i=u[1]){if(9===d){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return O.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&p.getElementsByClassName&&e.getElementsByClassName)return O.apply(n,e.getElementsByClassName(i)),n}if(p.qsa&&!k[t+" "]&&(!v||!v.test(t))&&(1!==d||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===d&&(U.test(t)||_.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&p.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=A)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+be(l[o]);c=l.join(",")}try{return O.apply(n,f.querySelectorAll(c)),n}catch(e){k(t,!0)}finally{s===A&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>x.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[A]=!0,e}function ce(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)x.attrHandle[n[r]]=t}function de(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function pe(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in p=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},C=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:d;return r!=T&&9===r.nodeType&&r.documentElement&&(a=(T=r).documentElement,E=!i(T),d!=T&&(n=T.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),p.scope=ce(function(e){return a.appendChild(e).appendChild(T.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),p.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),p.getElementsByTagName=ce(function(e){return e.appendChild(T.createComment("")),!e.getElementsByTagName("*").length}),p.getElementsByClassName=J.test(T.getElementsByClassName),p.getById=ce(function(e){return a.appendChild(e).id=A,!T.getElementsByName||!T.getElementsByName(A).length}),p.getById?(x.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},x.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(x.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},x.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),x.find.TAG=p.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):p.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},x.find.CLASS=p.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(p.qsa=J.test(T.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+R+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+R+"*(?:value|"+I+")"),e.querySelectorAll("[id~="+A+"-]").length||v.push("~="),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+R+"*name"+R+"*="+R+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+A+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=T.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+R+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(p.matchesSelector=J.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){p.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",W)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=J.test(a.compareDocumentPosition),y=t||J.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!p.sortDetached&&t.compareDocumentPosition(e)===n?e==T||e.ownerDocument==d&&y(d,e)?-1:t==T||t.ownerDocument==d&&y(d,t)?1:u?H(u,e)-H(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==T?-1:t==T?1:i?-1:o?1:u?H(u,e)-H(u,t):0;if(i===o)return de(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?de(a[r],s[r]):a[r]==d?-1:s[r]==d?1:0}),T},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(C(e),p.matchesSelector&&E&&!k[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||p.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){k(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return Q.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&V.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+R+")"+e+"("+R+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function D(e,n,r){return b(n)?E.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?E.grep(e,function(e){return e===n!==r}):"string"!=typeof n?E.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(E.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||L,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:j.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof E?t[0]:t,E.merge(this,E.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:w,!0)),k.test(r[1])&&E.isPlainObject(t))for(r in t)b(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=w.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):b(e)?void 0!==n.ready?n.ready(e):e(E):E.makeArray(e,this)}).prototype=E.fn,L=E(w);var q=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};function P(e,t){while((e=e[t])&&1!==e.nodeType);return e}E.fn.extend({has:function(e){var t=E(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,pe=/^$|^module$|\/(?:java|ecma)script/i;le=w.createDocumentFragment().appendChild(w.createElement("div")),(ce=w.createElement("input")).setAttribute("type","radio"),ce.setAttribute("checked","checked"),ce.setAttribute("name","t"),le.appendChild(ce),m.checkClone=le.cloneNode(!0).cloneNode(!0).lastChild.checked,le.innerHTML="",m.noCloneChecked=!!le.cloneNode(!0).lastChild.defaultValue,le.innerHTML="",m.option=!!le.lastChild;var he={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ge(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&S(e,t)?E.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;n",""]);var ye=/<|&#?\w+;/;function me(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),d=[],p=0,h=e.length;p\s*$/g;function Le(e,t){return S(e,"table")&&S(11!==t.nodeType?t:t.firstChild,"tr")&&E(e).children("tbody")[0]||e}function je(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Oe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n
",2===ft.childNodes.length),E.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(m.createHTMLDocument?((r=(t=w.implementation.createHTMLDocument("")).createElement("base")).href=w.location.href,t.head.appendChild(r)):t=w),o=!n&&[],(i=k.exec(e))?[t.createElement(i[1])]:(i=me([e],t,o),o&&o.length&&E(o).remove(),E.merge([],i.childNodes)));var r,i,o},E.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=E.css(e,"position"),c=E(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=E.css(e,"top"),u=E.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),b(t)&&(t=t.call(e,n,E.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):("number"==typeof f.top&&(f.top+="px"),"number"==typeof f.left&&(f.left+="px"),c.css(f))}},E.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){E.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===E.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===E.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=E(e).offset()).top+=E.css(e,"borderTopWidth",!0),i.left+=E.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-E.css(r,"marginTop",!0),left:t.left-i.left-E.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===E.css(e,"position"))e=e.offsetParent;return e||re})}}),E.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;E.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),E.each(["top","left"],function(e,n){E.cssHooks[n]=Fe(m.pixelPosition,function(e,t){if(t)return t=We(e,n),Ie.test(t)?E(e).position()[n]+"px":t})}),E.each({Height:"height",Width:"width"},function(a,s){E.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){E.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?E.css(e,t,i):E.style(e,t,n,i)},s,n?e:void 0,n)}})}),E.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),E.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){E.fn[n]=function(e,t){return 0=0&&n0&&t-1 in e)}var E=function(e){var t,n,r,i,o,a,s,u,l,c,f,p,d,h,g,y,v,m,x,b="sizzle"+1*new Date,w=e.document,T=0,C=0,E=ae(),k=ae(),S=ae(),D=function(e,t){return e===t&&(f=!0),0},N={}.hasOwnProperty,A=[],j=A.pop,q=A.push,L=A.push,H=A.slice,O=function(e,t){for(var n=0,r=e.length;n+~]|"+M+")"+M+"*"),z=new RegExp("="+M+"*([^\\]'\"]*?)"+M+"*\\]","g"),X=new RegExp(W),U=new RegExp("^"+R+"$"),V={ID:new RegExp("^#("+R+")"),CLASS:new RegExp("^\\.("+R+")"),TAG:new RegExp("^("+R+"|[*])"),ATTR:new RegExp("^"+I),PSEUDO:new RegExp("^"+W),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+P+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},G=/^(?:input|select|textarea|button)$/i,Y=/^h\d$/i,Q=/^[^{]+\{\s*\[native \w/,J=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,K=/[+~]/,Z=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),ee=function(e,t,n){var r="0x"+t-65536;return r!==r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},te=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ne=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},re=function(){p()},ie=me(function(e){return!0===e.disabled&&("form"in e||"label"in e)},{dir:"parentNode",next:"legend"});try{L.apply(A=H.call(w.childNodes),w.childNodes),A[w.childNodes.length].nodeType}catch(e){L={apply:A.length?function(e,t){q.apply(e,H.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function oe(e,t,r,i){var o,s,l,c,f,h,v,m=t&&t.ownerDocument,T=t?t.nodeType:9;if(r=r||[],"string"!=typeof e||!e||1!==T&&9!==T&&11!==T)return r;if(!i&&((t?t.ownerDocument||t:w)!==d&&p(t),t=t||d,g)){if(11!==T&&(f=J.exec(e)))if(o=f[1]){if(9===T){if(!(l=t.getElementById(o)))return r;if(l.id===o)return r.push(l),r}else if(m&&(l=m.getElementById(o))&&x(t,l)&&l.id===o)return r.push(l),r}else{if(f[2])return L.apply(r,t.getElementsByTagName(e)),r;if((o=f[3])&&n.getElementsByClassName&&t.getElementsByClassName)return L.apply(r,t.getElementsByClassName(o)),r}if(n.qsa&&!S[e+" "]&&(!y||!y.test(e))){if(1!==T)m=t,v=e;else if("object"!==t.nodeName.toLowerCase()){(c=t.getAttribute("id"))?c=c.replace(te,ne):t.setAttribute("id",c=b),s=(h=a(e)).length;while(s--)h[s]="#"+c+" "+ve(h[s]);v=h.join(","),m=K.test(e)&&ge(t.parentNode)||t}if(v)try{return L.apply(r,m.querySelectorAll(v)),r}catch(e){}finally{c===b&&t.removeAttribute("id")}}}return u(e.replace(B,"$1"),t,r,i)}function ae(){var e=[];function t(n,i){return e.push(n+" ")>r.cacheLength&&delete t[e.shift()],t[n+" "]=i}return t}function se(e){return e[b]=!0,e}function ue(e){var t=d.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function le(e,t){var n=e.split("|"),i=n.length;while(i--)r.attrHandle[n[i]]=t}function ce(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function fe(e){return function(t){return"input"===t.nodeName.toLowerCase()&&t.type===e}}function pe(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function de(e){return function(t){return"form"in t?t.parentNode&&!1===t.disabled?"label"in t?"label"in t.parentNode?t.parentNode.disabled===e:t.disabled===e:t.isDisabled===e||t.isDisabled!==!e&&ie(t)===e:t.disabled===e:"label"in t&&t.disabled===e}}function he(e){return se(function(t){return t=+t,se(function(n,r){var i,o=e([],n.length,t),a=o.length;while(a--)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}function ge(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}n=oe.support={},o=oe.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return!!t&&"HTML"!==t.nodeName},p=oe.setDocument=function(e){var t,i,a=e?e.ownerDocument||e:w;return a!==d&&9===a.nodeType&&a.documentElement?(d=a,h=d.documentElement,g=!o(d),w!==d&&(i=d.defaultView)&&i.top!==i&&(i.addEventListener?i.addEventListener("unload",re,!1):i.attachEvent&&i.attachEvent("onunload",re)),n.attributes=ue(function(e){return e.className="i",!e.getAttribute("className")}),n.getElementsByTagName=ue(function(e){return e.appendChild(d.createComment("")),!e.getElementsByTagName("*").length}),n.getElementsByClassName=Q.test(d.getElementsByClassName),n.getById=ue(function(e){return h.appendChild(e).id=b,!d.getElementsByName||!d.getElementsByName(b).length}),n.getById?(r.filter.ID=function(e){var t=e.replace(Z,ee);return function(e){return e.getAttribute("id")===t}},r.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&g){var n=t.getElementById(e);return n?[n]:[]}}):(r.filter.ID=function(e){var t=e.replace(Z,ee);return function(e){var n="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return n&&n.value===t}},r.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&g){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),r.find.TAG=n.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):n.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},r.find.CLASS=n.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&g)return t.getElementsByClassName(e)},v=[],y=[],(n.qsa=Q.test(d.querySelectorAll))&&(ue(function(e){h.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&y.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||y.push("\\["+M+"*(?:value|"+P+")"),e.querySelectorAll("[id~="+b+"-]").length||y.push("~="),e.querySelectorAll(":checked").length||y.push(":checked"),e.querySelectorAll("a#"+b+"+*").length||y.push(".#.+[+~]")}),ue(function(e){e.innerHTML="";var t=d.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&y.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&y.push(":enabled",":disabled"),h.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&y.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),y.push(",.*:")})),(n.matchesSelector=Q.test(m=h.matches||h.webkitMatchesSelector||h.mozMatchesSelector||h.oMatchesSelector||h.msMatchesSelector))&&ue(function(e){n.disconnectedMatch=m.call(e,"*"),m.call(e,"[s!='']:x"),v.push("!=",W)}),y=y.length&&new RegExp(y.join("|")),v=v.length&&new RegExp(v.join("|")),t=Q.test(h.compareDocumentPosition),x=t||Q.test(h.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return f=!0,0;var r=!e.compareDocumentPosition-!t.compareDocumentPosition;return r||(1&(r=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!n.sortDetached&&t.compareDocumentPosition(e)===r?e===d||e.ownerDocument===w&&x(w,e)?-1:t===d||t.ownerDocument===w&&x(w,t)?1:c?O(c,e)-O(c,t):0:4&r?-1:1)}:function(e,t){if(e===t)return f=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===d?-1:t===d?1:i?-1:o?1:c?O(c,e)-O(c,t):0;if(i===o)return ce(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?ce(a[r],s[r]):a[r]===w?-1:s[r]===w?1:0},d):d},oe.matches=function(e,t){return oe(e,null,null,t)},oe.matchesSelector=function(e,t){if((e.ownerDocument||e)!==d&&p(e),t=t.replace(z,"='$1']"),n.matchesSelector&&g&&!S[t+" "]&&(!v||!v.test(t))&&(!y||!y.test(t)))try{var r=m.call(e,t);if(r||n.disconnectedMatch||e.document&&11!==e.document.nodeType)return r}catch(e){}return oe(t,d,null,[e]).length>0},oe.contains=function(e,t){return(e.ownerDocument||e)!==d&&p(e),x(e,t)},oe.attr=function(e,t){(e.ownerDocument||e)!==d&&p(e);var i=r.attrHandle[t.toLowerCase()],o=i&&N.call(r.attrHandle,t.toLowerCase())?i(e,t,!g):void 0;return void 0!==o?o:n.attributes||!g?e.getAttribute(t):(o=e.getAttributeNode(t))&&o.specified?o.value:null},oe.escape=function(e){return(e+"").replace(te,ne)},oe.error=function(e){throw new Error("Syntax error, unrecognized expression: "+e)},oe.uniqueSort=function(e){var t,r=[],i=0,o=0;if(f=!n.detectDuplicates,c=!n.sortStable&&e.slice(0),e.sort(D),f){while(t=e[o++])t===e[o]&&(i=r.push(o));while(i--)e.splice(r[i],1)}return c=null,e},i=oe.getText=function(e){var t,n="",r=0,o=e.nodeType;if(o){if(1===o||9===o||11===o){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=i(e)}else if(3===o||4===o)return e.nodeValue}else while(t=e[r++])n+=i(t);return n},(r=oe.selectors={cacheLength:50,createPseudo:se,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(Z,ee),e[3]=(e[3]||e[4]||e[5]||"").replace(Z,ee),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||oe.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&oe.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return V.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=a(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(Z,ee).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=E[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&E(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=oe.attr(r,e);return null==i?"!="===t:!t||(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i.replace($," ")+" ").indexOf(n)>-1:"|="===t&&(i===n||i.slice(0,n.length+1)===n+"-"))}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),s="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,u){var l,c,f,p,d,h,g=o!==a?"nextSibling":"previousSibling",y=t.parentNode,v=s&&t.nodeName.toLowerCase(),m=!u&&!s,x=!1;if(y){if(o){while(g){p=t;while(p=p[g])if(s?p.nodeName.toLowerCase()===v:1===p.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?y.firstChild:y.lastChild],a&&m){x=(d=(l=(c=(f=(p=y)[b]||(p[b]={}))[p.uniqueID]||(f[p.uniqueID]={}))[e]||[])[0]===T&&l[1])&&l[2],p=d&&y.childNodes[d];while(p=++d&&p&&p[g]||(x=d=0)||h.pop())if(1===p.nodeType&&++x&&p===t){c[e]=[T,d,x];break}}else if(m&&(x=d=(l=(c=(f=(p=t)[b]||(p[b]={}))[p.uniqueID]||(f[p.uniqueID]={}))[e]||[])[0]===T&&l[1]),!1===x)while(p=++d&&p&&p[g]||(x=d=0)||h.pop())if((s?p.nodeName.toLowerCase()===v:1===p.nodeType)&&++x&&(m&&((c=(f=p[b]||(p[b]={}))[p.uniqueID]||(f[p.uniqueID]={}))[e]=[T,x]),p===t))break;return(x-=i)===r||x%r==0&&x/r>=0}}},PSEUDO:function(e,t){var n,i=r.pseudos[e]||r.setFilters[e.toLowerCase()]||oe.error("unsupported pseudo: "+e);return i[b]?i(t):i.length>1?(n=[e,e,"",t],r.setFilters.hasOwnProperty(e.toLowerCase())?se(function(e,n){var r,o=i(e,t),a=o.length;while(a--)e[r=O(e,o[a])]=!(n[r]=o[a])}):function(e){return i(e,0,n)}):i}},pseudos:{not:se(function(e){var t=[],n=[],r=s(e.replace(B,"$1"));return r[b]?se(function(e,t,n,i){var o,a=r(e,null,i,[]),s=e.length;while(s--)(o=a[s])&&(e[s]=!(t[s]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),t[0]=null,!n.pop()}}),has:se(function(e){return function(t){return oe(e,t).length>0}}),contains:se(function(e){return e=e.replace(Z,ee),function(t){return(t.textContent||t.innerText||i(t)).indexOf(e)>-1}}),lang:se(function(e){return U.test(e||"")||oe.error("unsupported lang: "+e),e=e.replace(Z,ee).toLowerCase(),function(t){var n;do{if(n=g?t.lang:t.getAttribute("xml:lang")||t.getAttribute("lang"))return(n=n.toLowerCase())===e||0===n.indexOf(e+"-")}while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===h},focus:function(e){return e===d.activeElement&&(!d.hasFocus||d.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:de(!1),disabled:de(!0),checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,!0===e.selected},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeType<6)return!1;return!0},parent:function(e){return!r.pseudos.empty(e)},header:function(e){return Y.test(e.nodeName)},input:function(e){return G.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||"text"===t.toLowerCase())},first:he(function(){return[0]}),last:he(function(e,t){return[t-1]}),eq:he(function(e,t,n){return[n<0?n+t:n]}),even:he(function(e,t){for(var n=0;n=0;)e.push(r);return e}),gt:he(function(e,t,n){for(var r=n<0?n+t:n;++r1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function be(e,t,n){for(var r=0,i=t.length;r-1&&(o[l]=!(a[l]=f))}}else v=we(v===a?v.splice(h,v.length):v),i?i(null,a,v,u):L.apply(a,v)})}function Ce(e){for(var t,n,i,o=e.length,a=r.relative[e[0].type],s=a||r.relative[" "],u=a?1:0,c=me(function(e){return e===t},s,!0),f=me(function(e){return O(t,e)>-1},s,!0),p=[function(e,n,r){var i=!a&&(r||n!==l)||((t=n).nodeType?c(e,n,r):f(e,n,r));return t=null,i}];u1&&xe(p),u>1&&ve(e.slice(0,u-1).concat({value:" "===e[u-2].type?"*":""})).replace(B,"$1"),n,u0,i=e.length>0,o=function(o,a,s,u,c){var f,h,y,v=0,m="0",x=o&&[],b=[],w=l,C=o||i&&r.find.TAG("*",c),E=T+=null==w?1:Math.random()||.1,k=C.length;for(c&&(l=a===d||a||c);m!==k&&null!=(f=C[m]);m++){if(i&&f){h=0,a||f.ownerDocument===d||(p(f),s=!g);while(y=e[h++])if(y(f,a||d,s)){u.push(f);break}c&&(T=E)}n&&((f=!y&&f)&&v--,o&&x.push(f))}if(v+=m,n&&m!==v){h=0;while(y=t[h++])y(x,b,a,s);if(o){if(v>0)while(m--)x[m]||b[m]||(b[m]=j.call(u));b=we(b)}L.apply(u,b),c&&!o&&b.length>0&&v+t.length>1&&oe.uniqueSort(u)}return c&&(T=E,l=w),x};return n?se(o):o}return s=oe.compile=function(e,t){var n,r=[],i=[],o=S[e+" "];if(!o){t||(t=a(e)),n=t.length;while(n--)(o=Ce(t[n]))[b]?r.push(o):i.push(o);(o=S(e,Ee(i,r))).selector=e}return o},u=oe.select=function(e,t,n,i){var o,u,l,c,f,p="function"==typeof e&&e,d=!i&&a(e=p.selector||e);if(n=n||[],1===d.length){if((u=d[0]=d[0].slice(0)).length>2&&"ID"===(l=u[0]).type&&9===t.nodeType&&g&&r.relative[u[1].type]){if(!(t=(r.find.ID(l.matches[0].replace(Z,ee),t)||[])[0]))return n;p&&(t=t.parentNode),e=e.slice(u.shift().value.length)}o=V.needsContext.test(e)?0:u.length;while(o--){if(l=u[o],r.relative[c=l.type])break;if((f=r.find[c])&&(i=f(l.matches[0].replace(Z,ee),K.test(u[0].type)&&ge(t.parentNode)||t))){if(u.splice(o,1),!(e=i.length&&ve(u)))return L.apply(n,i),n;break}}}return(p||s(e,d))(i,t,!g,n,!t||K.test(e)&&ge(t.parentNode)||t),n},n.sortStable=b.split("").sort(D).join("")===b,n.detectDuplicates=!!f,p(),n.sortDetached=ue(function(e){return 1&e.compareDocumentPosition(d.createElement("fieldset"))}),ue(function(e){return e.innerHTML="","#"===e.firstChild.getAttribute("href")})||le("type|href|height|width",function(e,t,n){if(!n)return e.getAttribute(t,"type"===t.toLowerCase()?1:2)}),n.attributes&&ue(function(e){return e.innerHTML="",e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")})||le("value",function(e,t,n){if(!n&&"input"===e.nodeName.toLowerCase())return e.defaultValue}),ue(function(e){return null==e.getAttribute("disabled")})||le(P,function(e,t,n){var r;if(!n)return!0===e[t]?t.toLowerCase():(r=e.getAttributeNode(t))&&r.specified?r.value:null}),oe}(e);w.find=E,w.expr=E.selectors,w.expr[":"]=w.expr.pseudos,w.uniqueSort=w.unique=E.uniqueSort,w.text=E.getText,w.isXMLDoc=E.isXML,w.contains=E.contains,w.escapeSelector=E.escape;var k=function(e,t,n){var r=[],i=void 0!==n;while((e=e[t])&&9!==e.nodeType)if(1===e.nodeType){if(i&&w(e).is(n))break;r.push(e)}return r},S=function(e,t){for(var n=[];e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n},D=w.expr.match.needsContext;function N(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()}var A=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,t,n){return g(t)?w.grep(e,function(e,r){return!!t.call(e,r,e)!==n}):t.nodeType?w.grep(e,function(e){return e===t!==n}):"string"!=typeof t?w.grep(e,function(e){return u.call(t,e)>-1!==n}):w.filter(t,e,n)}w.filter=function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?w.find.matchesSelector(r,e)?[r]:[]:w.find.matches(e,w.grep(t,function(e){return 1===e.nodeType}))},w.fn.extend({find:function(e){var t,n,r=this.length,i=this;if("string"!=typeof e)return this.pushStack(w(e).filter(function(){for(t=0;t1?w.uniqueSort(n):n},filter:function(e){return this.pushStack(j(this,e||[],!1))},not:function(e){return this.pushStack(j(this,e||[],!0))},is:function(e){return!!j(this,"string"==typeof e&&D.test(e)?w(e):e||[],!1).length}});var q,L=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;(w.fn.init=function(e,t,n){var i,o;if(!e)return this;if(n=n||q,"string"==typeof e){if(!(i="<"===e[0]&&">"===e[e.length-1]&&e.length>=3?[null,e,null]:L.exec(e))||!i[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(i[1]){if(t=t instanceof w?t[0]:t,w.merge(this,w.parseHTML(i[1],t&&t.nodeType?t.ownerDocument||t:r,!0)),A.test(i[1])&&w.isPlainObject(t))for(i in t)g(this[i])?this[i](t[i]):this.attr(i,t[i]);return this}return(o=r.getElementById(i[2]))&&(this[0]=o,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):g(e)?void 0!==n.ready?n.ready(e):e(w):w.makeArray(e,this)}).prototype=w.fn,q=w(r);var H=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};w.fn.extend({has:function(e){var t=w(e,this),n=t.length;return this.filter(function(){for(var e=0;e-1:1===n.nodeType&&w.find.matchesSelector(n,e))){o.push(n);break}return this.pushStack(o.length>1?w.uniqueSort(o):o)},index:function(e){return e?"string"==typeof e?u.call(w(e),this[0]):u.call(this,e.jquery?e[0]:e):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){return this.pushStack(w.uniqueSort(w.merge(this.get(),w(e,t))))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}});function P(e,t){while((e=e[t])&&1!==e.nodeType);return e}w.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return k(e,"parentNode")},parentsUntil:function(e,t,n){return k(e,"parentNode",n)},next:function(e){return P(e,"nextSibling")},prev:function(e){return P(e,"previousSibling")},nextAll:function(e){return k(e,"nextSibling")},prevAll:function(e){return k(e,"previousSibling")},nextUntil:function(e,t,n){return k(e,"nextSibling",n)},prevUntil:function(e,t,n){return k(e,"previousSibling",n)},siblings:function(e){return S((e.parentNode||{}).firstChild,e)},children:function(e){return S(e.firstChild)},contents:function(e){return N(e,"iframe")?e.contentDocument:(N(e,"template")&&(e=e.content||e),w.merge([],e.childNodes))}},function(e,t){w.fn[e]=function(n,r){var i=w.map(this,t,n);return"Until"!==e.slice(-5)&&(r=n),r&&"string"==typeof r&&(i=w.filter(r,i)),this.length>1&&(O[e]||w.uniqueSort(i),H.test(e)&&i.reverse()),this.pushStack(i)}});var M=/[^\x20\t\r\n\f]+/g;function R(e){var t={};return w.each(e.match(M)||[],function(e,n){t[n]=!0}),t}w.Callbacks=function(e){e="string"==typeof e?R(e):w.extend({},e);var t,n,r,i,o=[],a=[],s=-1,u=function(){for(i=i||e.once,r=t=!0;a.length;s=-1){n=a.shift();while(++s-1)o.splice(n,1),n<=s&&s--}),this},has:function(e){return e?w.inArray(e,o)>-1:o.length>0},empty:function(){return o&&(o=[]),this},disable:function(){return i=a=[],o=n="",this},disabled:function(){return!o},lock:function(){return i=a=[],n||t||(o=n=""),this},locked:function(){return!!i},fireWith:function(e,n){return i||(n=[e,(n=n||[]).slice?n.slice():n],a.push(n),t||u()),this},fire:function(){return l.fireWith(this,arguments),this},fired:function(){return!!r}};return l};function I(e){return e}function W(e){throw e}function $(e,t,n,r){var i;try{e&&g(i=e.promise)?i.call(e).done(t).fail(n):e&&g(i=e.then)?i.call(e,t,n):t.apply(void 0,[e].slice(r))}catch(e){n.apply(void 0,[e])}}w.extend({Deferred:function(t){var n=[["notify","progress",w.Callbacks("memory"),w.Callbacks("memory"),2],["resolve","done",w.Callbacks("once memory"),w.Callbacks("once memory"),0,"resolved"],["reject","fail",w.Callbacks("once memory"),w.Callbacks("once memory"),1,"rejected"]],r="pending",i={state:function(){return r},always:function(){return o.done(arguments).fail(arguments),this},"catch":function(e){return i.then(null,e)},pipe:function(){var e=arguments;return w.Deferred(function(t){w.each(n,function(n,r){var i=g(e[r[4]])&&e[r[4]];o[r[1]](function(){var e=i&&i.apply(this,arguments);e&&g(e.promise)?e.promise().progress(t.notify).done(t.resolve).fail(t.reject):t[r[0]+"With"](this,i?[e]:arguments)})}),e=null}).promise()},then:function(t,r,i){var o=0;function a(t,n,r,i){return function(){var s=this,u=arguments,l=function(){var e,l;if(!(t=o&&(r!==W&&(s=void 0,u=[e]),n.rejectWith(s,u))}};t?c():(w.Deferred.getStackHook&&(c.stackTrace=w.Deferred.getStackHook()),e.setTimeout(c))}}return w.Deferred(function(e){n[0][3].add(a(0,e,g(i)?i:I,e.notifyWith)),n[1][3].add(a(0,e,g(t)?t:I)),n[2][3].add(a(0,e,g(r)?r:W))}).promise()},promise:function(e){return null!=e?w.extend(e,i):i}},o={};return w.each(n,function(e,t){var a=t[2],s=t[5];i[t[1]]=a.add,s&&a.add(function(){r=s},n[3-e][2].disable,n[3-e][3].disable,n[0][2].lock,n[0][3].lock),a.add(t[3].fire),o[t[0]]=function(){return o[t[0]+"With"](this===o?void 0:this,arguments),this},o[t[0]+"With"]=a.fireWith}),i.promise(o),t&&t.call(o,o),o},when:function(e){var t=arguments.length,n=t,r=Array(n),i=o.call(arguments),a=w.Deferred(),s=function(e){return function(n){r[e]=this,i[e]=arguments.length>1?o.call(arguments):n,--t||a.resolveWith(r,i)}};if(t<=1&&($(e,a.done(s(n)).resolve,a.reject,!t),"pending"===a.state()||g(i[n]&&i[n].then)))return a.then();while(n--)$(i[n],s(n),a.reject);return a.promise()}});var B=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;w.Deferred.exceptionHook=function(t,n){e.console&&e.console.warn&&t&&B.test(t.name)&&e.console.warn("jQuery.Deferred exception: "+t.message,t.stack,n)},w.readyException=function(t){e.setTimeout(function(){throw t})};var F=w.Deferred();w.fn.ready=function(e){return F.then(e)["catch"](function(e){w.readyException(e)}),this},w.extend({isReady:!1,readyWait:1,ready:function(e){(!0===e?--w.readyWait:w.isReady)||(w.isReady=!0,!0!==e&&--w.readyWait>0||F.resolveWith(r,[w]))}}),w.ready.then=F.then;function _(){r.removeEventListener("DOMContentLoaded",_),e.removeEventListener("load",_),w.ready()}"complete"===r.readyState||"loading"!==r.readyState&&!r.documentElement.doScroll?e.setTimeout(w.ready):(r.addEventListener("DOMContentLoaded",_),e.addEventListener("load",_));var z=function(e,t,n,r,i,o,a){var s=0,u=e.length,l=null==n;if("object"===x(n)){i=!0;for(s in n)z(e,t,s,n[s],!0,o,a)}else if(void 0!==r&&(i=!0,g(r)||(a=!0),l&&(a?(t.call(e,r),t=null):(l=t,t=function(e,t,n){return l.call(w(e),n)})),t))for(;s1,null,!0)},removeData:function(e){return this.each(function(){K.remove(this,e)})}}),w.extend({queue:function(e,t,n){var r;if(e)return t=(t||"fx")+"queue",r=J.get(e,t),n&&(!r||Array.isArray(n)?r=J.access(e,t,w.makeArray(n)):r.push(n)),r||[]},dequeue:function(e,t){t=t||"fx";var n=w.queue(e,t),r=n.length,i=n.shift(),o=w._queueHooks(e,t),a=function(){w.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,a,o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return J.get(e,n)||J.access(e,n,{empty:w.Callbacks("once memory").add(function(){J.remove(e,[t+"queue",n])})})}}),w.fn.extend({queue:function(e,t){var n=2;return"string"!=typeof e&&(t=e,e="fx",n--),arguments.length\x20\t\r\n\f]+)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};ge.optgroup=ge.option,ge.tbody=ge.tfoot=ge.colgroup=ge.caption=ge.thead,ge.th=ge.td;function ye(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&N(e,t)?w.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;n-1)i&&i.push(o);else if(l=w.contains(o.ownerDocument,o),a=ye(f.appendChild(o),"script"),l&&ve(a),n){c=0;while(o=a[c++])he.test(o.type||"")&&n.push(o)}return f}!function(){var e=r.createDocumentFragment().appendChild(r.createElement("div")),t=r.createElement("input");t.setAttribute("type","radio"),t.setAttribute("checked","checked"),t.setAttribute("name","t"),e.appendChild(t),h.checkClone=e.cloneNode(!0).cloneNode(!0).lastChild.checked,e.innerHTML="",h.noCloneChecked=!!e.cloneNode(!0).lastChild.defaultValue}();var be=r.documentElement,we=/^key/,Te=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Ce=/^([^.]*)(?:\.(.+)|)/;function Ee(){return!0}function ke(){return!1}function Se(){try{return r.activeElement}catch(e){}}function De(e,t,n,r,i,o){var a,s;if("object"==typeof t){"string"!=typeof n&&(r=r||n,n=void 0);for(s in t)De(e,s,n,r,t[s],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=ke;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return w().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=w.guid++)),e.each(function(){w.event.add(this,t,i,r,n)})}w.event={global:{},add:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,y=J.get(e);if(y){n.handler&&(n=(o=n).handler,i=o.selector),i&&w.find.matchesSelector(be,i),n.guid||(n.guid=w.guid++),(u=y.events)||(u=y.events={}),(a=y.handle)||(a=y.handle=function(t){return"undefined"!=typeof w&&w.event.triggered!==t.type?w.event.dispatch.apply(e,arguments):void 0}),l=(t=(t||"").match(M)||[""]).length;while(l--)d=g=(s=Ce.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=w.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=w.event.special[d]||{},c=w.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&w.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(e,r,h,a)||e.addEventListener&&e.addEventListener(d,a)),f.add&&(f.add.call(e,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),w.event.global[d]=!0)}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,y=J.hasData(e)&&J.get(e);if(y&&(u=y.events)){l=(t=(t||"").match(M)||[""]).length;while(l--)if(s=Ce.exec(t[l])||[],d=g=s[1],h=(s[2]||"").split(".").sort(),d){f=w.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;while(o--)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,h,y.handle)||w.removeEvent(e,d,y.handle),delete u[d])}else for(d in u)w.event.remove(e,d+t[l],n,r,!0);w.isEmptyObject(u)&&J.remove(e,"handle events")}},dispatch:function(e){var t=w.event.fix(e),n,r,i,o,a,s,u=new Array(arguments.length),l=(J.get(this,"events")||{})[t.type]||[],c=w.event.special[t.type]||{};for(u[0]=t,n=1;n=1))for(;l!==this;l=l.parentNode||this)if(1===l.nodeType&&("click"!==e.type||!0!==l.disabled)){for(o=[],a={},n=0;n-1:w.find(i,this,null,[l]).length),a[i]&&o.push(r);o.length&&s.push({elem:l,handlers:o})}return l=this,u\x20\t\r\n\f]*)[^>]*)\/>/gi,Ae=/\s*$/g;function Le(e,t){return N(e,"table")&&N(11!==t.nodeType?t:t.firstChild,"tr")?w(e).children("tbody")[0]||e:e}function He(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Oe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Pe(e,t){var n,r,i,o,a,s,u,l;if(1===t.nodeType){if(J.hasData(e)&&(o=J.access(e),a=J.set(t,o),l=o.events)){delete a.handle,a.events={};for(i in l)for(n=0,r=l[i].length;n1&&"string"==typeof y&&!h.checkClone&&je.test(y))return e.each(function(i){var o=e.eq(i);v&&(t[0]=y.call(this,i,o.html())),Re(o,t,n,r)});if(p&&(i=xe(t,e[0].ownerDocument,!1,e,r),o=i.firstChild,1===i.childNodes.length&&(i=o),o||r)){for(u=(s=w.map(ye(i,"script"),He)).length;f")},clone:function(e,t,n){var r,i,o,a,s=e.cloneNode(!0),u=w.contains(e.ownerDocument,e);if(!(h.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||w.isXMLDoc(e)))for(a=ye(s),r=0,i=(o=ye(e)).length;r0&&ve(a,!u&&ye(e,"script")),s},cleanData:function(e){for(var t,n,r,i=w.event.special,o=0;void 0!==(n=e[o]);o++)if(Y(n)){if(t=n[J.expando]){if(t.events)for(r in t.events)i[r]?w.event.remove(n,r):w.removeEvent(n,r,t.handle);n[J.expando]=void 0}n[K.expando]&&(n[K.expando]=void 0)}}}),w.fn.extend({detach:function(e){return Ie(this,e,!0)},remove:function(e){return Ie(this,e)},text:function(e){return z(this,function(e){return void 0===e?w.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=e)})},null,e,arguments.length)},append:function(){return Re(this,arguments,function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||Le(this,e).appendChild(e)})},prepend:function(){return Re(this,arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=Le(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return Re(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return Re(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(w.cleanData(ye(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map(function(){return w.clone(this,e,t)})},html:function(e){return z(this,function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!Ae.test(e)&&!ge[(de.exec(e)||["",""])[1].toLowerCase()]){e=w.htmlPrefilter(e);try{for(;n=0&&(u+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-o-u-s-.5))),u}function et(e,t,n){var r=$e(e),i=Fe(e,t,r),o="border-box"===w.css(e,"boxSizing",!1,r),a=o;if(We.test(i)){if(!n)return i;i="auto"}return a=a&&(h.boxSizingReliable()||i===e.style[t]),("auto"===i||!parseFloat(i)&&"inline"===w.css(e,"display",!1,r))&&(i=e["offset"+t[0].toUpperCase()+t.slice(1)],a=!0),(i=parseFloat(i)||0)+Ze(e,t,n||(o?"border":"content"),a,r,i)+"px"}w.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Fe(e,"opacity");return""===n?"1":n}}}},cssNumber:{animationIterationCount:!0,columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,a,s=G(t),u=Xe.test(t),l=e.style;if(u||(t=Je(s)),a=w.cssHooks[t]||w.cssHooks[s],void 0===n)return a&&"get"in a&&void 0!==(i=a.get(e,!1,r))?i:l[t];"string"==(o=typeof n)&&(i=ie.exec(n))&&i[1]&&(n=ue(e,t,i),o="number"),null!=n&&n===n&&("number"===o&&(n+=i&&i[3]||(w.cssNumber[s]?"":"px")),h.clearCloneStyle||""!==n||0!==t.indexOf("background")||(l[t]="inherit"),a&&"set"in a&&void 0===(n=a.set(e,n,r))||(u?l.setProperty(t,n):l[t]=n))}},css:function(e,t,n,r){var i,o,a,s=G(t);return Xe.test(t)||(t=Je(s)),(a=w.cssHooks[t]||w.cssHooks[s])&&"get"in a&&(i=a.get(e,!0,n)),void 0===i&&(i=Fe(e,t,r)),"normal"===i&&t in Ve&&(i=Ve[t]),""===n||n?(o=parseFloat(i),!0===n||isFinite(o)?o||0:i):i}}),w.each(["height","width"],function(e,t){w.cssHooks[t]={get:function(e,n,r){if(n)return!ze.test(w.css(e,"display"))||e.getClientRects().length&&e.getBoundingClientRect().width?et(e,t,r):se(e,Ue,function(){return et(e,t,r)})},set:function(e,n,r){var i,o=$e(e),a="border-box"===w.css(e,"boxSizing",!1,o),s=r&&Ze(e,t,r,a,o);return a&&h.scrollboxSize()===o.position&&(s-=Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-parseFloat(o[t])-Ze(e,t,"border",!1,o)-.5)),s&&(i=ie.exec(n))&&"px"!==(i[3]||"px")&&(e.style[t]=n,n=w.css(e,t)),Ke(e,n,s)}}}),w.cssHooks.marginLeft=_e(h.reliableMarginLeft,function(e,t){if(t)return(parseFloat(Fe(e,"marginLeft"))||e.getBoundingClientRect().left-se(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px"}),w.each({margin:"",padding:"",border:"Width"},function(e,t){w.cssHooks[e+t]={expand:function(n){for(var r=0,i={},o="string"==typeof n?n.split(" "):[n];r<4;r++)i[e+oe[r]+t]=o[r]||o[r-2]||o[0];return i}},"margin"!==e&&(w.cssHooks[e+t].set=Ke)}),w.fn.extend({css:function(e,t){return z(this,function(e,t,n){var r,i,o={},a=0;if(Array.isArray(t)){for(r=$e(e),i=t.length;a1)}});function tt(e,t,n,r,i){return new tt.prototype.init(e,t,n,r,i)}w.Tween=tt,tt.prototype={constructor:tt,init:function(e,t,n,r,i,o){this.elem=e,this.prop=n,this.easing=i||w.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=o||(w.cssNumber[n]?"":"px")},cur:function(){var e=tt.propHooks[this.prop];return e&&e.get?e.get(this):tt.propHooks._default.get(this)},run:function(e){var t,n=tt.propHooks[this.prop];return this.options.duration?this.pos=t=w.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):tt.propHooks._default.set(this),this}},tt.prototype.init.prototype=tt.prototype,tt.propHooks={_default:{get:function(e){var t;return 1!==e.elem.nodeType||null!=e.elem[e.prop]&&null==e.elem.style[e.prop]?e.elem[e.prop]:(t=w.css(e.elem,e.prop,""))&&"auto"!==t?t:0},set:function(e){w.fx.step[e.prop]?w.fx.step[e.prop](e):1!==e.elem.nodeType||null==e.elem.style[w.cssProps[e.prop]]&&!w.cssHooks[e.prop]?e.elem[e.prop]=e.now:w.style(e.elem,e.prop,e.now+e.unit)}}},tt.propHooks.scrollTop=tt.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},w.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2},_default:"swing"},w.fx=tt.prototype.init,w.fx.step={};var nt,rt,it=/^(?:toggle|show|hide)$/,ot=/queueHooks$/;function at(){rt&&(!1===r.hidden&&e.requestAnimationFrame?e.requestAnimationFrame(at):e.setTimeout(at,w.fx.interval),w.fx.tick())}function st(){return e.setTimeout(function(){nt=void 0}),nt=Date.now()}function ut(e,t){var n,r=0,i={height:e};for(t=t?1:0;r<4;r+=2-t)i["margin"+(n=oe[r])]=i["padding"+n]=e;return t&&(i.opacity=i.width=e),i}function lt(e,t,n){for(var r,i=(pt.tweeners[t]||[]).concat(pt.tweeners["*"]),o=0,a=i.length;o1)},removeAttr:function(e){return this.each(function(){w.removeAttr(this,e)})}}),w.extend({attr:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return"undefined"==typeof e.getAttribute?w.prop(e,t,n):(1===o&&w.isXMLDoc(e)||(i=w.attrHooks[t.toLowerCase()]||(w.expr.match.bool.test(t)?dt:void 0)),void 0!==n?null===n?void w.removeAttr(e,t):i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:(e.setAttribute(t,n+""),n):i&&"get"in i&&null!==(r=i.get(e,t))?r:null==(r=w.find.attr(e,t))?void 0:r)},attrHooks:{type:{set:function(e,t){if(!h.radioValue&&"radio"===t&&N(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},removeAttr:function(e,t){var n,r=0,i=t&&t.match(M);if(i&&1===e.nodeType)while(n=i[r++])e.removeAttribute(n)}}),dt={set:function(e,t,n){return!1===t?w.removeAttr(e,n):e.setAttribute(n,n),n}},w.each(w.expr.match.bool.source.match(/\w+/g),function(e,t){var n=ht[t]||w.find.attr;ht[t]=function(e,t,r){var i,o,a=t.toLowerCase();return r||(o=ht[a],ht[a]=i,i=null!=n(e,t,r)?a:null,ht[a]=o),i}});var gt=/^(?:input|select|textarea|button)$/i,yt=/^(?:a|area)$/i;w.fn.extend({prop:function(e,t){return z(this,w.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each(function(){delete this[w.propFix[e]||e]})}}),w.extend({prop:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return 1===o&&w.isXMLDoc(e)||(t=w.propFix[t]||t,i=w.propHooks[t]),void 0!==n?i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:e[t]=n:i&&"get"in i&&null!==(r=i.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){var t=w.find.attr(e,"tabindex");return t?parseInt(t,10):gt.test(e.nodeName)||yt.test(e.nodeName)&&e.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),h.optSelected||(w.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),w.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){w.propFix[this.toLowerCase()]=this});function vt(e){return(e.match(M)||[]).join(" ")}function mt(e){return e.getAttribute&&e.getAttribute("class")||""}function xt(e){return Array.isArray(e)?e:"string"==typeof e?e.match(M)||[]:[]}w.fn.extend({addClass:function(e){var t,n,r,i,o,a,s,u=0;if(g(e))return this.each(function(t){w(this).addClass(e.call(this,t,mt(this)))});if((t=xt(e)).length)while(n=this[u++])if(i=mt(n),r=1===n.nodeType&&" "+vt(i)+" "){a=0;while(o=t[a++])r.indexOf(" "+o+" ")<0&&(r+=o+" ");i!==(s=vt(r))&&n.setAttribute("class",s)}return this},removeClass:function(e){var t,n,r,i,o,a,s,u=0;if(g(e))return this.each(function(t){w(this).removeClass(e.call(this,t,mt(this)))});if(!arguments.length)return this.attr("class","");if((t=xt(e)).length)while(n=this[u++])if(i=mt(n),r=1===n.nodeType&&" "+vt(i)+" "){a=0;while(o=t[a++])while(r.indexOf(" "+o+" ")>-1)r=r.replace(" "+o+" "," ");i!==(s=vt(r))&&n.setAttribute("class",s)}return this},toggleClass:function(e,t){var n=typeof e,r="string"===n||Array.isArray(e);return"boolean"==typeof t&&r?t?this.addClass(e):this.removeClass(e):g(e)?this.each(function(n){w(this).toggleClass(e.call(this,n,mt(this),t),t)}):this.each(function(){var t,i,o,a;if(r){i=0,o=w(this),a=xt(e);while(t=a[i++])o.hasClass(t)?o.removeClass(t):o.addClass(t)}else void 0!==e&&"boolean"!==n||((t=mt(this))&&J.set(this,"__className__",t),this.setAttribute&&this.setAttribute("class",t||!1===e?"":J.get(this,"__className__")||""))})},hasClass:function(e){var t,n,r=0;t=" "+e+" ";while(n=this[r++])if(1===n.nodeType&&(" "+vt(mt(n))+" ").indexOf(t)>-1)return!0;return!1}});var bt=/\r/g;w.fn.extend({val:function(e){var t,n,r,i=this[0];{if(arguments.length)return r=g(e),this.each(function(n){var i;1===this.nodeType&&(null==(i=r?e.call(this,n,w(this).val()):e)?i="":"number"==typeof i?i+="":Array.isArray(i)&&(i=w.map(i,function(e){return null==e?"":e+""})),(t=w.valHooks[this.type]||w.valHooks[this.nodeName.toLowerCase()])&&"set"in t&&void 0!==t.set(this,i,"value")||(this.value=i))});if(i)return(t=w.valHooks[i.type]||w.valHooks[i.nodeName.toLowerCase()])&&"get"in t&&void 0!==(n=t.get(i,"value"))?n:"string"==typeof(n=i.value)?n.replace(bt,""):null==n?"":n}}}),w.extend({valHooks:{option:{get:function(e){var t=w.find.attr(e,"value");return null!=t?t:vt(w.text(e))}},select:{get:function(e){var t,n,r,i=e.options,o=e.selectedIndex,a="select-one"===e.type,s=a?null:[],u=a?o+1:i.length;for(r=o<0?u:a?o:0;r-1)&&(n=!0);return n||(e.selectedIndex=-1),o}}}}),w.each(["radio","checkbox"],function(){w.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=w.inArray(w(e).val(),t)>-1}},h.checkOn||(w.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})}),h.focusin="onfocusin"in e;var wt=/^(?:focusinfocus|focusoutblur)$/,Tt=function(e){e.stopPropagation()};w.extend(w.event,{trigger:function(t,n,i,o){var a,s,u,l,c,p,d,h,v=[i||r],m=f.call(t,"type")?t.type:t,x=f.call(t,"namespace")?t.namespace.split("."):[];if(s=h=u=i=i||r,3!==i.nodeType&&8!==i.nodeType&&!wt.test(m+w.event.triggered)&&(m.indexOf(".")>-1&&(m=(x=m.split(".")).shift(),x.sort()),c=m.indexOf(":")<0&&"on"+m,t=t[w.expando]?t:new w.Event(m,"object"==typeof t&&t),t.isTrigger=o?2:3,t.namespace=x.join("."),t.rnamespace=t.namespace?new RegExp("(^|\\.)"+x.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,t.result=void 0,t.target||(t.target=i),n=null==n?[t]:w.makeArray(n,[t]),d=w.event.special[m]||{},o||!d.trigger||!1!==d.trigger.apply(i,n))){if(!o&&!d.noBubble&&!y(i)){for(l=d.delegateType||m,wt.test(l+m)||(s=s.parentNode);s;s=s.parentNode)v.push(s),u=s;u===(i.ownerDocument||r)&&v.push(u.defaultView||u.parentWindow||e)}a=0;while((s=v[a++])&&!t.isPropagationStopped())h=s,t.type=a>1?l:d.bindType||m,(p=(J.get(s,"events")||{})[t.type]&&J.get(s,"handle"))&&p.apply(s,n),(p=c&&s[c])&&p.apply&&Y(s)&&(t.result=p.apply(s,n),!1===t.result&&t.preventDefault());return t.type=m,o||t.isDefaultPrevented()||d._default&&!1!==d._default.apply(v.pop(),n)||!Y(i)||c&&g(i[m])&&!y(i)&&((u=i[c])&&(i[c]=null),w.event.triggered=m,t.isPropagationStopped()&&h.addEventListener(m,Tt),i[m](),t.isPropagationStopped()&&h.removeEventListener(m,Tt),w.event.triggered=void 0,u&&(i[c]=u)),t.result}},simulate:function(e,t,n){var r=w.extend(new w.Event,n,{type:e,isSimulated:!0});w.event.trigger(r,null,t)}}),w.fn.extend({trigger:function(e,t){return this.each(function(){w.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];if(n)return w.event.trigger(e,t,n,!0)}}),h.focusin||w.each({focus:"focusin",blur:"focusout"},function(e,t){var n=function(e){w.event.simulate(t,e.target,w.event.fix(e))};w.event.special[t]={setup:function(){var r=this.ownerDocument||this,i=J.access(r,t);i||r.addEventListener(e,n,!0),J.access(r,t,(i||0)+1)},teardown:function(){var r=this.ownerDocument||this,i=J.access(r,t)-1;i?J.access(r,t,i):(r.removeEventListener(e,n,!0),J.remove(r,t))}}});var Ct=e.location,Et=Date.now(),kt=/\?/;w.parseXML=function(t){var n;if(!t||"string"!=typeof t)return null;try{n=(new e.DOMParser).parseFromString(t,"text/xml")}catch(e){n=void 0}return n&&!n.getElementsByTagName("parsererror").length||w.error("Invalid XML: "+t),n};var St=/\[\]$/,Dt=/\r?\n/g,Nt=/^(?:submit|button|image|reset|file)$/i,At=/^(?:input|select|textarea|keygen)/i;function jt(e,t,n,r){var i;if(Array.isArray(t))w.each(t,function(t,i){n||St.test(e)?r(e,i):jt(e+"["+("object"==typeof i&&null!=i?t:"")+"]",i,n,r)});else if(n||"object"!==x(t))r(e,t);else for(i in t)jt(e+"["+i+"]",t[i],n,r)}w.param=function(e,t){var n,r=[],i=function(e,t){var n=g(t)?t():t;r[r.length]=encodeURIComponent(e)+"="+encodeURIComponent(null==n?"":n)};if(Array.isArray(e)||e.jquery&&!w.isPlainObject(e))w.each(e,function(){i(this.name,this.value)});else for(n in e)jt(n,e[n],t,i);return r.join("&")},w.fn.extend({serialize:function(){return w.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=w.prop(this,"elements");return e?w.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!w(this).is(":disabled")&&At.test(this.nodeName)&&!Nt.test(e)&&(this.checked||!pe.test(e))}).map(function(e,t){var n=w(this).val();return null==n?null:Array.isArray(n)?w.map(n,function(e){return{name:t.name,value:e.replace(Dt,"\r\n")}}):{name:t.name,value:n.replace(Dt,"\r\n")}}).get()}});var qt=/%20/g,Lt=/#.*$/,Ht=/([?&])_=[^&]*/,Ot=/^(.*?):[ \t]*([^\r\n]*)$/gm,Pt=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Mt=/^(?:GET|HEAD)$/,Rt=/^\/\//,It={},Wt={},$t="*/".concat("*"),Bt=r.createElement("a");Bt.href=Ct.href;function Ft(e){return function(t,n){"string"!=typeof t&&(n=t,t="*");var r,i=0,o=t.toLowerCase().match(M)||[];if(g(n))while(r=o[i++])"+"===r[0]?(r=r.slice(1)||"*",(e[r]=e[r]||[]).unshift(n)):(e[r]=e[r]||[]).push(n)}}function _t(e,t,n,r){var i={},o=e===Wt;function a(s){var u;return i[s]=!0,w.each(e[s]||[],function(e,s){var l=s(t,n,r);return"string"!=typeof l||o||i[l]?o?!(u=l):void 0:(t.dataTypes.unshift(l),a(l),!1)}),u}return a(t.dataTypes[0])||!i["*"]&&a("*")}function zt(e,t){var n,r,i=w.ajaxSettings.flatOptions||{};for(n in t)void 0!==t[n]&&((i[n]?e:r||(r={}))[n]=t[n]);return r&&w.extend(!0,e,r),e}function Xt(e,t,n){var r,i,o,a,s=e.contents,u=e.dataTypes;while("*"===u[0])u.shift(),void 0===r&&(r=e.mimeType||t.getResponseHeader("Content-Type"));if(r)for(i in s)if(s[i]&&s[i].test(r)){u.unshift(i);break}if(u[0]in n)o=u[0];else{for(i in n){if(!u[0]||e.converters[i+" "+u[0]]){o=i;break}a||(a=i)}o=o||a}if(o)return o!==u[0]&&u.unshift(o),n[o]}function Ut(e,t,n,r){var i,o,a,s,u,l={},c=e.dataTypes.slice();if(c[1])for(a in e.converters)l[a.toLowerCase()]=e.converters[a];o=c.shift();while(o)if(e.responseFields[o]&&(n[e.responseFields[o]]=t),!u&&r&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),u=o,o=c.shift())if("*"===o)o=u;else if("*"!==u&&u!==o){if(!(a=l[u+" "+o]||l["* "+o]))for(i in l)if((s=i.split(" "))[1]===o&&(a=l[u+" "+s[0]]||l["* "+s[0]])){!0===a?a=l[i]:!0!==l[i]&&(o=s[0],c.unshift(s[1]));break}if(!0!==a)if(a&&e["throws"])t=a(t);else try{t=a(t)}catch(e){return{state:"parsererror",error:a?e:"No conversion from "+u+" to "+o}}}return{state:"success",data:t}}w.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Ct.href,type:"GET",isLocal:Pt.test(Ct.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":$t,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":w.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?zt(zt(e,w.ajaxSettings),t):zt(w.ajaxSettings,e)},ajaxPrefilter:Ft(It),ajaxTransport:Ft(Wt),ajax:function(t,n){"object"==typeof t&&(n=t,t=void 0),n=n||{};var i,o,a,s,u,l,c,f,p,d,h=w.ajaxSetup({},n),g=h.context||h,y=h.context&&(g.nodeType||g.jquery)?w(g):w.event,v=w.Deferred(),m=w.Callbacks("once memory"),x=h.statusCode||{},b={},T={},C="canceled",E={readyState:0,getResponseHeader:function(e){var t;if(c){if(!s){s={};while(t=Ot.exec(a))s[t[1].toLowerCase()]=t[2]}t=s[e.toLowerCase()]}return null==t?null:t},getAllResponseHeaders:function(){return c?a:null},setRequestHeader:function(e,t){return null==c&&(e=T[e.toLowerCase()]=T[e.toLowerCase()]||e,b[e]=t),this},overrideMimeType:function(e){return null==c&&(h.mimeType=e),this},statusCode:function(e){var t;if(e)if(c)E.always(e[E.status]);else for(t in e)x[t]=[x[t],e[t]];return this},abort:function(e){var t=e||C;return i&&i.abort(t),k(0,t),this}};if(v.promise(E),h.url=((t||h.url||Ct.href)+"").replace(Rt,Ct.protocol+"//"),h.type=n.method||n.type||h.method||h.type,h.dataTypes=(h.dataType||"*").toLowerCase().match(M)||[""],null==h.crossDomain){l=r.createElement("a");try{l.href=h.url,l.href=l.href,h.crossDomain=Bt.protocol+"//"+Bt.host!=l.protocol+"//"+l.host}catch(e){h.crossDomain=!0}}if(h.data&&h.processData&&"string"!=typeof h.data&&(h.data=w.param(h.data,h.traditional)),_t(It,h,n,E),c)return E;(f=w.event&&h.global)&&0==w.active++&&w.event.trigger("ajaxStart"),h.type=h.type.toUpperCase(),h.hasContent=!Mt.test(h.type),o=h.url.replace(Lt,""),h.hasContent?h.data&&h.processData&&0===(h.contentType||"").indexOf("application/x-www-form-urlencoded")&&(h.data=h.data.replace(qt,"+")):(d=h.url.slice(o.length),h.data&&(h.processData||"string"==typeof h.data)&&(o+=(kt.test(o)?"&":"?")+h.data,delete h.data),!1===h.cache&&(o=o.replace(Ht,"$1"),d=(kt.test(o)?"&":"?")+"_="+Et+++d),h.url=o+d),h.ifModified&&(w.lastModified[o]&&E.setRequestHeader("If-Modified-Since",w.lastModified[o]),w.etag[o]&&E.setRequestHeader("If-None-Match",w.etag[o])),(h.data&&h.hasContent&&!1!==h.contentType||n.contentType)&&E.setRequestHeader("Content-Type",h.contentType),E.setRequestHeader("Accept",h.dataTypes[0]&&h.accepts[h.dataTypes[0]]?h.accepts[h.dataTypes[0]]+("*"!==h.dataTypes[0]?", "+$t+"; q=0.01":""):h.accepts["*"]);for(p in h.headers)E.setRequestHeader(p,h.headers[p]);if(h.beforeSend&&(!1===h.beforeSend.call(g,E,h)||c))return E.abort();if(C="abort",m.add(h.complete),E.done(h.success),E.fail(h.error),i=_t(Wt,h,n,E)){if(E.readyState=1,f&&y.trigger("ajaxSend",[E,h]),c)return E;h.async&&h.timeout>0&&(u=e.setTimeout(function(){E.abort("timeout")},h.timeout));try{c=!1,i.send(b,k)}catch(e){if(c)throw e;k(-1,e)}}else k(-1,"No Transport");function k(t,n,r,s){var l,p,d,b,T,C=n;c||(c=!0,u&&e.clearTimeout(u),i=void 0,a=s||"",E.readyState=t>0?4:0,l=t>=200&&t<300||304===t,r&&(b=Xt(h,E,r)),b=Ut(h,b,E,l),l?(h.ifModified&&((T=E.getResponseHeader("Last-Modified"))&&(w.lastModified[o]=T),(T=E.getResponseHeader("etag"))&&(w.etag[o]=T)),204===t||"HEAD"===h.type?C="nocontent":304===t?C="notmodified":(C=b.state,p=b.data,l=!(d=b.error))):(d=C,!t&&C||(C="error",t<0&&(t=0))),E.status=t,E.statusText=(n||C)+"",l?v.resolveWith(g,[p,C,E]):v.rejectWith(g,[E,C,d]),E.statusCode(x),x=void 0,f&&y.trigger(l?"ajaxSuccess":"ajaxError",[E,h,l?p:d]),m.fireWith(g,[E,C]),f&&(y.trigger("ajaxComplete",[E,h]),--w.active||w.event.trigger("ajaxStop")))}return E},getJSON:function(e,t,n){return w.get(e,t,n,"json")},getScript:function(e,t){return w.get(e,void 0,t,"script")}}),w.each(["get","post"],function(e,t){w[t]=function(e,n,r,i){return g(n)&&(i=i||r,r=n,n=void 0),w.ajax(w.extend({url:e,type:t,dataType:i,data:n,success:r},w.isPlainObject(e)&&e))}}),w._evalUrl=function(e){return w.ajax({url:e,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,"throws":!0})},w.fn.extend({wrapAll:function(e){var t;return this[0]&&(g(e)&&(e=e.call(this[0])),t=w(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstElementChild)e=e.firstElementChild;return e}).append(this)),this},wrapInner:function(e){return g(e)?this.each(function(t){w(this).wrapInner(e.call(this,t))}):this.each(function(){var t=w(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=g(e);return this.each(function(n){w(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(e){return this.parent(e).not("body").each(function(){w(this).replaceWith(this.childNodes)}),this}}),w.expr.pseudos.hidden=function(e){return!w.expr.pseudos.visible(e)},w.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},w.ajaxSettings.xhr=function(){try{return new e.XMLHttpRequest}catch(e){}};var Vt={0:200,1223:204},Gt=w.ajaxSettings.xhr();h.cors=!!Gt&&"withCredentials"in Gt,h.ajax=Gt=!!Gt,w.ajaxTransport(function(t){var n,r;if(h.cors||Gt&&!t.crossDomain)return{send:function(i,o){var a,s=t.xhr();if(s.open(t.type,t.url,t.async,t.username,t.password),t.xhrFields)for(a in t.xhrFields)s[a]=t.xhrFields[a];t.mimeType&&s.overrideMimeType&&s.overrideMimeType(t.mimeType),t.crossDomain||i["X-Requested-With"]||(i["X-Requested-With"]="XMLHttpRequest");for(a in i)s.setRequestHeader(a,i[a]);n=function(e){return function(){n&&(n=r=s.onload=s.onerror=s.onabort=s.ontimeout=s.onreadystatechange=null,"abort"===e?s.abort():"error"===e?"number"!=typeof s.status?o(0,"error"):o(s.status,s.statusText):o(Vt[s.status]||s.status,s.statusText,"text"!==(s.responseType||"text")||"string"!=typeof s.responseText?{binary:s.response}:{text:s.responseText},s.getAllResponseHeaders()))}},s.onload=n(),r=s.onerror=s.ontimeout=n("error"),void 0!==s.onabort?s.onabort=r:s.onreadystatechange=function(){4===s.readyState&&e.setTimeout(function(){n&&r()})},n=n("abort");try{s.send(t.hasContent&&t.data||null)}catch(e){if(n)throw e}},abort:function(){n&&n()}}}),w.ajaxPrefilter(function(e){e.crossDomain&&(e.contents.script=!1)}),w.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(e){return w.globalEval(e),e}}}),w.ajaxPrefilter("script",function(e){void 0===e.cache&&(e.cache=!1),e.crossDomain&&(e.type="GET")}),w.ajaxTransport("script",function(e){if(e.crossDomain){var t,n;return{send:function(i,o){t=w(" - {% endif %} -{% endmacro %} diff --git a/flask_admin/templates/bootstrap2/admin/base.html b/flask_admin/templates/bootstrap2/admin/base.html deleted file mode 100644 index becd40533d..0000000000 --- a/flask_admin/templates/bootstrap2/admin/base.html +++ /dev/null @@ -1,83 +0,0 @@ -{% import 'admin/layout.html' as layout with context -%} -{% import 'admin/static.html' as admin_static with context %} - - - - {% block title %}{% if admin_view.category %}{{ admin_view.category }} - {% endif %}{{ admin_view.name }} - {{ admin_view.admin.name }}{% endblock %} - {% block head_meta %} - - - - - - {% endblock %} - {% block head_css %} - - - - {% if admin_view.extra_css %} - {% for css_url in admin_view.extra_css %} - - {% endfor %} - {% endif %} - - {% endblock %} - {% block head %} - {% endblock %} - {% block head_tail %} - {% endblock %} - - - {% block page_body %} -
- - - {% block messages %} - {{ layout.messages() }} - {% endblock %} - - {# store the jinja2 context for form_rules rendering logic #} - {% set render_ctx = h.resolve_ctx() %} - - {% block body %}{% endblock %} -
- {% endblock %} - - {% block tail_js %} - - - - - {% if admin_view.extra_js %} - {% for js_url in admin_view.extra_js %} - - {% endfor %} - {% endif %} - {% endblock %} - - {% block tail %} - {% endblock %} - - diff --git a/flask_admin/templates/bootstrap2/admin/file/form.html b/flask_admin/templates/bootstrap2/admin/file/form.html deleted file mode 100644 index 7a4474ad9b..0000000000 --- a/flask_admin/templates/bootstrap2/admin/file/form.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends 'admin/master.html' %} -{% import 'admin/lib.html' as lib with context %} - -{% block body %} - {% block header %}

{{ header_text }}

{% endblock %} - {% block fa_form %} - {{ lib.render_form(form, dir_url) }} - {% endblock %} -{% endblock %} diff --git a/flask_admin/templates/bootstrap2/admin/file/list.html b/flask_admin/templates/bootstrap2/admin/file/list.html deleted file mode 100644 index f3883c9c5b..0000000000 --- a/flask_admin/templates/bootstrap2/admin/file/list.html +++ /dev/null @@ -1,196 +0,0 @@ -{% extends 'admin/master.html' %} -{% import 'admin/lib.html' as lib with context %} -{% import 'admin/actions.html' as actionslib with context %} - -{% block body %} - {% block breadcrums %} - - {% endblock %} - - {% block file_list_table %} -
- - - - {% block list_header scoped %} - {% if actions %} - - {% endif %} - - {% for column in admin_view.column_list %} - - {% endfor %} - {% endblock %} - - - {% for name, path, is_dir, size, date in items %} - - {% block list_row scoped %} - {% if actions %} - - {% endif %} - - {% if is_dir %} - - {% else %} - - {% if admin_view.is_column_visible('size') %} - - {% endif %} - {% if admin_view.is_column_visible('date') %} - - {% endif %} - {% endif %} - {% endblock %} - - {% endfor %} -
- -   - {% if admin_view.is_column_sortable(column) %} - {% if sort_column == column %} - - {{ admin_view.column_label(column) }} - {% if sort_desc %} - - {% else %} - - {% endif %} - - {% else %} - {{ admin_view.column_label(column) }} - {% endif %} - {% else %} - {{ _gettext(admin_view.column_label(column)) }} - {% endif %} -
- {% if not is_dir %} - - {% endif %} - - {% block list_row_actions scoped %} - {% if admin_view.can_rename and path and name != '..' %} - {%- if admin_view.rename_modal -%} - {{ lib.add_modal_button(url=get_url('.rename', path=path, modal=True), - title=_gettext('Rename File'), - content='') }} - {% else %} - - - - {%- endif -%} - {% endif %} - {%- if admin_view.can_delete and path -%} - {% if is_dir %} - {% if name != '..' and admin_view.can_delete_dirs %} -
- {{ delete_form.path(value=path) }} - {% if delete_form.csrf_token %} - {{ delete_form.csrf_token }} - {% elif csrf_token %} - - {% endif %} - -
- {% endif %} - {% else %} -
- {{ delete_form.path(value=path) }} - {% if delete_form.csrf_token %} - {{ delete_form.csrf_token }} - {% elif csrf_token %} - - {% endif %} - -
- {% endif %} - {%- endif -%} - {% endblock %} -
- - {{ name }} - - - {% if admin_view.can_download %} - {%- if admin_view.edit_modal and admin_view.is_file_editable(path) -%} - {{ lib.add_modal_button(url=get_file_url(path, modal=True)|safe, - btn_class='', content=name) }} - {% else %} - {{ name }} - {%- endif -%} - {% else %} - {{ name }} - {% endif %} - - {{ size|filesizeformat }} - - {{ timestamp_format(date) }} -
-
- {% endblock %} - {% block toolbar %} -
- {% if admin_view.can_upload %} -
- {%- if admin_view.upload_modal -%} - {{ lib.add_modal_button(url=get_dir_url('.upload', path=dir_path, modal=True), - btn_class="btn btn-large", - content=_gettext('Upload File')) }} - {% else %} - {{ _gettext('Upload File') }} - {%- endif -%} -
- {% endif %} - {% if admin_view.can_mkdir %} -
- {%- if admin_view.mkdir_modal -%} - {{ lib.add_modal_button(url=get_dir_url('.mkdir', path=dir_path, modal=True), - btn_class="btn btn-large", - content=_gettext('Create Directory')) }} - {% else %} - {{ _gettext('Create Directory') }} - {%- endif -%} -
- {% endif %} - {% if actions %} -
- {{ actionslib.dropdown(actions, 'dropdown-toggle btn btn-large') }} -
- {% endif %} -
- {% endblock %} - - {% block actions %} - {{ actionslib.form(actions, get_url('.action_view')) }} - {% endblock %} - - {%- if admin_view.rename_modal or admin_view.mkdir_modal - or admin_view.upload_modal or admin_view.edit_modal -%} - {{ lib.add_modal_window() }} - {%- endif -%} -{% endblock %} - -{% block tail %} - {{ super() }} - {{ actionslib.script(_gettext('Please select at least one file.'), - actions, - actions_confirmation) }} -{% endblock %} diff --git a/flask_admin/templates/bootstrap2/admin/file/modals/form.html b/flask_admin/templates/bootstrap2/admin/file/modals/form.html deleted file mode 100644 index 6148596c1d..0000000000 --- a/flask_admin/templates/bootstrap2/admin/file/modals/form.html +++ /dev/null @@ -1,18 +0,0 @@ -{% import 'admin/static.html' as admin_static with context %} -{% import 'admin/lib.html' as lib with context %} - -{% block body %} - {# content added to modal-content #} - {% block fa_form %} - {{ lib.render_form(form, dir_url, action=request.url, is_modal=True) }} - {% endblock %} -{% endblock %} - -{% block tail %} - - - -{% endblock %} diff --git a/flask_admin/templates/bootstrap2/admin/layout.html b/flask_admin/templates/bootstrap2/admin/layout.html deleted file mode 100644 index 6ac9e9be49..0000000000 --- a/flask_admin/templates/bootstrap2/admin/layout.html +++ /dev/null @@ -1,96 +0,0 @@ -{% macro menu_icon(item) -%} -{% set icon_type = item.get_icon_type() %} -{%- if icon_type %} - {% set icon_value = item.get_icon_value() %} - {% if icon_type == 'glyph' %} - - {% elif icon_type == 'fa' %} - - {% elif icon_type == 'image' %} - menu image - {% elif icon_type == 'image-url' %} - menu image - {% endif %} -{% endif %} -{%- endmacro %} - -{% macro menu(menu_root=None) %} - {% if menu_root is none %}{% set menu_root = admin_view.admin.menu() %}{% endif %} - {%- for item in menu_root %} - {%- if item.is_category() -%} - {% set children = item.get_children() %} - {%- if children %} - {% set class_name = item.get_class_name() or '' %} - {%- if item.is_active(admin_view) %} - - {% endif %} - {%- else %} - {%- if item.is_accessible() and item.is_visible() -%} - {% set class_name = item.get_class_name() %} - {%- if item.is_active(admin_view) %} -
  • - {%- else %} - - {%- endif %} - {{ menu_icon(item) }}{{ item.name }} -
  • - {%- endif -%} - {% endif -%} - {% endfor %} -{% endmacro %} - -{% macro menu_links(links=None) %} - {% if links is none %}{% set links = admin_view.admin.menu_links() %}{% endif %} - {% for item in links %} - {% set class_name = item.get_class_name() %} - {% if item.is_accessible() and item.is_visible() %} - - {{ menu_icon(item) }}{{ item.name }} - - {% endif %} - {% endfor %} -{% endmacro %} - -{% macro messages() %} - {% with messages = get_flashed_messages(with_categories=True) %} - {% if messages %} - {% for category, m in messages %} - {% if category %} -
    - {% else %} -
    - {% endif %} - x - {{ m }} -
    - {% endfor %} - {% endif %} - {% endwith %} -{% endmacro %} diff --git a/flask_admin/templates/bootstrap2/admin/lib.html b/flask_admin/templates/bootstrap2/admin/lib.html deleted file mode 100644 index f05d5f93ef..0000000000 --- a/flask_admin/templates/bootstrap2/admin/lib.html +++ /dev/null @@ -1,265 +0,0 @@ -{% import 'admin/static.html' as admin_static with context %} - -{# ---------------------- Pager -------------------------- #} -{% macro pager(page, pages, generator) -%} -{% if pages > 1 %} - -{% endif %} -{%- endmacro %} - -{% macro simple_pager(page, have_next, generator) -%} - -{%- endmacro %} - -{# ---------------------- Modal Window -------------------------- #} -{% macro add_modal_window(modal_window_id='fa_modal_window') %} - -{% endmacro %} - -{% macro add_modal_button(url='', title='', content='', modal_window_id='fa_modal_window', btn_class='icon') %} - - {{ content|safe }} - -{% endmacro %} - -{# ---------------------- Forms -------------------------- #} -{% macro render_field(form, field, kwargs={}, caller=None) %} - {% set direct_error = h.is_field_error(field.errors) %} -
    -
    - -
    -
    -
    - {{ field(**kwargs)|safe }} -
    - {% if field.description %} -

    {{ field.description|safe }}

    - {% endif %} - {% if direct_error %} -
      - {% for e in field.errors if e is string %} -
    • {{ e }}
    • - {% endfor %} -
    - {% endif %} -
    - {% if caller %} - {{ caller(form, field, direct_error, kwargs) }} - {% endif %} -
    -{% endmacro %} - -{% macro render_header(form, text) %} -

    {{ text }}

    -{% endmacro %} - -{% macro render_form_fields(form, form_opts=None) %} - {% if form.hidden_tag is defined %} - {{ form.hidden_tag() }} - {% else %} - {% if csrf_token %} - - {% endif %} - {% for f in form if f.widget.input_type == 'hidden' %} - {{ f }} - {% endfor %} - {% endif %} - - {% if form_opts and form_opts.form_rules %} - {% for r in form_opts.form_rules %} - {{ r(form, form_opts=form_opts) }} - {% endfor %} - {% else %} - {% for f in form if f.widget.input_type != 'hidden' %} - {% if form_opts %} - {% set kwargs = form_opts.widget_args.get(f.short_name, {}) %} - {% else %} - {% set kwargs = {} %} - {% endif %} - {{ render_field(form, f, kwargs) }} - {% endfor %} - {% endif %} -{% endmacro %} - -{% macro form_tag(form=None, action=None) %} -
    -
    - {{ caller() }} -
    -
    -{% endmacro %} - -{% macro render_form_buttons(cancel_url, extra=None, is_modal=False) %} -
    -
    -
    - - {% if extra %} - {{ extra }} - {% endif %} - {% if cancel_url %} - {{ _gettext('Cancel') }} - {% endif %} -
    -
    -{% endmacro %} - -{% macro render_form(form, cancel_url, extra=None, form_opts=None, action=None, is_modal=False) -%} - {% call form_tag(action=action) %} - {{ render_form_fields(form, form_opts=form_opts) }} - {{ render_form_buttons(cancel_url, extra, is_modal) }} - {% endcall %} -{% endmacro %} - -{% macro form_css() %} - - - {% if config.MAPBOX_MAP_ID %} - - - {% endif %} - {% if editable_columns %} - - {% endif %} -{% endmacro %} - -{% macro form_js() %} - {% if config.MAPBOX_MAP_ID %} - - - - {% if config.MAPBOX_SEARCH %} - - - {% endif %} - {% endif %} - - {% if editable_columns %} - - {% endif %} - -{% endmacro %} - -{% macro extra() %} - {% if admin_view.can_create %} - - {% endif %} - {% if admin_view.can_edit %} - - {% endif %} -{% endmacro %} diff --git a/flask_admin/templates/bootstrap2/admin/master.html b/flask_admin/templates/bootstrap2/admin/master.html deleted file mode 100644 index 8f27dad00c..0000000000 --- a/flask_admin/templates/bootstrap2/admin/master.html +++ /dev/null @@ -1 +0,0 @@ -{% extends admin_base_template %} diff --git a/flask_admin/templates/bootstrap2/admin/model/create.html b/flask_admin/templates/bootstrap2/admin/model/create.html deleted file mode 100644 index 9e0834edb6..0000000000 --- a/flask_admin/templates/bootstrap2/admin/model/create.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends 'admin/master.html' %} -{% import 'admin/lib.html' as lib with context %} -{% from 'admin/lib.html' import extra with context %} {# backward compatible #} - -{% block head %} - {{ super() }} - {{ lib.form_css() }} -{% endblock %} - -{% block body %} - {% block navlinks %} - - {% endblock %} - - {% block create_form %} - {{ lib.render_form(form, return_url, extra(), form_opts) }} - {% endblock %} -{% endblock %} - -{% block tail %} - {{ super() }} - {{ lib.form_js() }} -{% endblock %} diff --git a/flask_admin/templates/bootstrap2/admin/model/details.html b/flask_admin/templates/bootstrap2/admin/model/details.html deleted file mode 100644 index 2d516b81d5..0000000000 --- a/flask_admin/templates/bootstrap2/admin/model/details.html +++ /dev/null @@ -1,54 +0,0 @@ -{% extends 'admin/master.html' %} -{% import 'admin/lib.html' as lib with context %} - -{% block body %} - {% block navlinks %} - - {% endblock %} - - {% block details_search %} -
    -
    - {{ _gettext('Filter') }} - -
    -
    - {% endblock %} - - {% block details_table %} - - {% for c, name in details_columns %} - - - - - {% endfor %} -
    - {{ name }} - - {{ get_value(model, c) }} -
    - {% endblock %} -{% endblock %} - -{% block tail %} - {{ super() }} - -{% endblock %} diff --git a/flask_admin/templates/bootstrap2/admin/model/edit.html b/flask_admin/templates/bootstrap2/admin/model/edit.html deleted file mode 100644 index 5bd4099928..0000000000 --- a/flask_admin/templates/bootstrap2/admin/model/edit.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends 'admin/master.html' %} -{% import 'admin/lib.html' as lib with context %} -{% from 'admin/lib.html' import extra with context %} {# backward compatible #} - -{% block head %} - {{ super() }} - {{ lib.form_css() }} -{% endblock %} - -{% block body %} - {% block navlinks %} - - {% endblock %} - - {% block edit_form %} - {{ lib.render_form(form, return_url, extra(), form_opts) }} - {% endblock %} -{% endblock %} - -{% block tail %} - {{ super() }} - {{ lib.form_js() }} -{% endblock %} diff --git a/flask_admin/templates/bootstrap2/admin/model/inline_field_list.html b/flask_admin/templates/bootstrap2/admin/model/inline_field_list.html deleted file mode 100644 index 1207334329..0000000000 --- a/flask_admin/templates/bootstrap2/admin/model/inline_field_list.html +++ /dev/null @@ -1,15 +0,0 @@ -{% import 'admin/model/inline_list_base.html' as base with context %} - -{% macro render_field(field) %} - {{ field }} - - {% if h.is_field_error(field.errors) %} -
      - {% for e in field.errors if e is string %} -
    • {{ e }}
    • - {% endfor %} -
    - {% endif %} -{% endmacro %} - -{{ base.render_inline_fields(field, template, render_field, check) }} diff --git a/flask_admin/templates/bootstrap2/admin/model/inline_form.html b/flask_admin/templates/bootstrap2/admin/model/inline_form.html deleted file mode 100644 index 6ae3f6c059..0000000000 --- a/flask_admin/templates/bootstrap2/admin/model/inline_form.html +++ /dev/null @@ -1,4 +0,0 @@ -{% import 'admin/lib.html' as lib with context %} -
    - {{ lib.render_form_fields(field.form, form_opts=form_opts) }} -
    diff --git a/flask_admin/templates/bootstrap2/admin/model/inline_list_base.html b/flask_admin/templates/bootstrap2/admin/model/inline_list_base.html deleted file mode 100644 index c0fb9faff1..0000000000 --- a/flask_admin/templates/bootstrap2/admin/model/inline_list_base.html +++ /dev/null @@ -1,42 +0,0 @@ -{% macro render_inline_fields(field, template, render, check=None) %} -
    - {# existing inline form fields #} -
    - {% for subfield in field %} -
    - {%- if not check or check(subfield) %} - - {{ field.label.text }} #{{ loop.index }} -
    - {% if subfield.get_pk and subfield.get_pk() %} - - - {% else %} - - {% endif %} -
    -
    - {%- endif -%} - {{ render(subfield) }} -
    - {% endfor %} -
    - - {# template for new inline form fields #} -
    - {% filter forceescape %} -
    - - {{ _gettext('New') }} {{ field.label.text }} -
    - -
    -
    - {{ render(template) }} -
    - {% endfilter %} -
    - - {{ _gettext('Add') }} {{ field.label.text }} -
    -{% endmacro %} diff --git a/flask_admin/templates/bootstrap2/admin/model/layout.html b/flask_admin/templates/bootstrap2/admin/model/layout.html deleted file mode 100644 index 3963139884..0000000000 --- a/flask_admin/templates/bootstrap2/admin/model/layout.html +++ /dev/null @@ -1,105 +0,0 @@ -{% macro filter_options(btn_class='dropdown-toggle') %} - - {{ _gettext('Add Filter') }} - - -{% endmacro %} - -{% macro export_options(btn_class='dropdown-toggle') %} - {% if admin_view.export_types|length > 1 %} - - {% else %} -
  • - {{ _gettext('Export') }} -
  • - {% endif %} -{% endmacro %} - -{% macro filter_form() %} -
    - {% for arg_name, arg_value in extra_args.items() %} - - {% endfor %} - {% if sort_column is not none %} - - {% endif %} - {% if sort_desc %} - - {% endif %} - {% if search %} - - {% endif %} - {% if page_size != default_page_size %} - - {% endif %} -
    - - {% if active_filters %} - {{ _gettext('Reset Filters') }} - {% endif %} -
    - -
    -
    -
    -{% endmacro %} - -{% macro search_form(input_class=None) %} -
    - {% for flt_name, flt_value in filter_args.items() %} - - {% endfor %} - {% for arg_name, arg_value in extra_args.items() %} - - {% endfor %} - {% if page_size != default_page_size %} - - {% endif %} - {% if sort_column is not none %} - - {% 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 %} - {% if search %} -
    - - - - -
    - {% else %} - - {% endif %} -
    -{% endmacro %} - -{% macro page_size_form(generator, btn_class='dropdown-toggle') %} - - {{ page_size }} {{ _gettext('items') }} - - -{% endmacro %} diff --git a/flask_admin/templates/bootstrap2/admin/model/list.html b/flask_admin/templates/bootstrap2/admin/model/list.html deleted file mode 100755 index c3b29593db..0000000000 --- a/flask_admin/templates/bootstrap2/admin/model/list.html +++ /dev/null @@ -1,196 +0,0 @@ -{% extends 'admin/master.html' %} -{% import 'admin/lib.html' as lib with context %} -{% import 'admin/static.html' as admin_static with context%} -{% import 'admin/model/layout.html' as model_layout with context %} -{% import 'admin/actions.html' as actionlib with context %} -{% import 'admin/model/row_actions.html' as row_actions with context %} - -{% block head %} - {{ super() }} - {{ lib.form_css() }} -{% endblock %} - -{% block body %} - {% block model_menu_bar %} - - {% endblock %} - - {% if filters %} - {{ model_layout.filter_form() }} -
    - {% endif %} - - {% block model_list_table %} -
    - - - - {% block list_header scoped %} - {% if actions %} - - {% endif %} - {% block list_row_actions_header %} - {% if admin_view.column_display_actions %} - - {% endif %} - {% endblock %} - {% for c, name in list_columns %} - {% set column = loop.index0 %} - - {% endfor %} - {% endblock %} - - - {% for row in data %} - - {% block list_row scoped %} - {% if actions %} - - {% endif %} - {% block list_row_actions_column scoped %} - {% if admin_view.column_display_actions %} - - {%- endif -%} - {% endblock %} - - {% for c, name in list_columns %} - - {% endfor %} - {% endblock %} - - {% else %} - - - - {% endfor %} -
    - -   - {% if admin_view.is_sortable(c) %} - {% if sort_column == column %} - - {{ name }} - {% if sort_desc %} - - {% else %} - - {% endif %} - - {% else %} - {{ name }} - {% endif %} - {% else %} - {{ name }} - {% endif %} - {% if admin_view.column_descriptions.get(c) %} - - {% endif %} -
    - - - {% block list_row_actions scoped %} - {% for action in list_row_actions %} - {{ action.render_ctx(get_pk_value(row), row) }} - {% endfor %} - {% endblock %} - - {% if admin_view.is_editable(c) %} - {% set form = list_forms[get_pk_value(row)] %} - {% if form.csrf_token %} - {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c), csrf=form.csrf_token._value()) }} - {% elif csrf_token %} - {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c), csrf=csrf_token()) }} - {% else %} - {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c)) }} - {% endif %} - {% else %} - {{ get_value(row, c) }} - {% endif %} -
    - {% block empty_list_message %} -
    - {{ admin_view.get_empty_list_message() }} -
    - {% endblock %} -
    -
    - {% block list_pager %} - {% if num_pages is not none %} - {{ lib.pager(page, num_pages, pager_url) }} - {% else %} - {{ lib.simple_pager(page, data|length == page_size, pager_url) }} - {% endif %} - {% endblock %} - {% endblock %} - - {{ actionlib.form(actions, get_url('.action_view')) }} - - {%- if admin_view.edit_modal or admin_view.create_modal or admin_view.details_modal -%} - {{ lib.add_modal_window() }} - {%- endif -%} -{% endblock %} - -{% block tail %} - {{ super() }} - - {% if filter_groups %} - - - {% endif %} - - {{ lib.form_js() }} - - - {{ actionlib.script(_gettext('Please select at least one record.'), - actions, - actions_confirmation) }} -{% endblock %} diff --git a/flask_admin/templates/bootstrap2/admin/model/modals/create.html b/flask_admin/templates/bootstrap2/admin/model/modals/create.html deleted file mode 100644 index 58b3f45ece..0000000000 --- a/flask_admin/templates/bootstrap2/admin/model/modals/create.html +++ /dev/null @@ -1,25 +0,0 @@ -{% import 'admin/static.html' as admin_static with context%} -{% import 'admin/lib.html' as lib with context %} - -{# store the jinja2 context for form_rules rendering logic #} -{% set render_ctx = h.resolve_ctx() %} - -{% block body %} - {# "save and add" button is removed from modal (it won't function properly) #} - {% block create_form %} - {{ lib.render_form(form, return_url, extra=None, form_opts=form_opts, - action=url_for('.create_view', url=return_url), - is_modal=True) }} - {% endblock %} -{% endblock %} - -{% block tail %} - - - -{% endblock %} diff --git a/flask_admin/templates/bootstrap2/admin/model/modals/details.html b/flask_admin/templates/bootstrap2/admin/model/modals/details.html deleted file mode 100755 index 3580ca4d97..0000000000 --- a/flask_admin/templates/bootstrap2/admin/model/modals/details.html +++ /dev/null @@ -1,40 +0,0 @@ -{% import 'admin/static.html' as admin_static with context%} -{% import 'admin/lib.html' as lib with context %} - -{% block body %} - {% block details_search %} -
    -
    - {{ _gettext('Filter') }} - -
    -
    - {% endblock %} - - {% block details_table %} - - {% for c, name in details_columns %} - - - - - {% endfor %} -
    - {{ name }} - - {{ get_value(model, c) }} -
    - {% endblock %} -{% endblock %} - -{% block tail %} - - - - -{% endblock %} diff --git a/flask_admin/templates/bootstrap2/admin/model/modals/edit.html b/flask_admin/templates/bootstrap2/admin/model/modals/edit.html deleted file mode 100644 index 569aab992c..0000000000 --- a/flask_admin/templates/bootstrap2/admin/model/modals/edit.html +++ /dev/null @@ -1,25 +0,0 @@ -{% import 'admin/static.html' as admin_static with context%} -{% import 'admin/lib.html' as lib with context %} - -{# store the jinja2 context for form_rules rendering logic #} -{% set render_ctx = h.resolve_ctx() %} - -{% block body %} - {# "save and continue" button is removed from modal (it won't function properly) #} - {% block edit_form %} - {{ lib.render_form(form, return_url, extra=None, form_opts=form_opts, - action=url_for('.edit_view', id=request.args.get('id'), url=return_url), - is_modal=True) }} - {% endblock %} -{% endblock %} - -{% block tail %} - - - -{% endblock %} diff --git a/flask_admin/templates/bootstrap2/admin/model/row_actions.html b/flask_admin/templates/bootstrap2/admin/model/row_actions.html deleted file mode 100644 index 31463ed207..0000000000 --- a/flask_admin/templates/bootstrap2/admin/model/row_actions.html +++ /dev/null @@ -1,38 +0,0 @@ -{% import 'admin/lib.html' as lib with context %} - -{% macro link(action, url, icon_class=None) %} - - - -{% endmacro %} - -{% macro view_row(action, row_id, row) %} - {{ link(action, get_url('.details_view', id=row_id, url=return_url), 'fa fa-eye glyphicon icon-eye-open') }} -{% endmacro %} - -{% macro view_row_popup(action, row_id, row) %} - {{ lib.add_modal_button(url=get_url('.details_view', id=row_id, url=return_url, modal=True), title=action.title, content='') }} -{% endmacro %} - -{% macro edit_row(action, row_id, row) %} - {{ link(action, get_url('.edit_view', id=row_id, url=return_url), 'fa fa-pencil glyphicon icon-pencil') }} -{% endmacro %} - -{% macro edit_row_popup(action, row_id, row) %} - {{ lib.add_modal_button(url=get_url('.edit_view', id=row_id, url=return_url, modal=True), title=action.title, content='') }} -{% endmacro %} - -{% macro delete_row(action, row_id, row) %} -
    - {{ delete_form.id(value=get_pk_value(row)) }} - {{ delete_form.url(value=return_url) }} - {% if delete_form.csrf_token %} - {{ delete_form.csrf_token }} - {% elif csrf_token %} - - {% endif %} - -
    -{% endmacro %} diff --git a/flask_admin/templates/bootstrap2/admin/rediscli/console.html b/flask_admin/templates/bootstrap2/admin/rediscli/console.html deleted file mode 100644 index 465f817f56..0000000000 --- a/flask_admin/templates/bootstrap2/admin/rediscli/console.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends 'admin/master.html' %} -{% import 'admin/lib.html' as lib with context %} -{% import 'admin/static.html' as admin_static with context%} - -{% block head %} - {{ super() }} - -{% endblock %} - -{% block body %} -
    -
    -
    -
    -
    - -
    -
    -
    -{% endblock %} - -{% block tail %} - {{ super() }} - - - -{% endblock %} diff --git a/flask_admin/templates/bootstrap2/admin/rediscli/response.html b/flask_admin/templates/bootstrap2/admin/rediscli/response.html deleted file mode 100644 index f4a950a878..0000000000 --- a/flask_admin/templates/bootstrap2/admin/rediscli/response.html +++ /dev/null @@ -1,32 +0,0 @@ -{% macro render(item, depth=0) %} - {% set type = type_name(item) %} - - {% if type == 'tuple' or type == 'list' %} - {% if not item %} - Empty {{ type }}. - {% else %} - {% for n in item %} - {{ loop.index }}) {{ render(n, depth + 1) }}
    - {% endfor %} - {% endif %} - {% elif type == 'bool' %} - {% if depth == 0 and item %} - OK - {% else %} - {{ item }} - {% endif %} - {% elif type == 'str' or type == 'unicode' %} - "{{ item }}" - {% elif type == 'bytes' %} - "{{ item.decode('utf-8') }}" - {% elif type == 'TextWrapper' %} -
    {{ item }}
    - {% elif type == 'dict' %} - {% for k, v in item.items() %} - {{ loop.index }}) {{ k }} - {{ render(v, depth + 1) }}
    - {% endfor %} - {% else %} - {{ item }} - {% endif %} -{% endmacro %} -{{ render(result) }} \ No newline at end of file diff --git a/flask_admin/templates/bootstrap2/admin/static.html b/flask_admin/templates/bootstrap2/admin/static.html deleted file mode 100644 index 5735fbd405..0000000000 --- a/flask_admin/templates/bootstrap2/admin/static.html +++ /dev/null @@ -1,3 +0,0 @@ -{% macro url() -%} - {{ get_url('admin.static', *varargs, **kwargs) }} -{%- endmacro %} diff --git a/flask_admin/templates/bootstrap3/admin/actions.html b/flask_admin/templates/bootstrap3/admin/actions.html deleted file mode 100644 index 02257bb021..0000000000 --- a/flask_admin/templates/bootstrap3/admin/actions.html +++ /dev/null @@ -1,34 +0,0 @@ -{% import 'admin/static.html' as admin_static with context %} - -{% macro dropdown(actions, btn_class='btn dropdown-toggle') -%} - {{ _gettext('With selected') }} - -{% endmacro %} - -{% macro form(actions, url) %} - {% if actions %} - - {% endif %} -{% endmacro %} - -{% macro script(message, actions, actions_confirmation) %} - {% if actions %} - - - - {% endif %} -{% endmacro %} diff --git a/flask_admin/templates/bootstrap3/admin/base.html b/flask_admin/templates/bootstrap3/admin/base.html deleted file mode 100644 index 823df51776..0000000000 --- a/flask_admin/templates/bootstrap3/admin/base.html +++ /dev/null @@ -1,98 +0,0 @@ -{% import 'admin/layout.html' as layout with context -%} -{% import 'admin/static.html' as admin_static with context %} - - - - {% block title %}{% if admin_view.category %}{{ admin_view.category }} - {% endif %}{{ admin_view.name }} - {{ admin_view.admin.name }}{% endblock %} - {% block head_meta %} - - - - - - {% endblock %} - {% block head_css %} - - {%if config.get('FLASK_ADMIN_SWATCH', 'default') == 'default' %} - - {%endif%} - - - {% if admin_view.extra_css %} - {% for css_url in admin_view.extra_css %} - - {% endfor %} - {% endif %} - - {% endblock %} - {% block head %} - {% endblock %} - {% block head_tail %} - {% endblock %} - - - {% block page_body %} -
    - - - {% block messages %} - {{ layout.messages() }} - {% endblock %} - - {# store the jinja2 context for form_rules rendering logic #} - {% set render_ctx = h.resolve_ctx() %} - - {% block body %}{% endblock %} -
    - {% endblock %} - - {% block tail_js %} - - - - - - {% if admin_view.extra_js %} - {% for js_url in admin_view.extra_js %} - - {% endfor %} - {% endif %} - {% endblock %} - - {% block tail %} - {% endblock %} - - diff --git a/flask_admin/templates/bootstrap3/admin/file/form.html b/flask_admin/templates/bootstrap3/admin/file/form.html deleted file mode 100644 index 7a4474ad9b..0000000000 --- a/flask_admin/templates/bootstrap3/admin/file/form.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends 'admin/master.html' %} -{% import 'admin/lib.html' as lib with context %} - -{% block body %} - {% block header %}

    {{ header_text }}

    {% endblock %} - {% block fa_form %} - {{ lib.render_form(form, dir_url) }} - {% endblock %} -{% endblock %} diff --git a/flask_admin/templates/bootstrap3/admin/file/list.html b/flask_admin/templates/bootstrap3/admin/file/list.html deleted file mode 100644 index 1fb093e109..0000000000 --- a/flask_admin/templates/bootstrap3/admin/file/list.html +++ /dev/null @@ -1,196 +0,0 @@ -{% extends 'admin/master.html' %} -{% import 'admin/lib.html' as lib with context %} -{% import 'admin/actions.html' as actionslib with context %} - -{% block body %} - {% block breadcrums %} - - {% endblock %} - - {% block file_list_table %} -
    - - - - {% block list_header scoped %} - {% if actions %} - - {% endif %} - - {% for column in admin_view.column_list %} - - {% endfor %} - {% endblock %} - - - {% for name, path, is_dir, size, date in items %} - - {% block list_row scoped %} - {% if actions %} - - {% endif %} - - {% if is_dir %} - - {% else %} - - {% if admin_view.is_column_visible('size') %} - - {% endif %} - {% endif %} - {% if admin_view.is_column_visible('date') %} - - {% endif %} - {% endblock %} - - {% endfor %} -
    - -   - {% if admin_view.is_column_sortable(column) %} - {% if sort_column == column %} - - {{ admin_view.column_label(column) }} - {% if sort_desc %} - - {% else %} - - {% endif %} - - {% else %} - {{ admin_view.column_label(column) }} - {% endif %} - {% else %} - {{ _gettext(admin_view.column_label(column)) }} - {% endif %} -
    - {% if not is_dir %} - - {% endif %} - - {% block list_row_actions scoped %} - {% if admin_view.can_rename and path and name != '..' %} - {%- if admin_view.rename_modal -%} - {{ lib.add_modal_button(url=get_url('.rename', path=path, modal=True), - title=_gettext('Rename File'), - content='') }} - {% else %} - - - - {%- endif -%} - {% endif %} - {%- if admin_view.can_delete and path -%} - {% if is_dir %} - {% if name != '..' and admin_view.can_delete_dirs %} -
    - {{ delete_form.path(value=path) }} - {% if delete_form.csrf_token %} - {{ delete_form.csrf_token }} - {% elif csrf_token %} - - {% endif %} - -
    - {% endif %} - {% else %} -
    - {{ delete_form.path(value=path) }} - {% if delete_form.csrf_token %} - {{ delete_form.csrf_token }} - {% elif csrf_token %} - - {% endif %} - -
    - {% endif %} - {%- endif -%} - {% endblock %} -
    - - {{ name }} - - - {% if admin_view.can_download %} - {%- if admin_view.edit_modal and admin_view.is_file_editable(path) -%} - {{ lib.add_modal_button(url=get_file_url(path, modal=True)|safe, - btn_class='', content=name) }} - {% else %} - {{ name }} - {%- endif -%} - {% else %} - {{ name }} - {% endif %} - - {{ size|filesizeformat }} - - {{ timestamp_format(date) }} -
    -
    - {% endblock %} - {% block toolbar %} -
    - {% if admin_view.can_upload %} -
    - {%- if admin_view.upload_modal -%} - {{ lib.add_modal_button(url=get_dir_url('.upload', path=dir_path, modal=True), - btn_class="btn btn-default btn-large", - content=_gettext('Upload File')) }} - {% else %} - {{ _gettext('Upload File') }} - {%- endif -%} -
    - {% endif %} - {% if admin_view.can_mkdir %} -
    - {%- if admin_view.mkdir_modal -%} - {{ lib.add_modal_button(url=get_dir_url('.mkdir', path=dir_path, modal=True), - btn_class="btn btn-default btn-large", - content=_gettext('Create Directory')) }} - {% else %} - {{ _gettext('Create Directory') }} - {%- endif -%} -
    - {% endif %} - {% if actions %} -
    - {{ actionslib.dropdown(actions, 'dropdown-toggle btn btn-default btn-large') }} -
    - {% endif %} -
    - {% endblock %} - - {% block actions %} - {{ actionslib.form(actions, get_url('.action_view')) }} - {% endblock %} - - {%- if admin_view.rename_modal or admin_view.mkdir_modal - or admin_view.upload_modal or admin_view.edit_modal -%} - {{ lib.add_modal_window() }} - {%- endif -%} -{% endblock %} - -{% block tail %} - {{ super() }} - {{ actionslib.script(_gettext('Please select at least one file.'), - actions, - actions_confirmation) }} -{% endblock %} diff --git a/flask_admin/templates/bootstrap3/admin/file/modals/form.html b/flask_admin/templates/bootstrap3/admin/file/modals/form.html deleted file mode 100644 index 68d2f87252..0000000000 --- a/flask_admin/templates/bootstrap3/admin/file/modals/form.html +++ /dev/null @@ -1,19 +0,0 @@ -{% import 'admin/static.html' as admin_static with context %} -{% import 'admin/lib.html' as lib with context %} - -{% block body %} - {# content added to modal-content #} - - -{% endblock %} - -{% block tail %} - -{% endblock %} diff --git a/flask_admin/templates/bootstrap3/admin/layout.html b/flask_admin/templates/bootstrap3/admin/layout.html deleted file mode 100644 index a2f63ea152..0000000000 --- a/flask_admin/templates/bootstrap3/admin/layout.html +++ /dev/null @@ -1,102 +0,0 @@ -{% macro menu_icon(item) -%} -{% set icon_type = item.get_icon_type() %} -{%- if icon_type %} - {% set icon_value = item.get_icon_value() %} - {% if icon_type == 'glyph' %} - - {% elif icon_type == 'fa' %} - - {% elif icon_type == 'image' %} - menu image - {% elif icon_type == 'image-url' %} - menu image - {% endif %} -{% endif %} -{%- endmacro %} - -{% macro menu(menu_root=None) %} - {% if menu_root is none %}{% set menu_root = admin_view.admin.menu() %}{% endif %} - {%- for item in menu_root %} - {%- if item.is_category() -%} - {% set children = item.get_children() %} - {%- if children %} - {% set class_name = item.get_class_name() or '' %} - {%- if item.is_active(admin_view) %} - - {% endif %} - {%- else %} - {%- if item.is_accessible() and item.is_visible() -%} - {% set class_name = item.get_class_name() %} - {%- if item.is_active(admin_view) %} -
  • - {%- else %} - - {%- endif %} - {{ menu_icon(item) }}{{ item.name }} -
  • - {%- endif -%} - {% endif -%} - {% endfor %} -{% endmacro %} - -{% macro menu_links(links=None) %} - {% if links is none %}{% set links = admin_view.admin.menu_links() %}{% endif %} - {% for item in links %} - {% set class_name = item.get_class_name() %} - {% if item.is_accessible() and item.is_visible() %} - - {{ menu_icon(item) }}{{ item.name }} - - {% endif %} - {% endfor %} -{% endmacro %} - -{% macro messages() %} - {% with messages = get_flashed_messages(with_categories=True) %} - {% if messages %} - {% for category, m in messages %} - {% if category %} - {# alert-error changed to alert-danger in bootstrap 3, mapping is for backwards compatibility #} - {% set mapping = {'message': 'info', 'error': 'danger'} %} -
    - {% else %} -
    - {% endif %} - - {{ m }} -
    - {% endfor %} - {% endif %} - {% endwith %} -{% endmacro %} diff --git a/flask_admin/templates/bootstrap3/admin/lib.html b/flask_admin/templates/bootstrap3/admin/lib.html deleted file mode 100644 index 6bfdb05a6b..0000000000 --- a/flask_admin/templates/bootstrap3/admin/lib.html +++ /dev/null @@ -1,256 +0,0 @@ -{% import 'admin/static.html' as admin_static with context %} - -{# ---------------------- Pager -------------------------- #} -{% macro pager(page, pages, generator) -%} -{% if pages > 1 %} -
      - {% set min = page - 3 %} - {% set max = page + 3 + 1 %} - - {% if min < 0 %} - {% set max = max - min %} - {% endif %} - {% if max >= pages %} - {% set min = min - max + pages %} - {% endif %} - - {% if min < 0 %} - {% set min = 0 %} - {% endif %} - {% if max >= pages %} - {% set max = pages %} - {% endif %} - - {% if min > 0 %} -
    • - « -
    • - {% else %} -
    • - « -
    • - {% endif %} - {% if page > 0 %} -
    • - < -
    • - {% else %} -
    • - < -
    • - {% endif %} - - {% for p in range(min, max) %} - {% if page == p %} -
    • - {{ p + 1 }} -
    • - {% else %} -
    • - {{ p + 1 }} -
    • - {% endif %} - {% endfor %} - - {% if page + 1 < pages %} -
    • - > -
    • - {% else %} -
    • - > -
    • - {% endif %} - {% if max < pages %} -
    • - » -
    • - {% else %} -
    • - » -
    • - {% endif %} -
    -{% endif %} -{%- endmacro %} - -{% macro simple_pager(page, have_next, generator) -%} -
      - {% if page > 0 %} -
    • - < -
    • - {% else %} -
    • - < -
    • - {% endif %} - {% if have_next %} -
    • - > -
    • - {% else %} -
    • - > -
    • - {% endif %} -
    -{%- endmacro %} - -{# ---------------------- Modal Window ------------------- #} -{% macro add_modal_window(modal_window_id='fa_modal_window', modal_label_id='fa_modal_label') %} - -{% endmacro %} - -{% macro add_modal_button(url='', title='', content='', modal_window_id='fa_modal_window', btn_class='icon') %} - - {{ content|safe }} - -{% endmacro %} - -{# ---------------------- Forms -------------------------- #} -{% macro render_field(form, field, kwargs={}, caller=None) %} - {% set direct_error = h.is_field_error(field.errors) %} -
    - -
    - {% set _dummy = kwargs.setdefault('class', 'form-control') %} - {{ field(**kwargs)|safe }} - {% if field.description %} -

    {{ field.description|safe }}

    - {% endif %} - {% if direct_error %} -
      - {% for e in field.errors if e is string %} -
    • {{ e }}
    • - {% endfor %} -
    - {% endif %} -
    - {% if caller %} - {{ caller(form, field, direct_error, kwargs) }} - {% endif %} -
    -{% endmacro %} - -{% macro render_header(form, text) %} -

    {{ text }}

    -{% endmacro %} - -{% macro render_form_fields(form, form_opts=None) %} - {% if form.hidden_tag is defined %} - {{ form.hidden_tag() }} - {% else %} - {% if csrf_token %} - - {% endif %} - {% for f in form if f.widget.input_type == 'hidden' %} - {{ f }} - {% endfor %} - {% endif %} - - {% if form_opts and form_opts.form_rules %} - {% for r in form_opts.form_rules %} - {{ r(form, form_opts=form_opts) }} - {% endfor %} - {% else %} - {% for f in form if f.widget.input_type != 'hidden' %} - {% if form_opts %} - {% set kwargs = form_opts.widget_args.get(f.short_name, {}) %} - {% else %} - {% set kwargs = {} %} - {% endif %} - {{ render_field(form, f, kwargs) }} - {% endfor %} - {% endif %} -{% endmacro %} - -{% macro form_tag(form=None, action=None) %} -
    - {{ caller() }} -
    -{% endmacro %} - -{% macro render_form_buttons(cancel_url, extra=None, is_modal=False) %} -
    -
    -
    - - {% if extra %} - {{ extra }} - {% endif %} - {% if cancel_url %} - {{ _gettext('Cancel') }} - {% endif %} -
    -
    -{% endmacro %} - -{% macro render_form(form, cancel_url, extra=None, form_opts=None, action=None, is_modal=False) -%} - {% call form_tag(action=action) %} - {{ render_form_fields(form, form_opts=form_opts) }} - {{ render_form_buttons(cancel_url, extra, is_modal) }} - {% endcall %} -{% endmacro %} - -{% macro form_css() %} - - - - {% if config.MAPBOX_MAP_ID %} - - - {% endif %} - {% if editable_columns %} - - {% endif %} -{% endmacro %} - -{% macro form_js() %} - {% if config.MAPBOX_MAP_ID %} - - - - {% if config.MAPBOX_SEARCH %} - - - {% endif %} - {% endif %} - - {% if editable_columns %} - - {% endif %} - -{% endmacro %} - -{% macro extra() %} - {% if admin_view.can_create %} - - {% endif %} - {% if admin_view.can_edit %} - - {% endif %} -{% endmacro %} diff --git a/flask_admin/templates/bootstrap3/admin/master.html b/flask_admin/templates/bootstrap3/admin/master.html deleted file mode 100644 index 8f27dad00c..0000000000 --- a/flask_admin/templates/bootstrap3/admin/master.html +++ /dev/null @@ -1 +0,0 @@ -{% extends admin_base_template %} diff --git a/flask_admin/templates/bootstrap3/admin/model/create.html b/flask_admin/templates/bootstrap3/admin/model/create.html deleted file mode 100644 index 9e0834edb6..0000000000 --- a/flask_admin/templates/bootstrap3/admin/model/create.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends 'admin/master.html' %} -{% import 'admin/lib.html' as lib with context %} -{% from 'admin/lib.html' import extra with context %} {# backward compatible #} - -{% block head %} - {{ super() }} - {{ lib.form_css() }} -{% endblock %} - -{% block body %} - {% block navlinks %} - - {% endblock %} - - {% block create_form %} - {{ lib.render_form(form, return_url, extra(), form_opts) }} - {% endblock %} -{% endblock %} - -{% block tail %} - {{ super() }} - {{ lib.form_js() }} -{% endblock %} diff --git a/flask_admin/templates/bootstrap3/admin/model/details.html b/flask_admin/templates/bootstrap3/admin/model/details.html deleted file mode 100644 index 0008c5a1b8..0000000000 --- a/flask_admin/templates/bootstrap3/admin/model/details.html +++ /dev/null @@ -1,52 +0,0 @@ -{% extends 'admin/master.html' %} -{% import 'admin/lib.html' as lib with context %} - -{% block body %} - {% block navlinks %} - - {% endblock %} - - {% block details_search %} -
    - {{ _gettext('Filter') }} - -
    - {% endblock %} - - {% block details_table %} - - {% for c, name in details_columns %} - - - - - {% endfor %} -
    - {{ name }} - - {{ get_value(model, c) }} -
    - {% endblock %} -{% endblock %} - -{% block tail %} - {{ super() }} - -{% endblock %} diff --git a/flask_admin/templates/bootstrap3/admin/model/edit.html b/flask_admin/templates/bootstrap3/admin/model/edit.html deleted file mode 100644 index 5bd4099928..0000000000 --- a/flask_admin/templates/bootstrap3/admin/model/edit.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends 'admin/master.html' %} -{% import 'admin/lib.html' as lib with context %} -{% from 'admin/lib.html' import extra with context %} {# backward compatible #} - -{% block head %} - {{ super() }} - {{ lib.form_css() }} -{% endblock %} - -{% block body %} - {% block navlinks %} - - {% endblock %} - - {% block edit_form %} - {{ lib.render_form(form, return_url, extra(), form_opts) }} - {% endblock %} -{% endblock %} - -{% block tail %} - {{ super() }} - {{ lib.form_js() }} -{% endblock %} diff --git a/flask_admin/templates/bootstrap3/admin/model/inline_field_list.html b/flask_admin/templates/bootstrap3/admin/model/inline_field_list.html deleted file mode 100644 index b19dc2ee3a..0000000000 --- a/flask_admin/templates/bootstrap3/admin/model/inline_field_list.html +++ /dev/null @@ -1,15 +0,0 @@ -{% import 'admin/model/inline_list_base.html' as base with context %} - -{% macro render_field(field) %} - {{ field }} - - {% if h.is_field_error(field.errors) %} -
      - {% for e in field.errors if e is string %} -
    • {{ e }}
    • - {% endfor %} -
    - {% endif %} -{% endmacro %} - -{{ base.render_inline_fields(field, template, render_field, check) }} diff --git a/flask_admin/templates/bootstrap3/admin/model/inline_form.html b/flask_admin/templates/bootstrap3/admin/model/inline_form.html deleted file mode 100644 index 6ae3f6c059..0000000000 --- a/flask_admin/templates/bootstrap3/admin/model/inline_form.html +++ /dev/null @@ -1,4 +0,0 @@ -{% import 'admin/lib.html' as lib with context %} -
    - {{ lib.render_form_fields(field.form, form_opts=form_opts) }} -
    diff --git a/flask_admin/templates/bootstrap3/admin/model/inline_list_base.html b/flask_admin/templates/bootstrap3/admin/model/inline_list_base.html deleted file mode 100644 index 79a93db389..0000000000 --- a/flask_admin/templates/bootstrap3/admin/model/inline_list_base.html +++ /dev/null @@ -1,45 +0,0 @@ -{% macro render_inline_fields(field, template, render, check=None) %} -
    - {# existing inline form fields #} -
    - {% for subfield in field %} -
    - {%- if not check or check(subfield) %} - - - {{ field.label.text }} #{{ loop.index }} -
    - {% if subfield.get_pk and subfield.get_pk() %} - - - {% else %} - - {% endif %} -
    -
    -
    -
    - {%- endif -%} - {{ render(subfield) }} -
    - {% endfor %} -
    - - {# template for new inline form fields #} -
    - {% filter forceescape %} -
    - - {{ _gettext('New') }} {{ field.label.text }} -
    - -
    -
    -
    - {{ render(template) }} -
    - {% endfilter %} -
    - {{ _gettext('Add') }} {{ field.label.text }} -
    -{% endmacro %} diff --git a/flask_admin/templates/bootstrap3/admin/model/layout.html b/flask_admin/templates/bootstrap3/admin/model/layout.html deleted file mode 100644 index 792eb5b83f..0000000000 --- a/flask_admin/templates/bootstrap3/admin/model/layout.html +++ /dev/null @@ -1,107 +0,0 @@ -{% macro filter_options(btn_class='dropdown-toggle') %} - - {{ _gettext('Add Filter') }} - - -{% endmacro %} - -{% macro export_options(btn_class='dropdown-toggle') %} - {% if admin_view.export_types|length > 1 %} - - {% else %} -
  • - {{ _gettext('Export') }} -
  • - {% endif %} -{% endmacro %} - -{% macro filter_form() %} -
    - {% for arg_name, arg_value in extra_args.items() %} - - {% endfor %} - {% if sort_column is not none %} - - {% endif %} - {% if sort_desc %} - - {% endif %} - {% if search %} - - {% endif %} - {% if page_size != default_page_size %} - - {% endif %} -
    - - {% if active_filters %} - {{ _gettext('Reset Filters') }} - {% endif %} -
    - -
    -
    -
    -{% endmacro %} - -{% macro search_form(input_class=None) %} - -{% endmacro %} - -{% macro page_size_form(generator, btn_class='dropdown-toggle') %} - - {{ page_size }} {{ _gettext('items') }} - - -{% endmacro %} diff --git a/flask_admin/templates/bootstrap3/admin/model/list.html b/flask_admin/templates/bootstrap3/admin/model/list.html deleted file mode 100755 index 08c1d5743b..0000000000 --- a/flask_admin/templates/bootstrap3/admin/model/list.html +++ /dev/null @@ -1,197 +0,0 @@ -{% extends 'admin/master.html' %} -{% import 'admin/lib.html' as lib with context %} -{% import 'admin/static.html' as admin_static with context%} -{% import 'admin/model/layout.html' as model_layout with context %} -{% import 'admin/actions.html' as actionlib with context %} -{% import 'admin/model/row_actions.html' as row_actions with context %} - -{% block head %} - {{ super() }} - {{ lib.form_css() }} -{% endblock %} - -{% block body %} - {% block model_menu_bar %} - - {% endblock %} - - {% if filters %} - {{ model_layout.filter_form() }} -
    - {% endif %} - - {% block model_list_table %} -
    - - - - {% block list_header scoped %} - {% if actions %} - - {% endif %} - {% block list_row_actions_header %} - {% if admin_view.column_display_actions %} - - {% endif %} - {% endblock %} - {% for c, name in list_columns %} - {% set column = loop.index0 %} - - {% endfor %} - {% endblock %} - - - {% for row in data %} - - {% block list_row scoped %} - {% if actions %} - - {% endif %} - {% block list_row_actions_column scoped %} - {% if admin_view.column_display_actions %} - - {%- endif -%} - {% endblock %} - - {% for c, name in list_columns %} - - {% endfor %} - {% endblock %} - - {% else %} - - - - {% endfor %} -
    - -   - {% if admin_view.is_sortable(c) %} - {% if sort_column == column %} - - {{ name }} - {% if sort_desc %} - - {% else %} - - {% endif %} - - {% else %} - {{ name }} - {% endif %} - {% else %} - {{ name }} - {% endif %} - {% if admin_view.column_descriptions.get(c) %} - - {% endif %} -
    - - - {% block list_row_actions scoped %} - {% for action in list_row_actions %} - {{ action.render_ctx(get_pk_value(row), row) }} - {% endfor %} - {% endblock %} - - {% if admin_view.is_editable(c) %} - {% set form = list_forms[get_pk_value(row)] %} - {% if form.csrf_token %} - {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c), csrf=form.csrf_token._value()) }} - {% elif csrf_token %} - {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c), csrf=csrf_token()) }} - {% else %} - {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c)) }} - {% endif %} - {% else %} - {{ get_value(row, c) }} - {% endif %} -
    - {% block empty_list_message %} -
    - {{ admin_view.get_empty_list_message() }} -
    - {% endblock %} -
    -
    - {% block list_pager %} - {% if num_pages is not none %} - {{ lib.pager(page, num_pages, pager_url) }} - {% else %} - {{ lib.simple_pager(page, data|length == page_size, pager_url) }} - {% endif %} - {% endblock %} - {% endblock %} - - {% block actions %} - {{ actionlib.form(actions, get_url('.action_view')) }} - {% endblock %} - - {%- if admin_view.edit_modal or admin_view.create_modal or admin_view.details_modal -%} - {{ lib.add_modal_window() }} - {%- endif -%} -{% endblock %} - -{% block tail %} - {{ super() }} - - {% if filter_groups %} - - - {% endif %} - - {{ lib.form_js() }} - - - {{ actionlib.script(_gettext('Please select at least one record.'), - actions, - actions_confirmation) }} -{% endblock %} diff --git a/flask_admin/templates/bootstrap3/admin/model/modals/create.html b/flask_admin/templates/bootstrap3/admin/model/modals/create.html deleted file mode 100644 index abc1cf58e8..0000000000 --- a/flask_admin/templates/bootstrap3/admin/model/modals/create.html +++ /dev/null @@ -1,24 +0,0 @@ -{% import 'admin/static.html' as admin_static with context%} -{% import 'admin/lib.html' as lib with context %} - -{# store the jinja2 context for form_rules rendering logic #} -{% set render_ctx = h.resolve_ctx() %} - -{% block body %} - - -{% endblock %} - -{% block tail %} - -{% endblock %} diff --git a/flask_admin/templates/bootstrap3/admin/model/modals/details.html b/flask_admin/templates/bootstrap3/admin/model/modals/details.html deleted file mode 100755 index 9abb55d38a..0000000000 --- a/flask_admin/templates/bootstrap3/admin/model/modals/details.html +++ /dev/null @@ -1,40 +0,0 @@ -{% import 'admin/static.html' as admin_static with context%} -{% import 'admin/lib.html' as lib with context %} - -{% block body %} - - - -{% endblock %} - -{% block tail %} - - -{% endblock %} diff --git a/flask_admin/templates/bootstrap3/admin/model/modals/edit.html b/flask_admin/templates/bootstrap3/admin/model/modals/edit.html deleted file mode 100644 index 8897810f43..0000000000 --- a/flask_admin/templates/bootstrap3/admin/model/modals/edit.html +++ /dev/null @@ -1,26 +0,0 @@ -{% import 'admin/static.html' as admin_static with context%} -{% import 'admin/lib.html' as lib with context %} - -{# store the jinja2 context for form_rules rendering logic #} -{% set render_ctx = h.resolve_ctx() %} - -{% block body %} - - -{% endblock %} - -{% block tail %} - -{% endblock %} diff --git a/flask_admin/templates/bootstrap3/admin/model/row_actions.html b/flask_admin/templates/bootstrap3/admin/model/row_actions.html deleted file mode 100644 index 74d65ca438..0000000000 --- a/flask_admin/templates/bootstrap3/admin/model/row_actions.html +++ /dev/null @@ -1,38 +0,0 @@ -{% import 'admin/lib.html' as lib with context %} - -{% macro link(action, url, icon_class=None) %} - - - -{% endmacro %} - -{% macro view_row(action, row_id, row) %} - {{ link(action, get_url('.details_view', id=row_id, url=return_url), 'fa fa-eye glyphicon glyphicon-eye-open') }} -{% endmacro %} - -{% macro view_row_popup(action, row_id, row) %} - {{ lib.add_modal_button(url=get_url('.details_view', id=row_id, url=return_url, modal=True), title=action.title, content='') }} -{% endmacro %} - -{% macro edit_row(action, row_id, row) %} - {{ link(action, get_url('.edit_view', id=row_id, url=return_url), 'fa fa-pencil glyphicon glyphicon-pencil') }} -{% endmacro %} - -{% macro edit_row_popup(action, row_id, row) %} - {{ lib.add_modal_button(url=get_url('.edit_view', id=row_id, url=return_url, modal=True), title=action.title, content='') }} -{% endmacro %} - -{% macro delete_row(action, row_id, row) %} -
    - {{ delete_form.id(value=get_pk_value(row)) }} - {{ delete_form.url(value=return_url) }} - {% if delete_form.csrf_token %} - {{ delete_form.csrf_token }} - {% elif csrf_token %} - - {% endif %} - -
    -{% endmacro %} diff --git a/flask_admin/templates/bootstrap3/admin/rediscli/console.html b/flask_admin/templates/bootstrap3/admin/rediscli/console.html deleted file mode 100644 index 368f55dda8..0000000000 --- a/flask_admin/templates/bootstrap3/admin/rediscli/console.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends 'admin/master.html' %} -{% import 'admin/lib.html' as lib with context %} -{% import 'admin/static.html' as admin_static with context%} - -{% block head %} - {{ super() }} - -{% endblock %} - -{% block body %} -
    -
    -
    -
    -
    - -
    -
    -
    -{% endblock %} - -{% block tail %} - {{ super() }} - - - -{% endblock %} diff --git a/flask_admin/templates/bootstrap3/admin/rediscli/response.html b/flask_admin/templates/bootstrap3/admin/rediscli/response.html deleted file mode 100644 index f4a950a878..0000000000 --- a/flask_admin/templates/bootstrap3/admin/rediscli/response.html +++ /dev/null @@ -1,32 +0,0 @@ -{% macro render(item, depth=0) %} - {% set type = type_name(item) %} - - {% if type == 'tuple' or type == 'list' %} - {% if not item %} - Empty {{ type }}. - {% else %} - {% for n in item %} - {{ loop.index }}) {{ render(n, depth + 1) }}
    - {% endfor %} - {% endif %} - {% elif type == 'bool' %} - {% if depth == 0 and item %} - OK - {% else %} - {{ item }} - {% endif %} - {% elif type == 'str' or type == 'unicode' %} - "{{ item }}" - {% elif type == 'bytes' %} - "{{ item.decode('utf-8') }}" - {% elif type == 'TextWrapper' %} -
    {{ item }}
    - {% elif type == 'dict' %} - {% for k, v in item.items() %} - {{ loop.index }}) {{ k }} - {{ render(v, depth + 1) }}
    - {% endfor %} - {% else %} - {{ item }} - {% endif %} -{% endmacro %} -{{ render(result) }} \ No newline at end of file diff --git a/flask_admin/templates/bootstrap3/admin/static.html b/flask_admin/templates/bootstrap3/admin/static.html deleted file mode 100644 index 5735fbd405..0000000000 --- a/flask_admin/templates/bootstrap3/admin/static.html +++ /dev/null @@ -1,3 +0,0 @@ -{% macro url() -%} - {{ get_url('admin.static', *varargs, **kwargs) }} -{%- endmacro %} diff --git a/flask_admin/templates/bootstrap4/admin/actions.html b/flask_admin/templates/bootstrap4/admin/actions.html index cd645d8320..89899089b8 100644 --- a/flask_admin/templates/bootstrap4/admin/actions.html +++ b/flask_admin/templates/bootstrap4/admin/actions.html @@ -2,7 +2,7 @@ {% macro dropdown(actions, btn_class='nav-link dropdown-toggle') -%} + aria-expanded="false">{{ _gettext('With selected') }} @@ -244,42 +246,43 @@

    {{ text }}

    {% macro form_css() %} - - {% if config.MAPBOX_MAP_ID %} + + {% if config.FLASK_ADMIN_MAPS is defined and config.FLASK_ADMIN_MAPS %} {% endif %} - {% if editable_columns %} + {% if editable_columns is defined and editable_columns %} {% endif %} {% endmacro %} {% macro form_js() %} - {% if config.MAPBOX_MAP_ID %} - - - - {% if config.MAPBOX_SEARCH %} - + + {% if config.FLASK_ADMIN_MAPS_SEARCH is defined and config.FLASK_ADMIN_MAPS_SEARCH %} + - + {% endif %} {% endif %} - - {% if editable_columns %} - + + {% if editable_columns is defined and editable_columns %} + {% endif %} - + {% endmacro %} {% macro extra() %} diff --git a/flask_admin/templates/bootstrap4/admin/model/details.html b/flask_admin/templates/bootstrap4/admin/model/details.html index f4017e3df0..4e6408b27c 100644 --- a/flask_admin/templates/bootstrap4/admin/model/details.html +++ b/flask_admin/templates/bootstrap4/admin/model/details.html @@ -48,5 +48,5 @@ {% block tail %} {{ super() }} - + {% endblock %} diff --git a/flask_admin/templates/bootstrap4/admin/model/inline_field_list.html b/flask_admin/templates/bootstrap4/admin/model/inline_field_list.html index b19dc2ee3a..384b48c027 100644 --- a/flask_admin/templates/bootstrap4/admin/model/inline_field_list.html +++ b/flask_admin/templates/bootstrap4/admin/model/inline_field_list.html @@ -4,7 +4,7 @@ {{ field }} {% if h.is_field_error(field.errors) %} -
      +
        {% for e in field.errors if e is string %}
      • {{ e }}
      • {% endfor %} diff --git a/flask_admin/templates/bootstrap4/admin/model/inline_list_base.html b/flask_admin/templates/bootstrap4/admin/model/inline_list_base.html index 73ac483498..d8ed6ecd9b 100644 --- a/flask_admin/templates/bootstrap4/admin/model/inline_list_base.html +++ b/flask_admin/templates/bootstrap4/admin/model/inline_list_base.html @@ -3,7 +3,7 @@ {# existing inline form fields #}
        {% for subfield in field %} -
        +
        {%- if not check or check(subfield) %} @@ -11,7 +11,7 @@
        {% if subfield.get_pk and subfield.get_pk() %} - + {% else %} {% endif %} @@ -26,9 +26,9 @@
        {# template for new inline form fields #} -
        +
        {% filter forceescape %} -
        +
        {{ _gettext('New') }} {{ field.label.text }}
        diff --git a/flask_admin/templates/bootstrap4/admin/model/layout.html b/flask_admin/templates/bootstrap4/admin/model/layout.html index 00e6977a68..0a828ead57 100644 --- a/flask_admin/templates/bootstrap4/admin/model/layout.html +++ b/flask_admin/templates/bootstrap4/admin/model/layout.html @@ -1,5 +1,5 @@ {% macro filter_options(btn_class='dropdown-toggle') %} - {{ _gettext('Add Filter') }} + {{ _gettext('Add Filter') }}