├── .coveragerc ├── .github └── workflows │ ├── pre-commit.yaml │ ├── publish.yaml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGES.rst ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── changes.rst ├── conf.py ├── index.rst ├── make.bat ├── requirements.txt └── wtforms_sqlalchemy.rst ├── examples └── flask │ ├── README.rst │ ├── basic.py │ └── templates │ └── create.html ├── pyproject.toml ├── src └── wtforms_sqlalchemy │ ├── __init__.py │ ├── fields.py │ └── orm.py ├── tests ├── __init__.py ├── common.py └── test_main.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = 3 | wtforms_sqlalchemy/* 4 | 5 | [report] 6 | exclude_lines = 7 | pragma: no cover 8 | except ImportError: 9 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yaml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | on: 3 | pull_request: 4 | push: 5 | branches: [main, '*.x'] 6 | jobs: 7 | main: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 11 | - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 12 | with: 13 | python-version: 3.x 14 | - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 15 | - uses: pre-commit-ci/lite-action@9d882e7a565f7008d4faf128f27d1cb6503d4ebf # v1.0.2 16 | if: ${{ !cancelled() }} 17 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | outputs: 10 | hash: ${{ steps.hash.outputs.hash }} 11 | steps: 12 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 13 | - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 14 | with: 15 | python-version: '3.x' 16 | cache: pip 17 | - run: pip install -e . 18 | - run: pip install build 19 | # Use the commit date instead of the current date during the build. 20 | - run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV 21 | - run: python -m build 22 | # Generate hashes used for provenance. 23 | - name: generate hash 24 | id: hash 25 | run: cd dist && echo "hash=$(sha256sum * | base64 -w0)" >> $GITHUB_OUTPUT 26 | - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 27 | with: 28 | path: ./dist 29 | provenance: 30 | needs: [build] 31 | permissions: 32 | actions: read 33 | id-token: write 34 | contents: write 35 | # Can't pin with hash due to how this workflow works. 36 | uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 37 | with: 38 | base64-subjects: ${{ needs.build.outputs.hash }} 39 | create-release: 40 | # Upload the sdist, wheels, and provenance to a GitHub release. They remain 41 | # available as build artifacts for a while as well. 42 | needs: [provenance] 43 | runs-on: ubuntu-latest 44 | permissions: 45 | contents: write 46 | steps: 47 | - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 48 | - name: create release 49 | run: > 50 | gh release create --draft --repo ${{ github.repository }} 51 | ${{ github.ref_name }} 52 | *.intoto.jsonl/* artifact/* 53 | env: 54 | GH_TOKEN: ${{ github.token }} 55 | publish-pypi: 56 | needs: [provenance] 57 | # Wait for approval before attempting to upload to PyPI. This allows reviewing the 58 | # files in the draft release. 59 | environment: 60 | name: publish 61 | url: https://pypi.org/project/wtforms-sqlalchemy/${{ github.ref_name }} 62 | runs-on: ubuntu-latest 63 | permissions: 64 | id-token: write 65 | steps: 66 | - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 67 | - uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 # v1.9.0 68 | with: 69 | repository-url: https://test.pypi.org/legacy/ 70 | packages-dir: artifact/ 71 | - uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 # v1.9.0 72 | with: 73 | packages-dir: artifact/ 74 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - '*.x' 7 | paths-ignore: 8 | - 'docs/**' 9 | - '*.md' 10 | - '*.rst' 11 | pull_request: 12 | paths-ignore: 13 | - 'docs/**' 14 | - '*.md' 15 | - '*.rst' 16 | jobs: 17 | tests: 18 | name: ${{ matrix.name || matrix.python }} 19 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | include: 24 | - {python: '3.13'} 25 | - {python: '3.12'} 26 | - {python: '3.11'} 27 | - {python: '3.10'} 28 | - {python: '3.9'} 29 | steps: 30 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 31 | - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 32 | with: 33 | python-version: ${{ matrix.python }} 34 | allow-prereleases: true 35 | cache: pip 36 | - run: pip install tox 37 | - run: tox run -e ${{ matrix.tox || format('py{0}', matrix.python) }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File extensions 2 | *.pyc 3 | *.pyo 4 | .*.swp 5 | .DS_Store 6 | 7 | # Extra Files 8 | MANIFEST 9 | WTForms_SQLAlchemy.egg-info 10 | .coverage 11 | .coveralls.yml 12 | examples/flask/sample_db.sqlite 13 | 14 | # Directories 15 | build/ 16 | dist/ 17 | docs/_build 18 | docs/html 19 | .eggs 20 | env/ 21 | .tox 22 | 23 | # Editors 24 | .idea 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.6.9 4 | hooks: 5 | - id: ruff 6 | - id: ruff-format 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v5.0.0 9 | hooks: 10 | - id: check-merge-conflict 11 | - id: debug-statements 12 | - id: fix-byte-order-marker 13 | - id: trailing-whitespace 14 | - id: end-of-file-fixer 15 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | python: 4 | install: 5 | - method: pip 6 | path: . 7 | - requirements: docs/requirements.txt 8 | sphinx: 9 | builder: dirhtml 10 | configuration: docs/conf.py 11 | formats: 12 | - pdf 13 | - epub 14 | build: 15 | os: "ubuntu-24.04" 16 | tools: 17 | python: "3.13" 18 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Version 0.4.2 2 | ------------- 3 | 4 | Released on October 25th, 2024 5 | 6 | - move the project to pallets-eco organization 7 | - move from `master` branch to `main` 8 | - move to pyproject.toml 9 | - move to src directory 10 | - remove python 3.8 support 11 | - use pre-commit configuration from flask 12 | - python 3.13 support 13 | 14 | Version 0.4.1 15 | ------------- 16 | 17 | Released on January 5th, 2024 18 | 19 | - Allow to override the default blank value. (:pr:`38`) 20 | 21 | Version 0.4.0 22 | ------------- 23 | 24 | Released on January 4th, 2024 25 | 26 | - Stop support for python 3.6 and 3.7. Start support for python3 27 | 3.11 and 3.12. (:pr:`41`) 28 | - ``render_kw`` support (:pr:`42`) 29 | - ``optgroup`` support (:pr:`44`) 30 | - SQLAlchemy 2.0 support (:pr:`45`) 31 | 32 | Version 0.3 33 | ----------- 34 | 35 | Released on November 6th, 2021 36 | 37 | - Wtforms 3 support. (:pr:`35`) 38 | - SQLAlchemy 1.4 tests fixes. (:pr:`34`) 39 | - Switched from Travis CI to Github Actions (:pr:`33`) 40 | - Abandon deperecated validator. (:pr:`32`) 41 | 42 | Version 0.2 43 | ----------- 44 | 45 | Released on June 21st, 2020 46 | 47 | - Auto-generated ``DecimalField`` does not limit places for ``Float`` 48 | columns. (:pr:`2`) 49 | - Add an example of using this library with Flask-SQAlchemy. (:pr:`3`) 50 | - Generating a form from a model copies any lists of validators 51 | passed in ``field_args`` to prevent modifying a shared value across 52 | forms. (:pr:`5`) 53 | - Don't add a ``Length`` validator when the column's length is not an 54 | int. (:pr:`6`) 55 | - Add ``QueryRadioField``, like ``QuerySelectField`` except 56 | it renders a list of radio fields. Add ``QueryCheckboxField``, like 57 | ``QuerySelectMultipleField`` except it renders a list of checkbox 58 | fields. (:pr:`8`) 59 | - Fix a compatibility issue with SQLAlchemy 2.1 that caused 60 | ``QuerySelectField`` to fail with a ``ValueError``. (:issue:`9`, :pr:`10`, 61 | :pr:`11`) 62 | - QuerySelectField.query allowing no results instead of falling back to 63 | ``query_factory``. (:pr:`15`) 64 | - Explicitly check if db_session is None in converter. (:pr:`17`) 65 | - Check for ``sqlalchemy.`` to avoid matching packages with names starting 66 | with ``sqlalchemy`` (6237a0f_) 67 | - Use SQLAlchemy's Column.doc for WTForm's Field.description (:pr:`21`) 68 | - Stopped support for python < 3.5 and added a style pre-commit hook. (:pr:`23`) 69 | - Documentation cleanup. (:pr:`24`) 70 | 71 | .. _6237a0f: https://github.com/wtforms/wtforms-sqlalchemy/commit/6237a0f9e53ec5f22048be7f129e29f7f1c58448 72 | 73 | Version 0.1 74 | ----------- 75 | 76 | Released on January 18th, 2015 77 | 78 | - Initial release, extracted from WTForms 2.1. 79 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 by Thomas Johansson, James Crasta and others. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * The names of the contributors may not be used to endorse or promote 15 | products derived from this software without specific prior written 16 | permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES.rst 2 | include LICENSE.txt 3 | include MANIFEST.in 4 | include README.md 5 | recursive-include docs * 6 | recursive-include tests * 7 | recursive-exclude docs/_build * 8 | recursive-exclude tests *.pyc 9 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | WTForms-SQLAlchemy 2 | ================== 3 | 4 | .. image:: https://github.com/wtforms/wtforms-sqlalchemy/actions/workflows/tests.yaml/badge.svg 5 | :target: https://github.com/wtforms/wtforms-sqlalchemy/actions/workflows/tests.yaml 6 | .. image:: https://readthedocs.org/projects/wtforms-sqlalchemy/badge/?version=latest&style=flat 7 | :target: https://wtforms-sqlalchemy.readthedocs.io 8 | 9 | WTForms-SQLAlchemy is a fork of the ``wtforms.ext.sqlalchemy`` package from WTForms. 10 | The package has been renamed to ``wtforms_sqlalchemy`` but otherwise should 11 | function the same as ``wtforms.ext.sqlalchemy`` did. 12 | 13 | to install:: 14 | 15 | pip install WTForms-SQLAlchemy 16 | 17 | An example using Flask is included in ``examples/flask``. 18 | 19 | Features 20 | -------- 21 | 22 | 1. Provide ``SelectField`` integration with SQLAlchemy models 23 | 24 | - ``wtforms_sqlalchemy.fields.QuerySelectField`` 25 | - ``wtforms_sqlalchemy.fields.QuerySelectMultipleField`` 26 | 27 | 2. Generate forms from SQLAlchemy models using 28 | ``wtforms_sqlalchemy.orm.model_form`` 29 | 30 | Rationale 31 | --------- 32 | 33 | The reasoning for splitting out this package is that WTForms 2.0 has 34 | deprecated all its ``wtforms.ext.`` packages and they will 35 | not receive any further feature updates. The authors feel that packages 36 | for companion libraries work better with their own release schedule and 37 | the ability to diverge more from WTForms. 38 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/WTForms-SQLAlchemy.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/WTForms-SQLAlchemy.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/WTForms-SQLAlchemy" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/WTForms-SQLAlchemy" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/changes.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | .. include:: ../CHANGES.rst 5 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from pallets_sphinx_themes import get_version 5 | from pallets_sphinx_themes import ProjectLink 6 | 7 | sys.path.insert(0, os.path.abspath("..")) 8 | 9 | # -- Project ------------------------------------------------------------------- 10 | 11 | project = "WTForms-SQLAlchemy" 12 | 13 | # -- General ------------------------------------------------------------------- 14 | 15 | master_doc = "index" 16 | extensions = [ 17 | "sphinx.ext.autodoc", 18 | "sphinx.ext.intersphinx", 19 | "sphinx.ext.viewcode", 20 | "pallets_sphinx_themes", 21 | "sphinx_issues", 22 | "sphinxcontrib.log_cabinet", 23 | ] 24 | intersphinx_mapping = { 25 | "python": ("https://docs.python.org/3/", None), 26 | "WTForms": ("https://wtforms.readthedocs.io/en/stable/", None), 27 | } 28 | copyright = "2020, WTForms Team" 29 | release, version = get_version("WTForms") 30 | exclude_patterns = ["_build"] 31 | pygments_style = "sphinx" 32 | 33 | # -- HTML ---------------------------------------------------------------------- 34 | 35 | html_theme = "werkzeug" 36 | html_context = { 37 | "project_links": [ 38 | ProjectLink( 39 | "WTForms documentation", "https://wtforms.readthedocs.io/en/stable/" 40 | ), 41 | ProjectLink("PyPI Releases", "https://pypi.org/project/WTForms-SQLAlchemy/"), 42 | ProjectLink("Source Code", "https://github.com/wtforms/wtforms-sqlalchemy/"), 43 | ProjectLink( 44 | "Discord Chat", 45 | "https://discord.gg/F65P7Z9", 46 | ), 47 | ProjectLink( 48 | "Issue Tracker", "https://github.com/wtforms/wtforms-sqlalchemy/issues/" 49 | ), 50 | ] 51 | } 52 | html_sidebars = { 53 | "index": ["project.html", "localtoc.html", "searchbox.html"], 54 | "**": ["localtoc.html", "relations.html", "searchbox.html"], 55 | } 56 | singlehtml_sidebars = {"index": ["project.html", "localtoc.html"]} 57 | html_title = f"WTForms SQLAlchemy Documentation ({version})" 58 | html_show_sourcelink = False 59 | 60 | # -- LATEX --------------------------------------------------------------------- 61 | 62 | latex_documents = [ 63 | ( 64 | "index", 65 | "WTForms-SQLAlchemy.tex", 66 | "WTForms-SQLAlchemy Documentation", 67 | "WTForms Team", 68 | "manual", 69 | ), 70 | ] 71 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. WTForms-SQLAlchemy documentation master file, created by 2 | sphinx-quickstart on Sun Nov 10 16:35:07 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | WTForms-SQLAlchemy documentation 7 | ================================ 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | wtforms_sqlalchemy 15 | changes 16 | 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\WTForms-SQLAlchemy.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\WTForms-SQLAlchemy.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx~=8.1.2 2 | Pallets-Sphinx-Themes~=2.2.0 3 | sphinx-issues~=5.0.0 4 | sphinxcontrib-log-cabinet~=1.0.1 5 | python-dateutil~=2.9.0.post0 6 | -------------------------------------------------------------------------------- /docs/wtforms_sqlalchemy.rst: -------------------------------------------------------------------------------- 1 | WTForms-SQLAlchemy 2 | ================== 3 | 4 | .. module:: wtforms_sqlalchemy 5 | 6 | 7 | This module provides SelectField integration with SQLAlchemy ORM models, 8 | similar to those in the Django extension. 9 | 10 | 11 | ORM-backed fields 12 | ~~~~~~~~~~~~~~~~~ 13 | .. module:: wtforms_sqlalchemy.fields 14 | 15 | These fields are provided to make it easier to use data from ORM objects in 16 | your forms. 17 | 18 | .. code-block:: python 19 | 20 | def enabled_categories(): 21 | return Category.query.filter_by(enabled=True) 22 | 23 | class BlogPostEdit(Form): 24 | title = StringField() 25 | blog = QuerySelectField(get_label='title') 26 | category = QuerySelectField(query_factory=enabled_categories, allow_blank=True) 27 | 28 | def edit_blog_post(request, id): 29 | post = Post.query.get(id) 30 | form = BlogPostEdit(obj=post) 31 | # Since we didn't provide a query_factory for the 'blog' field, we need 32 | # to set a dynamic one in the view. 33 | form.blog.query = Blog.query.filter(Blog.author == request.user).order_by(Blog.name) 34 | 35 | 36 | .. autoclass:: QuerySelectField(default field args, query_factory=None, get_pk=None, get_label=None, allow_blank=False, blank_text='', blank_value='__None') 37 | 38 | .. autoclass:: QuerySelectMultipleField(default field args, query_factory=None, get_pk=None, get_label=None) 39 | 40 | 41 | Model forms 42 | ~~~~~~~~~~~ 43 | .. module:: wtforms_sqlalchemy.orm 44 | 45 | It is possible to generate forms from SQLAlchemy models similarly to how it can be done for Django ORM models. 46 | 47 | .. autofunction:: model_form 48 | -------------------------------------------------------------------------------- /examples/flask/README.rst: -------------------------------------------------------------------------------- 1 | WTForms-SQLAlchemy + Flask examples. 2 | 3 | To run this example: 4 | 5 | 1. Clone the repository:: 6 | 7 | git clone https://github.com/wtforms/wtforms-sqlalchemy.git 8 | cd wtforms-sqlalchemy 9 | 10 | 2. Create and activate a virtual environment:: 11 | 12 | virtualenv env 13 | source env/bin/activate 14 | 15 | 3. Install requirements:: 16 | 17 | pip install flask flask_sqlalchemy -e . 18 | 19 | 4. Run the application:: 20 | 21 | python examples/flask/basic.py 22 | 23 | 5. Open your browser to http://127.0.0.1:5000/ 24 | -------------------------------------------------------------------------------- /examples/flask/basic.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask import render_template 3 | from flask import request 4 | from flask_sqlalchemy import SQLAlchemy 5 | 6 | from wtforms_sqlalchemy.orm import model_form 7 | 8 | app = Flask(__name__) 9 | app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///sample_db.sqlite" 10 | app.config["SQLALCHEMY_ECHO"] = True 11 | 12 | db = SQLAlchemy(app) 13 | 14 | 15 | class Car(db.Model): 16 | __tablename__ = "cars" 17 | id = db.Column(db.Integer, primary_key=True) 18 | make = db.Column(db.String(50)) 19 | model = db.Column(db.String(50)) 20 | 21 | 22 | CarForm = model_form(Car) 23 | 24 | 25 | @app.route("/", methods=["GET", "POST"]) 26 | def create_car(): 27 | car = Car() 28 | success = False 29 | 30 | if request.method == "POST": 31 | form = CarForm(request.form, obj=car) 32 | if form.validate(): 33 | form.populate_obj(car) 34 | db.session.add(car) 35 | db.session.commit() 36 | success = True 37 | else: 38 | form = CarForm(obj=car) 39 | 40 | return render_template("create.html", form=form, success=success) 41 | 42 | 43 | if __name__ == "__main__": 44 | db.create_all() 45 | 46 | app.run(debug=True) 47 | -------------------------------------------------------------------------------- /examples/flask/templates/create.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Create Form 6 | 7 | 8 | {% block body %} 9 | {% if success %}

Saved Successfully!

{% endif %} 10 |
11 | {% for field in form %} 12 |

{{ field.label }} {{ field }}

13 | {% endfor %} 14 |

15 |
16 | {% endblock %} 17 | 18 | 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "WTForms-SQLAlchemy" 3 | description = "SQLAlchemy tools for WTForms" 4 | readme = "README.rst" 5 | license = {file = "LICENSE.txt"} 6 | maintainers = [{name = "WTForms"}] 7 | classifiers = [ 8 | "Development Status :: 5 - Production/Stable", 9 | "Environment :: Web Environment", 10 | "Intended Audience :: Developers", 11 | "License :: OSI Approved :: BSD License", 12 | "Operating System :: OS Independent", 13 | "Programming Language :: Python", 14 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 15 | "Topic :: Internet :: WWW/HTTP :: WSGI", 16 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", 17 | "Topic :: Software Development :: Libraries :: Application Frameworks", 18 | ] 19 | requires-python = ">=3.9" 20 | dependencies = [ 21 | "WTForms>=3.1", 22 | "SQLAlchemy>=1.4", 23 | ] 24 | dynamic = ["version"] 25 | 26 | [project.urls] 27 | Documentation = "https://wtforms-sqlalchemy.readthedocs.io/" 28 | Changes = "https://wtforms-sqlalchemy.readthedocs.io/changes/" 29 | "Source Code" = "https://github.com/pallets-eco/wtforms-sqlalchemy/" 30 | "Issue Tracker" = "https://github.com/pallets-eco/wtforms-sqlalchemy/issues/" 31 | Chat = "https://discord.gg/pallets" 32 | 33 | [build-system] 34 | requires = ["hatchling"] 35 | build-backend = "hatchling.build" 36 | 37 | [tool.hatch.build.targets.wheel] 38 | packages = ["src/wtforms_sqlalchemy"] 39 | 40 | [tool.hatch.version] 41 | path = "src/wtforms_sqlalchemy/__init__.py" 42 | 43 | [tool.hatch.build] 44 | include = [ 45 | "src/", 46 | "docs/", 47 | "tests/", 48 | "CHANGES.rst", 49 | "tox.ini", 50 | ] 51 | exclude = [ 52 | "docs/_build/", 53 | ] 54 | 55 | [tool.pytest.ini_options] 56 | testpaths = ["tests"] 57 | filterwarnings = [ 58 | "error", 59 | ] 60 | 61 | [tool.coverage.run] 62 | branch = true 63 | source = ["wtforms_sqlalchemy", "tests"] 64 | 65 | [tool.coverage.paths] 66 | source = ["src", "*/site-packages"] 67 | 68 | [tool.coverage.report] 69 | exclude_lines = [ 70 | "pragma: no cover", 71 | "except ImportError:", 72 | ] 73 | 74 | [tool.ruff] 75 | src = ["src"] 76 | fix = true 77 | show-fixes = true 78 | output-format = "full" 79 | 80 | [tool.ruff.lint] 81 | select = [ 82 | "B", # flake8-bugbear 83 | "E", # pycodestyle error 84 | "F", # pyflakes 85 | "I", # isort 86 | "UP", # pyupgrade 87 | "W", # pycodestyle warning 88 | ] 89 | 90 | [tool.ruff.lint.isort] 91 | force-single-line = true 92 | order-by-type = false 93 | -------------------------------------------------------------------------------- /src/wtforms_sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.4.2" 2 | -------------------------------------------------------------------------------- /src/wtforms_sqlalchemy/fields.py: -------------------------------------------------------------------------------- 1 | """Useful form fields for use with SQLAlchemy ORM.""" 2 | 3 | import operator 4 | from collections import defaultdict 5 | 6 | from wtforms import widgets 7 | from wtforms.fields import SelectFieldBase 8 | from wtforms.validators import ValidationError 9 | 10 | try: 11 | from sqlalchemy.orm.util import identity_key 12 | 13 | has_identity_key = True 14 | except ImportError: 15 | has_identity_key = False 16 | 17 | 18 | __all__ = ( 19 | "QuerySelectField", 20 | "QuerySelectMultipleField", 21 | "QueryRadioField", 22 | "QueryCheckboxField", 23 | ) 24 | 25 | 26 | class QuerySelectField(SelectFieldBase): 27 | """Will display a select drop-down field to choose between ORM results in a 28 | sqlalchemy `Query`. The `data` property actually will store/keep an ORM 29 | model instance, not the ID. Submitting a choice which is not in the query 30 | will result in a validation error. 31 | 32 | This field only works for queries on models whose primary key column(s) 33 | have a consistent string representation. This means it mostly only works 34 | for those composed of string, unicode, and integer types. For the most 35 | part, the primary keys will be auto-detected from the model, alternately 36 | pass a one-argument callable to `get_pk` which can return a unique 37 | comparable key. 38 | 39 | The `query` property on the field can be set from within a view to assign 40 | a query per-instance to the field. If the property is not set, the 41 | `query_factory` callable passed to the field constructor will be called to 42 | obtain a query. 43 | 44 | Specify `get_label` to customize the label associated with each option. If 45 | a string, this is the name of an attribute on the model object to use as 46 | the label text. If a one-argument callable, this callable will be passed 47 | model instance and expected to return the label text. Otherwise, the model 48 | object's `__str__` will be used. 49 | 50 | 51 | Specify `get_group` to allow `option` elements to be grouped into `optgroup` 52 | sections. If a string, this is the name of an attribute on the model 53 | containing the group name. If a one-argument callable, this callable will 54 | be passed the model instance and expected to return a group name. Otherwise, 55 | the `option` elements will not be grouped. Note: the result of `get_group` 56 | will be used as both the grouping key and the display label in the `select` 57 | options. 58 | 59 | Specify `get_render_kw` to apply HTML attributes to each option. If a 60 | string, this is the name of an attribute on the model containing a 61 | dictionary. If a one-argument callable, this callable will be passed the 62 | model instance and expected to return a dictionary. Otherwise, an empty 63 | dictionary will be used. 64 | 65 | If `allow_blank` is set to `True`, then a blank choice will be added to the 66 | top of the list. Selecting this choice will result in the `data` property 67 | being `None`. The label for this blank choice can be set by specifying the 68 | `blank_text` parameter. The value for this blank choice can be set by 69 | specifying the `blank_value` parameter (default: `__None`). 70 | """ 71 | 72 | widget = widgets.Select() 73 | 74 | def __init__( 75 | self, 76 | label=None, 77 | validators=None, 78 | query_factory=None, 79 | get_pk=None, 80 | get_label=None, 81 | get_group=None, 82 | get_render_kw=None, 83 | allow_blank=False, 84 | blank_text="", 85 | blank_value="__None", 86 | **kwargs, 87 | ): 88 | super().__init__(label, validators, **kwargs) 89 | self.query_factory = query_factory 90 | 91 | if get_pk is None: 92 | if not has_identity_key: 93 | raise Exception( 94 | "The sqlalchemy identity_key function could not be imported." 95 | ) 96 | self.get_pk = get_pk_from_identity 97 | else: 98 | self.get_pk = get_pk 99 | 100 | if get_label is None: 101 | self.get_label = lambda x: x 102 | elif isinstance(get_label, str): 103 | self.get_label = operator.attrgetter(get_label) 104 | else: 105 | self.get_label = get_label 106 | 107 | if get_group is None: 108 | self._has_groups = False 109 | else: 110 | self._has_groups = True 111 | if isinstance(get_group, str): 112 | self.get_group = operator.attrgetter(get_group) 113 | else: 114 | self.get_group = get_group 115 | 116 | if get_render_kw is None: 117 | self.get_render_kw = lambda _: {} 118 | elif isinstance(get_render_kw, str): 119 | self.get_render_kw = operator.attrgetter(get_render_kw) 120 | else: 121 | self.get_render_kw = get_render_kw 122 | 123 | self.allow_blank = allow_blank 124 | self.blank_text = blank_text 125 | self.blank_value = blank_value 126 | self.query = None 127 | self._object_list = None 128 | 129 | def _get_data(self): 130 | if self._formdata is not None: 131 | for pk, obj in self._get_object_list(): 132 | if pk == self._formdata: 133 | self._set_data(obj) 134 | break 135 | return self._data 136 | 137 | def _set_data(self, data): 138 | self._data = data 139 | self._formdata = None 140 | 141 | data = property(_get_data, _set_data) 142 | 143 | def _get_object_list(self): 144 | if self._object_list is None: 145 | query = self.query if self.query is not None else self.query_factory() 146 | get_pk = self.get_pk 147 | self._object_list = list((str(get_pk(obj)), obj) for obj in query) 148 | return self._object_list 149 | 150 | def iter_choices(self): 151 | if self.allow_blank: 152 | yield (self.blank_value, self.blank_text, self.data is None, {}) 153 | 154 | for pk, obj in self._get_object_list(): 155 | yield (pk, self.get_label(obj), obj == self.data, self.get_render_kw(obj)) 156 | 157 | def has_groups(self): 158 | return self._has_groups 159 | 160 | def iter_groups(self): 161 | if self.has_groups(): 162 | groups = defaultdict(list) 163 | for pk, obj in self._get_object_list(): 164 | groups[self.get_group(obj)].append((pk, obj)) 165 | for group, choices in groups.items(): 166 | yield (group, self._choices_generator(choices)) 167 | 168 | def _choices_generator(self, choices): 169 | if not choices: 170 | _choices = [] 171 | else: 172 | _choices = choices 173 | 174 | for pk, obj in _choices: 175 | yield (pk, self.get_label(obj), obj == self.data, self.get_render_kw(obj)) 176 | 177 | def process_formdata(self, valuelist): 178 | if valuelist: 179 | if self.allow_blank and valuelist[0] == self.blank_value: 180 | self.data = None 181 | else: 182 | self._data = None 183 | self._formdata = valuelist[0] 184 | 185 | def pre_validate(self, form): 186 | data = self.data 187 | if data is not None: 188 | for _, obj in self._get_object_list(): 189 | if data == obj: 190 | break 191 | else: 192 | raise ValidationError(self.gettext("Not a valid choice")) 193 | elif self._formdata or not self.allow_blank: 194 | raise ValidationError(self.gettext("Not a valid choice")) 195 | 196 | 197 | class QuerySelectMultipleField(QuerySelectField): 198 | """Very similar to QuerySelectField with the difference that this will 199 | display a multiple select. The data property will hold a list with ORM 200 | model instances and will be an empty list when no value is selected. 201 | 202 | If any of the items in the data list or submitted form data cannot 203 | be found in the query, this will result in a validation error. 204 | """ 205 | 206 | widget = widgets.Select(multiple=True) 207 | 208 | def __init__(self, label=None, validators=None, default=None, **kwargs): 209 | if default is None: 210 | default = [] 211 | super().__init__(label, validators, default=default, **kwargs) 212 | if kwargs.get("allow_blank", False): 213 | import warnings 214 | 215 | warnings.warn( 216 | "allow_blank=True does not do anything for QuerySelectMultipleField.", 217 | stacklevel=2, 218 | ) 219 | self._invalid_formdata = False 220 | 221 | def _get_data(self): 222 | formdata = self._formdata 223 | if formdata is not None: 224 | data = [] 225 | for pk, obj in self._get_object_list(): 226 | if not formdata: 227 | break 228 | elif pk in formdata: 229 | formdata.remove(pk) 230 | data.append(obj) 231 | if formdata: 232 | self._invalid_formdata = True 233 | self._set_data(data) 234 | return self._data 235 | 236 | def _set_data(self, data): 237 | self._data = data 238 | self._formdata = None 239 | 240 | data = property(_get_data, _set_data) 241 | 242 | def iter_choices(self): 243 | for pk, obj in self._get_object_list(): 244 | yield (pk, self.get_label(obj), obj in self.data, self.get_render_kw(obj)) 245 | 246 | def process_formdata(self, valuelist): 247 | self._formdata = set(valuelist) 248 | 249 | def pre_validate(self, form): 250 | if self._invalid_formdata: 251 | raise ValidationError(self.gettext("Not a valid choice")) 252 | elif self.data: 253 | obj_list = list(x[1] for x in self._get_object_list()) 254 | for v in self.data: 255 | if v not in obj_list: 256 | raise ValidationError(self.gettext("Not a valid choice")) 257 | 258 | 259 | class QueryRadioField(QuerySelectField): 260 | widget = widgets.ListWidget(prefix_label=False) 261 | option_widget = widgets.RadioInput() 262 | 263 | 264 | class QueryCheckboxField(QuerySelectMultipleField): 265 | widget = widgets.ListWidget(prefix_label=False) 266 | option_widget = widgets.CheckboxInput() 267 | 268 | 269 | def get_pk_from_identity(obj): 270 | key = identity_key(instance=obj)[1] 271 | return ":".join(str(x) for x in key) 272 | -------------------------------------------------------------------------------- /src/wtforms_sqlalchemy/orm.py: -------------------------------------------------------------------------------- 1 | """Tools for generating forms based on SQLAlchemy models.""" 2 | 3 | import inspect 4 | 5 | from sqlalchemy import inspect as sainspect 6 | from wtforms import fields as wtforms_fields 7 | from wtforms import validators 8 | from wtforms.form import Form 9 | 10 | from .fields import QuerySelectField 11 | from .fields import QuerySelectMultipleField 12 | 13 | __all__ = ( 14 | "model_fields", 15 | "model_form", 16 | ) 17 | 18 | 19 | def converts(*args): 20 | def _inner(func): 21 | func._converter_for = frozenset(args) 22 | return func 23 | 24 | return _inner 25 | 26 | 27 | class ModelConversionError(Exception): 28 | def __init__(self, message): 29 | Exception.__init__(self, message) 30 | 31 | 32 | class ModelConverterBase: 33 | def __init__(self, converters, use_mro=True): 34 | self.use_mro = use_mro 35 | 36 | if not converters: 37 | converters = {} 38 | 39 | for name in dir(self): 40 | obj = getattr(self, name) 41 | if hasattr(obj, "_converter_for"): 42 | for classname in obj._converter_for: 43 | converters[classname] = obj 44 | 45 | self.converters = converters 46 | 47 | def get_converter(self, column): 48 | """Searches `self.converters` for a converter method with an argument 49 | that matches the column's type.""" 50 | if self.use_mro: 51 | types = inspect.getmro(type(column.type)) 52 | else: 53 | types = [type(column.type)] 54 | 55 | # Search by module + name 56 | for col_type in types: 57 | type_string = f"{col_type.__module__}.{col_type.__name__}" 58 | 59 | # remove the 'sqlalchemy.' prefix for sqlalchemy <0.7 compatibility 60 | if type_string.startswith("sqlalchemy."): 61 | type_string = type_string[11:] 62 | 63 | if type_string in self.converters: 64 | return self.converters[type_string] 65 | 66 | # Search by name 67 | for col_type in types: 68 | if col_type.__name__ in self.converters: 69 | return self.converters[col_type.__name__] 70 | 71 | raise ModelConversionError( 72 | f"Could not find field converter for column {column.name} ({types[0]!r})." 73 | ) 74 | 75 | def convert(self, model, mapper, prop, field_args, db_session=None): 76 | if not hasattr(prop, "columns") and not hasattr(prop, "direction"): 77 | return 78 | elif not hasattr(prop, "direction") and len(prop.columns) != 1: 79 | raise TypeError( 80 | "Do not know how to convert multiple-column properties currently" 81 | ) 82 | 83 | kwargs = { 84 | "validators": [], 85 | "filters": [], 86 | "default": None, 87 | "description": prop.doc, 88 | } 89 | 90 | if field_args: 91 | kwargs.update(field_args) 92 | 93 | if kwargs["validators"]: 94 | # Copy to prevent modifying nested mutable values of the original 95 | kwargs["validators"] = list(kwargs["validators"]) 96 | 97 | converter = None 98 | column = None 99 | 100 | if not hasattr(prop, "direction"): 101 | column = prop.columns[0] 102 | # Support sqlalchemy.schema.ColumnDefault, so users can benefit 103 | # from setting defaults for fields, e.g.: 104 | # field = Column(DateTimeField, default=datetime.utcnow) 105 | 106 | default = getattr(column, "default", None) 107 | 108 | if default is not None: 109 | # Only actually change default if it has an attribute named 110 | # 'arg' that's callable. 111 | callable_default = getattr(default, "arg", None) 112 | 113 | if callable_default is not None: 114 | # ColumnDefault(val).arg can be also a plain value 115 | default = ( 116 | callable_default(None) 117 | if callable(callable_default) 118 | else callable_default 119 | ) 120 | 121 | kwargs["default"] = default 122 | 123 | if column.nullable: 124 | kwargs["validators"].append(validators.Optional()) 125 | else: 126 | kwargs["validators"].append(validators.InputRequired()) 127 | 128 | converter = self.get_converter(column) 129 | else: 130 | # We have a property with a direction. 131 | if db_session is None: 132 | raise ModelConversionError( 133 | f"Cannot convert field {prop.key}, need DB session." 134 | ) 135 | 136 | foreign_model = prop.mapper.class_ 137 | 138 | nullable = True 139 | for pair in prop.local_remote_pairs: 140 | if not pair[0].nullable: 141 | nullable = False 142 | 143 | kwargs.update( 144 | { 145 | "allow_blank": nullable, 146 | "query_factory": lambda: db_session.query(foreign_model).all(), 147 | } 148 | ) 149 | 150 | converter = self.converters[prop.direction.name] 151 | 152 | return converter( 153 | model=model, mapper=mapper, prop=prop, column=column, field_args=kwargs 154 | ) 155 | 156 | 157 | class ModelConverter(ModelConverterBase): 158 | def __init__(self, extra_converters=None, use_mro=True): 159 | super().__init__(extra_converters, use_mro=use_mro) 160 | 161 | @classmethod 162 | def _string_common(cls, column, field_args, **extra): 163 | if isinstance(column.type.length, int) and column.type.length: 164 | field_args["validators"].append(validators.Length(max=column.type.length)) 165 | 166 | @converts("String") # includes Unicode 167 | def conv_String(self, field_args, **extra): 168 | self._string_common(field_args=field_args, **extra) 169 | return wtforms_fields.StringField(**field_args) 170 | 171 | @converts("Text", "LargeBinary", "Binary") # includes UnicodeText 172 | def conv_Text(self, field_args, **extra): 173 | self._string_common(field_args=field_args, **extra) 174 | return wtforms_fields.TextAreaField(**field_args) 175 | 176 | @converts("Boolean", "dialects.mssql.base.BIT") 177 | def conv_Boolean(self, field_args, **extra): 178 | return wtforms_fields.BooleanField(**field_args) 179 | 180 | @converts("Date") 181 | def conv_Date(self, field_args, **extra): 182 | return wtforms_fields.DateField(**field_args) 183 | 184 | @converts("DateTime") 185 | def conv_DateTime(self, field_args, **extra): 186 | return wtforms_fields.DateTimeField(**field_args) 187 | 188 | @converts("Enum") 189 | def conv_Enum(self, column, field_args, **extra): 190 | field_args["choices"] = [(e, e) for e in column.type.enums] 191 | return wtforms_fields.SelectField(**field_args) 192 | 193 | @converts("Integer") # includes BigInteger and SmallInteger 194 | def handle_integer_types(self, column, field_args, **extra): 195 | unsigned = getattr(column.type, "unsigned", False) 196 | if unsigned: 197 | field_args["validators"].append(validators.NumberRange(min=0)) 198 | return wtforms_fields.IntegerField(**field_args) 199 | 200 | @converts("Numeric") # includes DECIMAL, Float/FLOAT, REAL, and DOUBLE 201 | def handle_decimal_types(self, column, field_args, **extra): 202 | # override default decimal places limit, use database defaults instead 203 | field_args.setdefault("places", None) 204 | return wtforms_fields.DecimalField(**field_args) 205 | 206 | @converts("dialects.mysql.types.YEAR", "dialects.mysql.base.YEAR") 207 | def conv_MSYear(self, field_args, **extra): 208 | field_args["validators"].append(validators.NumberRange(min=1901, max=2155)) 209 | return wtforms_fields.StringField(**field_args) 210 | 211 | @converts("dialects.postgresql.types.INET", "dialects.postgresql.base.INET") 212 | def conv_PGInet(self, field_args, **extra): 213 | field_args.setdefault("label", "IP Address") 214 | field_args["validators"].append(validators.IPAddress()) 215 | return wtforms_fields.StringField(**field_args) 216 | 217 | @converts("dialects.postgresql.types.MACADDR", "dialects.postgresql.base.MACADDR") 218 | def conv_PGMacaddr(self, field_args, **extra): 219 | field_args.setdefault("label", "MAC Address") 220 | field_args["validators"].append(validators.MacAddress()) 221 | return wtforms_fields.StringField(**field_args) 222 | 223 | @converts( 224 | "sql.sqltypes.UUID", 225 | "dialects.postgresql.types.UUID", 226 | "dialects.postgresql.base.UUID", 227 | ) 228 | def conv_PGUuid(self, field_args, **extra): 229 | field_args.setdefault("label", "UUID") 230 | field_args["validators"].append(validators.UUID()) 231 | return wtforms_fields.StringField(**field_args) 232 | 233 | @converts("MANYTOONE") 234 | def conv_ManyToOne(self, field_args, **extra): 235 | return QuerySelectField(**field_args) 236 | 237 | @converts("MANYTOMANY", "ONETOMANY") 238 | def conv_ManyToMany(self, field_args, **extra): 239 | return QuerySelectMultipleField(**field_args) 240 | 241 | 242 | def model_fields( 243 | model, 244 | db_session=None, 245 | only=None, 246 | exclude=None, 247 | field_args=None, 248 | converter=None, 249 | exclude_pk=False, 250 | exclude_fk=False, 251 | ): 252 | """Generate a dictionary of fields for a given SQLAlchemy model. 253 | 254 | See `model_form` docstring for description of parameters. 255 | """ 256 | mapper = sainspect(model) 257 | converter = converter or ModelConverter() 258 | field_args = field_args or {} 259 | properties = [] 260 | 261 | for prop in mapper.attrs.values(): 262 | if getattr(prop, "columns", None): 263 | if exclude_fk and prop.columns[0].foreign_keys: 264 | continue 265 | elif exclude_pk and prop.columns[0].primary_key: 266 | continue 267 | 268 | properties.append((prop.key, prop)) 269 | 270 | # ((p.key, p) for p in mapper.iterate_properties) 271 | if only: 272 | properties = (x for x in properties if x[0] in only) 273 | elif exclude: 274 | properties = (x for x in properties if x[0] not in exclude) 275 | 276 | field_dict = {} 277 | for name, prop in properties: 278 | field = converter.convert(model, mapper, prop, field_args.get(name), db_session) 279 | if field is not None: 280 | field_dict[name] = field 281 | 282 | return field_dict 283 | 284 | 285 | def model_form( 286 | model, 287 | db_session=None, 288 | base_class=Form, 289 | only=None, 290 | exclude=None, 291 | field_args=None, 292 | converter=None, 293 | exclude_pk=True, 294 | exclude_fk=True, 295 | type_name=None, 296 | ): 297 | """ 298 | Create a wtforms Form for a given SQLAlchemy model class:: 299 | 300 | from wtforms_sqlalchemy.orm import model_form 301 | from myapp.models import User 302 | UserForm = model_form(User) 303 | 304 | :param model: 305 | A SQLAlchemy mapped model class. 306 | :param db_session: 307 | An optional SQLAlchemy Session. 308 | :param base_class: 309 | Base form class to extend from. Must be a ``wtforms.Form`` subclass. 310 | :param only: 311 | An optional iterable with the property names that should be included in 312 | the form. Only these properties will have fields. 313 | :param exclude: 314 | An optional iterable with the property names that should be excluded 315 | from the form. All other properties will have fields. 316 | :param field_args: 317 | An optional dictionary of field names mapping to keyword arguments used 318 | to construct each field object. 319 | :param converter: 320 | A converter to generate the fields based on the model properties. If 321 | not set, ``ModelConverter`` is used. 322 | :param exclude_pk: 323 | An optional boolean to force primary key exclusion. 324 | :param exclude_fk: 325 | An optional boolean to force foreign keys exclusion. 326 | :param type_name: 327 | An optional string to set returned type name. 328 | """ 329 | if not hasattr(model, "_sa_class_manager"): 330 | raise TypeError("model must be a sqlalchemy mapped model") 331 | 332 | type_name = type_name or str(model.__name__ + "Form") 333 | field_dict = model_fields( 334 | model, 335 | db_session, 336 | only, 337 | exclude, 338 | field_args, 339 | converter, 340 | exclude_pk=exclude_pk, 341 | exclude_fk=exclude_fk, 342 | ) 343 | return type(type_name, (base_class,), field_dict) 344 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pallets-eco/wtforms-sqlalchemy/1512116491b55d4c225c6d1dcd9f567f4eabb93a/tests/__init__.py -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | from wtforms.validators import StopValidation 4 | from wtforms.validators import ValidationError 5 | 6 | 7 | class DummyTranslations: 8 | def gettext(self, string): 9 | return string 10 | 11 | def ngettext(self, singular, plural, n): 12 | if n == 1: 13 | return singular 14 | 15 | return plural 16 | 17 | 18 | class DummyField: 19 | _translations = DummyTranslations() 20 | 21 | def __init__(self, data, errors=(), raw_data=None): 22 | self.data = data 23 | self.errors = list(errors) 24 | self.raw_data = raw_data 25 | 26 | def gettext(self, string): 27 | return self._translations.gettext(string) 28 | 29 | def ngettext(self, singular, plural, n): 30 | return self._translations.ngettext(singular, plural, n) 31 | 32 | 33 | def grab_error_message(callable, form, field): 34 | try: 35 | callable(form, field) 36 | except ValidationError as e: 37 | return e.args[0] 38 | 39 | 40 | def grab_stop_message(callable, form, field): 41 | try: 42 | callable(form, field) 43 | except StopValidation as e: 44 | return e.args[0] 45 | 46 | 47 | def contains_validator(field, v_type): 48 | for v in field.validators: 49 | if isinstance(v, v_type): 50 | return True 51 | return False 52 | 53 | 54 | class DummyPostData(dict): 55 | def getlist(self, key): 56 | v = self[key] 57 | if not isinstance(v, (list, tuple)): 58 | v = [v] 59 | return v 60 | 61 | 62 | @contextmanager 63 | def assert_raises_text(e_type, text): 64 | import re 65 | 66 | try: 67 | yield 68 | except e_type as e: 69 | if not re.match(text, e.args[0]): 70 | raise AssertionError( 71 | f"Exception raised: {e!r} but text {e.args[0]!r} " 72 | f"did not match pattern {text!r}" 73 | ) from e 74 | else: 75 | raise AssertionError(f"Expected Exception {e_type!r}, did not get it") 76 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from sqlalchemy import create_engine 4 | from sqlalchemy import ForeignKey 5 | from sqlalchemy import types as sqla_types 6 | from sqlalchemy.dialects.mssql import BIT 7 | from sqlalchemy.dialects.mysql import YEAR 8 | from sqlalchemy.dialects.postgresql import INET 9 | from sqlalchemy.dialects.postgresql import MACADDR 10 | from sqlalchemy.dialects.postgresql import UUID 11 | from sqlalchemy.orm import backref 12 | from sqlalchemy.orm import declarative_base 13 | from sqlalchemy.orm import registry 14 | from sqlalchemy.orm import relationship 15 | from sqlalchemy.orm import sessionmaker 16 | from sqlalchemy.schema import Column 17 | from sqlalchemy.schema import ColumnDefault 18 | from sqlalchemy.schema import Table 19 | from wtforms import fields 20 | from wtforms import Form 21 | from wtforms.validators import InputRequired 22 | from wtforms.validators import Optional 23 | from wtforms.validators import Regexp 24 | 25 | from wtforms_sqlalchemy.fields import QuerySelectField 26 | from wtforms_sqlalchemy.fields import QuerySelectMultipleField 27 | from wtforms_sqlalchemy.orm import model_form 28 | from wtforms_sqlalchemy.orm import ModelConversionError 29 | from wtforms_sqlalchemy.orm import ModelConverter 30 | 31 | from .common import contains_validator 32 | from .common import DummyPostData 33 | 34 | 35 | class LazySelect: 36 | def __call__(self, field, **kwargs): 37 | return list( 38 | (val, str(label), selected, render_kw) 39 | for val, label, selected, render_kw in field.iter_choices() 40 | ) 41 | 42 | 43 | class Base: 44 | def __init__(self, **kwargs): 45 | for k, v in iter(kwargs.items()): 46 | setattr(self, k, v) 47 | 48 | 49 | class AnotherInteger(sqla_types.Integer): 50 | """Use me to test if MRO works like we want.""" 51 | 52 | 53 | class TestBase(TestCase): 54 | def _do_tables(self, mapper, engine): 55 | mapper_registry = registry() 56 | 57 | test_table = Table( 58 | "test", 59 | mapper_registry.metadata, 60 | Column("id", sqla_types.Integer, primary_key=True, nullable=False), 61 | Column("name", sqla_types.String, nullable=False), 62 | ) 63 | 64 | pk_test_table = Table( 65 | "pk_test", 66 | mapper_registry.metadata, 67 | Column("foobar", sqla_types.String, primary_key=True, nullable=False), 68 | Column("baz", sqla_types.String, nullable=False), 69 | ) 70 | 71 | Test = type("Test", (Base,), {}) 72 | PKTest = type( 73 | "PKTest", 74 | (Base,), 75 | {"__unicode__": lambda x: x.baz, "__str__": lambda x: x.baz}, 76 | ) 77 | 78 | mapper_registry.map_imperatively(Test, test_table) 79 | mapper_registry.map_imperatively(PKTest, pk_test_table) 80 | self.Test = Test 81 | self.PKTest = PKTest 82 | 83 | mapper_registry.metadata.create_all(bind=engine) 84 | 85 | def _fill(self, sess): 86 | for i, n in [(1, "apple"), (2, "banana")]: 87 | s = self.Test(id=i, name=n) 88 | p = self.PKTest(foobar=f"hello{i}", baz=n) 89 | sess.add(s) 90 | sess.add(p) 91 | sess.flush() 92 | sess.commit() 93 | 94 | 95 | class QuerySelectFieldTest(TestBase): 96 | def setUp(self): 97 | self.engine = create_engine("sqlite:///:memory:", echo=False) 98 | self.Session = sessionmaker(bind=self.engine) 99 | from sqlalchemy.orm import mapper 100 | 101 | self._do_tables(mapper, self.engine) 102 | 103 | def tearDown(self): 104 | self.engine.dispose() 105 | 106 | def test_without_factory(self): 107 | sess = self.Session() 108 | self._fill(sess) 109 | 110 | class F(Form): 111 | a = QuerySelectField( 112 | get_label="name", widget=LazySelect(), get_pk=lambda x: x.id 113 | ) 114 | 115 | form = F(DummyPostData(a=["1"])) 116 | form.a.query = sess.query(self.Test) 117 | self.assertTrue(form.a.data is not None) 118 | self.assertEqual(form.a.data.id, 1) 119 | self.assertEqual( 120 | form.a(), [("1", "apple", True, {}), ("2", "banana", False, {})] 121 | ) 122 | self.assertTrue(form.validate()) 123 | 124 | form = F(a=sess.query(self.Test).filter_by(name="banana").first()) 125 | form.a.query = sess.query(self.Test).filter(self.Test.name != "banana") 126 | assert not form.validate() 127 | self.assertEqual(form.a.errors, ["Not a valid choice"]) 128 | 129 | # Test query with no results 130 | form = F() 131 | form.a.query = ( 132 | sess.query(self.Test).filter(self.Test.id == 1, self.Test.id != 1).all() 133 | ) 134 | self.assertEqual(form.a(), []) 135 | 136 | def test_with_query_factory(self): 137 | sess = self.Session() 138 | self._fill(sess) 139 | 140 | class F(Form): 141 | a = QuerySelectField( 142 | get_label=(lambda model: model.name), 143 | query_factory=lambda: sess.query(self.Test), 144 | widget=LazySelect(), 145 | ) 146 | b = QuerySelectField( 147 | allow_blank=True, 148 | query_factory=lambda: sess.query(self.PKTest), 149 | widget=LazySelect(), 150 | ) 151 | c = QuerySelectField( 152 | allow_blank=True, 153 | blank_text="", 154 | blank_value="", 155 | query_factory=lambda: sess.query(self.PKTest), 156 | widget=LazySelect(), 157 | ) 158 | 159 | form = F() 160 | self.assertEqual(form.a.data, None) 161 | self.assertEqual( 162 | form.a(), [("1", "apple", False, {}), ("2", "banana", False, {})] 163 | ) 164 | self.assertEqual(form.b.data, None) 165 | self.assertEqual( 166 | form.b(), 167 | [ 168 | ("__None", "", True, {}), 169 | ("hello1", "apple", False, {}), 170 | ("hello2", "banana", False, {}), 171 | ], 172 | ) 173 | self.assertEqual(form.c.data, None) 174 | self.assertEqual( 175 | form.c(), 176 | [ 177 | ("", "", True, {}), 178 | ("hello1", "apple", False, {}), 179 | ("hello2", "banana", False, {}), 180 | ], 181 | ) 182 | self.assertFalse(form.validate()) 183 | 184 | form = F(DummyPostData(a=["1"], b=["hello2"], c=[""])) 185 | self.assertEqual(form.a.data.id, 1) 186 | self.assertEqual( 187 | form.a(), [("1", "apple", True, {}), ("2", "banana", False, {})] 188 | ) 189 | self.assertEqual(form.b.data.baz, "banana") 190 | self.assertEqual( 191 | form.b(), 192 | [ 193 | ("__None", "", False, {}), 194 | ("hello1", "apple", False, {}), 195 | ("hello2", "banana", True, {}), 196 | ], 197 | ) 198 | self.assertEqual(form.c.data, None) 199 | self.assertEqual( 200 | form.c(), 201 | [ 202 | ("", "", True, {}), 203 | ("hello1", "apple", False, {}), 204 | ("hello2", "banana", False, {}), 205 | ], 206 | ) 207 | self.assertTrue(form.validate()) 208 | 209 | # Make sure the query is cached 210 | sess.add(self.Test(id=3, name="meh")) 211 | sess.flush() 212 | sess.commit() 213 | self.assertEqual( 214 | form.a(), [("1", "apple", True, {}), ("2", "banana", False, {})] 215 | ) 216 | form.a._object_list = None 217 | self.assertEqual( 218 | form.a(), 219 | [ 220 | ("1", "apple", True, {}), 221 | ("2", "banana", False, {}), 222 | ("3", "meh", False, {}), 223 | ], 224 | ) 225 | 226 | # Test bad data 227 | form = F(DummyPostData(b=["__None"], a=["fail"])) 228 | assert not form.validate() 229 | self.assertEqual(form.a.errors, ["Not a valid choice"]) 230 | self.assertEqual(form.b.errors, []) 231 | self.assertEqual(form.b.data, None) 232 | 233 | # Test query with no results 234 | form = F() 235 | form.a.query = ( 236 | sess.query(self.Test).filter(self.Test.id == 1, self.Test.id != 1).all() 237 | ) 238 | self.assertEqual(form.a(), []) 239 | 240 | 241 | class QuerySelectMultipleFieldTest(TestBase): 242 | def setUp(self): 243 | from sqlalchemy.orm import mapper 244 | 245 | self.engine = create_engine("sqlite:///:memory:", echo=False) 246 | Session = sessionmaker(bind=self.engine) 247 | self._do_tables(mapper, self.engine) 248 | self.sess = Session() 249 | self._fill(self.sess) 250 | 251 | def tearDown(self): 252 | self.sess.close() 253 | self.engine.dispose() 254 | 255 | class F(Form): 256 | a = QuerySelectMultipleField(get_label="name", widget=LazySelect()) 257 | 258 | def test_unpopulated_default(self): 259 | form = self.F() 260 | self.assertEqual([], form.a.data) 261 | 262 | def test_single_value_without_factory(self): 263 | form = self.F(DummyPostData(a=["1"])) 264 | form.a.query = self.sess.query(self.Test) 265 | self.assertEqual([1], [v.id for v in form.a.data]) 266 | self.assertEqual( 267 | form.a(), [("1", "apple", True, {}), ("2", "banana", False, {})] 268 | ) 269 | self.assertTrue(form.validate()) 270 | 271 | def test_multiple_values_without_query_factory(self): 272 | form = self.F(DummyPostData(a=["1", "2"])) 273 | form.a.query = self.sess.query(self.Test) 274 | self.assertEqual([1, 2], [v.id for v in form.a.data]) 275 | self.assertEqual( 276 | form.a(), [("1", "apple", True, {}), ("2", "banana", True, {})] 277 | ) 278 | self.assertTrue(form.validate()) 279 | 280 | form = self.F(DummyPostData(a=["1", "3"])) 281 | form.a.query = self.sess.query(self.Test) 282 | self.assertEqual([x.id for x in form.a.data], [1]) 283 | self.assertFalse(form.validate()) 284 | 285 | def test_single_default_value(self): 286 | first_test = self.sess.get(self.Test, 2) 287 | 288 | class F(Form): 289 | a = QuerySelectMultipleField( 290 | get_label="name", 291 | default=[first_test], 292 | widget=LazySelect(), 293 | query_factory=lambda: self.sess.query(self.Test), 294 | ) 295 | 296 | form = F() 297 | self.assertEqual([v.id for v in form.a.data], [2]) 298 | self.assertEqual( 299 | form.a(), [("1", "apple", False, {}), ("2", "banana", True, {})] 300 | ) 301 | self.assertTrue(form.validate()) 302 | 303 | 304 | class ModelFormTest(TestCase): 305 | def setUp(self): 306 | Model = declarative_base() 307 | 308 | student_course = Table( 309 | "student_course", 310 | Model.metadata, 311 | Column("student_id", sqla_types.Integer, ForeignKey("student.id")), 312 | Column("course_id", sqla_types.Integer, ForeignKey("course.id")), 313 | ) 314 | 315 | class Course(Model): 316 | __tablename__ = "course" 317 | id = Column(sqla_types.Integer, primary_key=True) 318 | name = Column(sqla_types.String(255), nullable=False) 319 | # These are for better model form testing 320 | cost = Column(sqla_types.Numeric(5, 2), nullable=False) 321 | description = Column(sqla_types.Text, nullable=False) 322 | level = Column(sqla_types.Enum("Primary", "Secondary")) 323 | has_prereqs = Column(sqla_types.Boolean, nullable=False) 324 | boolean_nullable = Column(sqla_types.Boolean, nullable=True) 325 | started = Column(sqla_types.DateTime, nullable=False) 326 | grade = Column(AnotherInteger, nullable=False) 327 | 328 | class School(Model): 329 | __tablename__ = "school" 330 | id = Column(sqla_types.Integer, primary_key=True) 331 | name = Column(sqla_types.String(255), nullable=False) 332 | 333 | class Student(Model): 334 | __tablename__ = "student" 335 | id = Column(sqla_types.Integer, primary_key=True) 336 | full_name = Column(sqla_types.String(255), nullable=False, unique=True) 337 | dob = Column(sqla_types.Date(), nullable=True) 338 | current_school_id = Column( 339 | sqla_types.Integer, ForeignKey(School.id), nullable=False 340 | ) 341 | 342 | current_school = relationship(School, backref=backref("students")) 343 | courses = relationship( 344 | "Course", 345 | secondary=student_course, 346 | backref=backref("students", lazy="dynamic"), 347 | ) 348 | 349 | self.School = School 350 | self.Student = Student 351 | self.Course = Course 352 | 353 | self.engine = create_engine("sqlite:///:memory:", echo=False) 354 | Session = sessionmaker(bind=self.engine) 355 | self.metadata = Model.metadata 356 | self.metadata.create_all(bind=self.engine) 357 | self.sess = Session() 358 | 359 | def tearDown(self): 360 | self.sess.close() 361 | self.engine.dispose() 362 | 363 | def test_auto_validators(self): 364 | course_form = model_form(self.Course, self.sess)() 365 | student_form = model_form(self.Student, self.sess)() 366 | assert contains_validator(student_form.dob, Optional) 367 | assert contains_validator(student_form.full_name, InputRequired) 368 | assert not contains_validator(course_form.has_prereqs, Optional) 369 | assert contains_validator(course_form.has_prereqs, InputRequired) 370 | assert contains_validator(course_form.boolean_nullable, Optional) 371 | assert not contains_validator(course_form.boolean_nullable, InputRequired) 372 | 373 | def test_field_args(self): 374 | shared = {"full_name": {"validators": [Regexp("test")]}} 375 | student_form = model_form(self.Student, self.sess, field_args=shared)() 376 | assert contains_validator(student_form.full_name, Regexp) 377 | 378 | # original shared field_args should not be modified 379 | assert len(shared["full_name"]["validators"]) == 1 380 | 381 | def test_include_pk(self): 382 | form_class = model_form(self.Student, self.sess, exclude_pk=False) 383 | student_form = form_class() 384 | assert "id" in student_form._fields 385 | 386 | def test_exclude_pk(self): 387 | form_class = model_form(self.Student, self.sess, exclude_pk=True) 388 | student_form = form_class() 389 | assert "id" not in student_form._fields 390 | 391 | def test_exclude_fk(self): 392 | student_form = model_form(self.Student, self.sess)() 393 | assert "current_school_id" not in student_form._fields 394 | 395 | def test_include_fk(self): 396 | student_form = model_form(self.Student, self.sess, exclude_fk=False)() 397 | assert "current_school_id" in student_form._fields 398 | 399 | def test_convert_many_to_one(self): 400 | student_form = model_form(self.Student, self.sess)() 401 | assert isinstance(student_form.current_school, QuerySelectField) 402 | 403 | def test_convert_one_to_many(self): 404 | school_form = model_form(self.School, self.sess)() 405 | assert isinstance(school_form.students, QuerySelectMultipleField) 406 | 407 | def test_convert_many_to_many(self): 408 | student_form = model_form(self.Student, self.sess)() 409 | assert isinstance(student_form.courses, QuerySelectMultipleField) 410 | 411 | def test_convert_basic(self): 412 | self.assertRaises(TypeError, model_form, None) 413 | self.assertRaises(ModelConversionError, model_form, self.Course) 414 | form_class = model_form(self.Course, exclude=["students"]) 415 | form = form_class() 416 | self.assertEqual(len(list(form)), 8) 417 | 418 | def test_only(self): 419 | desired_fields = ["id", "cost", "description"] 420 | form = model_form(self.Course, only=desired_fields)() 421 | self.assertEqual(len(list(form)), 2) 422 | form = model_form(self.Course, only=desired_fields, exclude_pk=False)() 423 | self.assertEqual(len(list(form)), 3) 424 | 425 | def test_no_mro(self): 426 | converter = ModelConverter(use_mro=False) 427 | # Without MRO, will not be able to convert 'grade' 428 | self.assertRaises( 429 | ModelConversionError, 430 | model_form, 431 | self.Course, 432 | self.sess, 433 | converter=converter, 434 | ) 435 | # If we exclude 'grade' everything should continue working 436 | F = model_form(self.Course, self.sess, exclude=["grade"], converter=converter) 437 | self.assertEqual(len(list(F())), 8) 438 | 439 | 440 | class ModelFormColumnDefaultTest(TestCase): 441 | def setUp(self): 442 | Model = declarative_base() 443 | 444 | def default_score(): 445 | return 5 446 | 447 | class StudentDefaultScoreCallable(Model): 448 | __tablename__ = "course" 449 | id = Column(sqla_types.Integer, primary_key=True) 450 | name = Column(sqla_types.String(255), nullable=False) 451 | score = Column(sqla_types.Integer, default=default_score, nullable=False) 452 | 453 | class StudentDefaultScoreScalar(Model): 454 | __tablename__ = "school" 455 | id = Column(sqla_types.Integer, primary_key=True) 456 | name = Column(sqla_types.String(255), nullable=False) 457 | # Default scalar value 458 | score = Column(sqla_types.Integer, default=10, nullable=False) 459 | 460 | self.StudentDefaultScoreCallable = StudentDefaultScoreCallable 461 | self.StudentDefaultScoreScalar = StudentDefaultScoreScalar 462 | 463 | self.engine = create_engine("sqlite:///:memory:", echo=False) 464 | Session = sessionmaker(bind=self.engine) 465 | self.metadata = Model.metadata 466 | self.metadata.create_all(bind=self.engine) 467 | self.sess = Session() 468 | 469 | def tearDown(self): 470 | self.sess.close() 471 | self.engine.dispose() 472 | 473 | def test_column_default_callable(self): 474 | student_form = model_form(self.StudentDefaultScoreCallable, self.sess)() 475 | self.assertEqual(student_form._fields["score"].default, 5) 476 | 477 | def test_column_default_scalar(self): 478 | student_form = model_form(self.StudentDefaultScoreScalar, self.sess)() 479 | assert not isinstance(student_form._fields["score"].default, ColumnDefault) 480 | self.assertEqual(student_form._fields["score"].default, 10) 481 | 482 | 483 | class ModelFormTest2(TestCase): 484 | def setUp(self): 485 | Model = declarative_base() 486 | 487 | class AllTypesModel(Model): 488 | __tablename__ = "course" 489 | id = Column(sqla_types.Integer, primary_key=True) 490 | string = Column(sqla_types.String) 491 | unicode = Column(sqla_types.Unicode) 492 | varchar = Column(sqla_types.VARCHAR) 493 | integer = Column(sqla_types.Integer) 494 | biginteger = Column(sqla_types.BigInteger) 495 | smallinteger = Column(sqla_types.SmallInteger) 496 | numeric = Column(sqla_types.Numeric) 497 | float = Column(sqla_types.Float) 498 | text = Column(sqla_types.Text) 499 | largebinary = Column(sqla_types.LargeBinary) 500 | unicodetext = Column(sqla_types.UnicodeText) 501 | enum = Column(sqla_types.Enum("Primary", "Secondary")) 502 | boolean = Column(sqla_types.Boolean) 503 | datetime = Column(sqla_types.DateTime) 504 | timestamp = Column(sqla_types.TIMESTAMP) 505 | date = Column(sqla_types.Date) 506 | postgres_inet = Column(INET) 507 | postgres_macaddr = Column(MACADDR) 508 | postgres_uuid = Column(UUID) 509 | mysql_year = Column(YEAR) 510 | mssql_bit = Column(BIT) 511 | 512 | self.AllTypesModel = AllTypesModel 513 | 514 | def test_convert_types(self): 515 | form = model_form(self.AllTypesModel)() 516 | 517 | assert isinstance(form.string, fields.StringField) 518 | assert isinstance(form.unicode, fields.StringField) 519 | assert isinstance(form.varchar, fields.StringField) 520 | assert isinstance(form.postgres_inet, fields.StringField) 521 | assert isinstance(form.postgres_macaddr, fields.StringField) 522 | assert isinstance(form.postgres_uuid, fields.StringField) 523 | assert isinstance(form.mysql_year, fields.StringField) 524 | 525 | assert isinstance(form.integer, fields.IntegerField) 526 | assert isinstance(form.biginteger, fields.IntegerField) 527 | assert isinstance(form.smallinteger, fields.IntegerField) 528 | 529 | assert isinstance(form.numeric, fields.DecimalField) 530 | assert isinstance(form.float, fields.DecimalField) 531 | 532 | assert isinstance(form.text, fields.TextAreaField) 533 | assert isinstance(form.largebinary, fields.TextAreaField) 534 | assert isinstance(form.unicodetext, fields.TextAreaField) 535 | 536 | assert isinstance(form.enum, fields.SelectField) 537 | 538 | assert isinstance(form.boolean, fields.BooleanField) 539 | assert isinstance(form.mssql_bit, fields.BooleanField) 540 | 541 | assert isinstance(form.datetime, fields.DateTimeField) 542 | assert isinstance(form.timestamp, fields.DateTimeField) 543 | 544 | assert isinstance(form.date, fields.DateField) 545 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py3{13,12,11,10,9},pypy3{10,9} 4 | style 5 | docs 6 | 7 | [testenv] 8 | deps = 9 | -e . 10 | pytest 11 | commands = pytest {posargs} 12 | 13 | [testenv:style] 14 | deps = pre-commit 15 | skip_install = true 16 | commands = pre-commit run --all-files --show-diff-on-failure 17 | 18 | [testenv:docs] 19 | deps = -r docs/requirements.txt 20 | commands = sphinx-build -W -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html 21 | 22 | [testenv:coverage] 23 | deps = 24 | -e . 25 | pytest 26 | coverage 27 | commands = 28 | coverage run -m pytest --tb=short --basetemp={envtmpdir} {posargs} 29 | coverage html 30 | coverage report 31 | --------------------------------------------------------------------------------