├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .pre-commit-config.yaml ├── .pylintrc ├── .readthedocs.yaml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── api.rst ├── autogen │ ├── api.rst │ ├── deletion.rst │ ├── forms.rst │ ├── managers.rst │ ├── modelview.rst │ ├── multitenancy.rst │ ├── paginator.rst │ ├── queryset_transform.rst │ ├── quick.rst │ ├── templatetags.rst │ └── utils.rst ├── conf.py ├── index.rst ├── installation.rst ├── modelviews.rst ├── nature │ ├── static │ │ ├── nature.css_t │ │ └── pygments.css │ └── theme.conf ├── search_and_filter.rst └── templatetags.rst ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── manage.py └── testapp │ ├── __init__.py │ ├── models.py │ ├── resources.py │ ├── settings.py │ ├── templates │ ├── 404.html │ ├── base.html │ ├── resources │ │ ├── object_action.html │ │ ├── object_delete_confirmation.html │ │ ├── object_detail.html │ │ ├── object_form.html │ │ ├── object_list.html │ │ └── object_picker.html │ └── testapp │ │ ├── emailaddress_list.html │ │ └── person_list.html │ ├── templatetags │ ├── __init__.py │ └── testapp_tags.py │ ├── test_deletion.py │ ├── test_forms.py │ ├── test_modelview.py │ ├── test_mt.py │ ├── test_quick.py │ ├── test_resources.py │ ├── test_utils.py │ ├── urls.py │ └── views.py ├── towel ├── __init__.py ├── auth.py ├── deletion.py ├── django-admin.sh ├── forms.py ├── incubator │ ├── __init__.py │ ├── frankenresource.py │ └── modelview.py ├── locale │ └── de │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── managers.py ├── models.py ├── modelview.py ├── mt │ ├── __init__.py │ ├── api.py │ ├── auth.py │ ├── forms.py │ ├── middleware.py │ ├── models.py │ └── modelview.py ├── paginator.py ├── queryset_transform.py ├── quick.py ├── resources │ ├── __init__.py │ ├── base.py │ ├── inlines.py │ ├── mt.py │ └── urls.py ├── static │ └── towel │ │ ├── towel.js │ │ └── towel_editlive.js ├── templates │ ├── modelview │ │ ├── object_delete_confirmation.html │ │ ├── object_detail.html │ │ ├── object_form.html │ │ └── object_list.html │ ├── resources │ │ ├── object_delete_confirmation.html │ │ ├── object_detail.html │ │ ├── object_form.html │ │ └── object_list.html │ └── towel │ │ ├── _form_errors.html │ │ ├── _form_item.html │ │ ├── _form_item_plain.html │ │ ├── _form_warnings.html │ │ ├── _ordering_link.html │ │ └── _pagination.html ├── templatetags │ ├── __init__.py │ ├── modelview_detail.py │ ├── modelview_list.py │ ├── towel_batch_tags.py │ ├── towel_form_tags.py │ ├── towel_region.py │ ├── towel_resources.py │ └── verbose_name_tags.py └── utils.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.py] 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@babel/eslint-parser", 3 | env: { 4 | browser: true, 5 | es6: true, 6 | node: true, 7 | }, 8 | extends: ["eslint:recommended", "prettier"], 9 | globals: { 10 | $: true, 11 | }, 12 | parserOptions: { 13 | ecmaFeatures: { 14 | experimentalObjectRestSpread: true, 15 | jsx: true, 16 | }, 17 | ecmaVersion: 2021, 18 | requireConfigFile: false, 19 | sourceType: "module", 20 | }, 21 | rules: { 22 | "no-unused-vars": [ 23 | "error", 24 | { 25 | argsIgnorePattern: "^_", 26 | varsIgnorePattern: "^_", 27 | }, 28 | ], 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | .*.sw? 4 | \#*# 5 | /docs/_build 6 | /django 7 | /example 8 | /dist 9 | /build 10 | /towel.egg-info 11 | /MANIFEST 12 | /tests/venv 13 | /tests/.tox 14 | .tox 15 | .coverage 16 | htmlcov 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ".yarn/|yarn.lock" 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.2.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-merge-conflict 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | - repo: local 11 | hooks: 12 | - id: django-check 13 | name: django check 14 | entry: sh -c 'stat venv/bin/python && venv/bin/python manage.py check || echo "Skipped"' 15 | pass_filenames: false 16 | language: system 17 | always_run: true 18 | - repo: https://github.com/asottile/pyupgrade 19 | rev: v2.32.1 20 | hooks: 21 | - id: pyupgrade 22 | args: [--py38-plus] 23 | - repo: https://github.com/adamchainz/django-upgrade 24 | rev: 1.7.0 25 | hooks: 26 | - id: django-upgrade 27 | args: [--target-version, "3.2"] 28 | - repo: https://github.com/pycqa/isort 29 | rev: 5.10.1 30 | hooks: 31 | - id: isort 32 | args: [--profile=black, --lines-after-imports=2, --combine-as] 33 | - repo: https://github.com/psf/black 34 | rev: 22.3.0 35 | hooks: 36 | - id: black 37 | - repo: https://github.com/pycqa/flake8 38 | rev: 4.0.1 39 | hooks: 40 | - id: flake8 41 | args: ["--ignore=E203,E501,W503"] 42 | - repo: https://github.com/pre-commit/mirrors-prettier 43 | rev: v2.6.2 44 | hooks: 45 | - id: prettier 46 | args: [--list-different, --no-semi] 47 | exclude: "^conf/|.*\\.html$" 48 | - repo: https://github.com/pre-commit/mirrors-eslint 49 | rev: v8.15.0 50 | hooks: 51 | - id: eslint 52 | args: [--fix] 53 | verbose: true 54 | additional_dependencies: 55 | - eslint@8.15.0 56 | - eslint-config-prettier@^8.5.0 57 | - "@babel/core" 58 | - "@babel/eslint-parser" 59 | - "@babel/preset-env" 60 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=tests,migrations 3 | 4 | [TYPECHECK] 5 | generated-members=objects,DoesNotExist,id,pk,_meta,base_fields,context,fields,is_valid,cleaned_data 6 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-24.04 8 | tools: 9 | python: "3.11" 10 | 11 | sphinx: 12 | configuration: docs/conf.py 13 | 14 | # python: 15 | # install: 16 | # - requirements: docs/requirements.txt 17 | # - method: pip 18 | # path: . 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010, FEINHEIT GmbH and individual contributors. 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 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of FEINHEIT GmbH nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written 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 LICENSE 2 | include MANIFEST.in 3 | include README.rst 4 | recursive-include towel/static * 5 | recursive-include towel/locale * 6 | recursive-include towel/templates * 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | Towel - Keeping you DRY since 2010 3 | ================================== 4 | 5 | .. image:: https://travis-ci.org/matthiask/towel.png?branch=master 6 | :target: https://travis-ci.org/matthiask/towel 7 | 8 | Towel is a collection of tools which make your life easier if you 9 | are building a web application using Django. It contains helpers and 10 | templates for creating paginated, searchable object lists, CRUD 11 | functionality helping you safely and easily create and update objects, 12 | and using Django's own proofed machinery to see what happens when 13 | you want to safely delete objects. 14 | 15 | * Towel on github: https://github.com/matthiask/towel/ 16 | * Documentation: http://www.feinheit.ch/media/labs/towel/ 17 | -------------------------------------------------------------------------------- /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 | 9 | # Internal variables. 10 | PAPEROPT_a4 = -D latex_paper_size=a4 11 | PAPEROPT_letter = -D latex_paper_size=letter 12 | ALLSPHINXOPTS = -d _build/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 13 | 14 | .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest 15 | 16 | help: 17 | @echo "Please use \`make ' where is one of" 18 | @echo " html to make standalone HTML files" 19 | @echo " dirhtml to make HTML files named index.html in directories" 20 | @echo " pickle to make pickle files" 21 | @echo " json to make JSON files" 22 | @echo " htmlhelp to make HTML files and a HTML help project" 23 | @echo " qthelp to make HTML files and a qthelp project" 24 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 25 | @echo " changes to make an overview of all changed/added/deprecated items" 26 | @echo " linkcheck to check all external links for integrity" 27 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 28 | 29 | clean: 30 | -rm -rf _build/* 31 | 32 | html: 33 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) _build/html 34 | @echo 35 | @echo "Build finished. The HTML pages are in _build/html." 36 | 37 | dirhtml: 38 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) _build/dirhtml 39 | @echo 40 | @echo "Build finished. The HTML pages are in _build/dirhtml." 41 | 42 | pickle: 43 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) _build/pickle 44 | @echo 45 | @echo "Build finished; now you can process the pickle files." 46 | 47 | json: 48 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) _build/json 49 | @echo 50 | @echo "Build finished; now you can process the JSON files." 51 | 52 | htmlhelp: 53 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) _build/htmlhelp 54 | @echo 55 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 56 | ".hhp project file in _build/htmlhelp." 57 | 58 | qthelp: 59 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) _build/qthelp 60 | @echo 61 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 62 | ".qhcp project file in _build/qthelp, like this:" 63 | @echo "# qcollectiongenerator _build/qthelp/Plata.qhcp" 64 | @echo "To view the help file:" 65 | @echo "# assistant -collectionFile _build/qthelp/Plata.qhc" 66 | 67 | latex: 68 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) _build/latex 69 | @echo 70 | @echo "Build finished; the LaTeX files are in _build/latex." 71 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 72 | "run these through (pdf)latex." 73 | 74 | changes: 75 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) _build/changes 76 | @echo 77 | @echo "The overview file is in _build/changes." 78 | 79 | linkcheck: 80 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) _build/linkcheck 81 | @echo 82 | @echo "Link check complete; look for any errors in the above output " \ 83 | "or in _build/linkcheck/output.txt." 84 | 85 | doctest: 86 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) _build/doctest 87 | @echo "Testing of doctests in the sources finished, look at the " \ 88 | "results in _build/doctest/output.txt." 89 | -------------------------------------------------------------------------------- /docs/autogen/api.rst: -------------------------------------------------------------------------------- 1 | .. _autogen-api: 2 | 3 | API programming 4 | =============== 5 | 6 | .. automodule:: towel.api 7 | :members: 8 | :noindex: 9 | -------------------------------------------------------------------------------- /docs/autogen/deletion.rst: -------------------------------------------------------------------------------- 1 | .. _autogen-deletion: 2 | 3 | Deletion 4 | ======== 5 | 6 | .. automodule:: towel.deletion 7 | :members: 8 | :noindex: 9 | -------------------------------------------------------------------------------- /docs/autogen/forms.rst: -------------------------------------------------------------------------------- 1 | Forms 2 | ===== 3 | 4 | .. automodule:: towel.forms 5 | :members: 6 | :noindex: 7 | -------------------------------------------------------------------------------- /docs/autogen/managers.rst: -------------------------------------------------------------------------------- 1 | Managers 2 | ======== 3 | 4 | .. automodule:: towel.managers 5 | :members: 6 | :noindex: 7 | -------------------------------------------------------------------------------- /docs/autogen/modelview.rst: -------------------------------------------------------------------------------- 1 | ModelView 2 | ========= 3 | 4 | .. automodule:: towel.modelview 5 | :members: 6 | :noindex: 7 | -------------------------------------------------------------------------------- /docs/autogen/multitenancy.rst: -------------------------------------------------------------------------------- 1 | Multitenancy 2 | ============ 3 | 4 | .. automodule:: towel.mt 5 | :members: 6 | :noindex: 7 | 8 | .. automodule:: towel.mt.api 9 | :members: 10 | :noindex: 11 | 12 | .. automodule:: towel.mt.auth 13 | :members: 14 | :noindex: 15 | 16 | .. automodule:: towel.mt.forms 17 | :members: 18 | :noindex: 19 | 20 | .. automodule:: towel.mt.middleware 21 | :members: 22 | :noindex: 23 | 24 | .. automodule:: towel.mt.models 25 | :members: 26 | :noindex: 27 | 28 | .. automodule:: towel.mt.modelview 29 | :members: 30 | :noindex: 31 | -------------------------------------------------------------------------------- /docs/autogen/paginator.rst: -------------------------------------------------------------------------------- 1 | Paginator 2 | ========= 3 | 4 | .. automodule:: towel.paginator 5 | :members: 6 | :noindex: 7 | -------------------------------------------------------------------------------- /docs/autogen/queryset_transform.rst: -------------------------------------------------------------------------------- 1 | Queryset transform 2 | ================== 3 | 4 | .. automodule:: towel.queryset_transform 5 | :members: 6 | :noindex: 7 | -------------------------------------------------------------------------------- /docs/autogen/quick.rst: -------------------------------------------------------------------------------- 1 | Quick 2 | ===== 3 | 4 | .. automodule:: towel.quick 5 | :members: 6 | :noindex: 7 | -------------------------------------------------------------------------------- /docs/autogen/templatetags.rst: -------------------------------------------------------------------------------- 1 | Template tags 2 | ============= 3 | 4 | ``ModelView`` template tags 5 | --------------------------- 6 | 7 | .. automodule:: towel.templatetags.modelview_detail 8 | :members: 9 | :noindex: 10 | 11 | .. automodule:: towel.templatetags.modelview_list 12 | :members: 13 | :noindex: 14 | 15 | 16 | Batch form template tags 17 | ------------------------ 18 | 19 | .. automodule:: towel.templatetags.towel_batch_tags 20 | :members: 21 | :noindex: 22 | 23 | 24 | Generally helpful form tags 25 | --------------------------- 26 | 27 | .. automodule:: towel.templatetags.towel_form_tags 28 | :members: 29 | :noindex: 30 | 31 | 32 | Template tags for pulling out the ``verbose_name(_plural)?`` from almost any object 33 | ----------------------------------------------------------------------------------- 34 | 35 | .. automodule:: towel.templatetags.verbose_name_tags 36 | :members: 37 | :noindex: 38 | -------------------------------------------------------------------------------- /docs/autogen/utils.rst: -------------------------------------------------------------------------------- 1 | Utils 2 | ===== 3 | 4 | .. automodule:: towel.utils 5 | :members: 6 | :noindex: 7 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # Plata documentation build configuration file 3 | # 4 | # This file is execfile()d with the current directory set to its containing 5 | # dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | 14 | import os 15 | import sys 16 | 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | sys.path.append(os.path.abspath(".")) 22 | sys.path.append(os.path.abspath("..")) 23 | os.environ["DJANGO_SETTINGS_MODULE"] = "conf" 24 | 25 | # -- Make Django shut up 26 | SECRET_KEY = "YAY" 27 | 28 | # -- General configuration ---------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 32 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.intersphinx"] 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ["_templates"] 36 | 37 | # The suffix of source filenames. 38 | source_suffix = ".rst" 39 | 40 | # The encoding of source files. 41 | # source_encoding = 'utf-8' 42 | 43 | # The master toctree document. 44 | master_doc = "index" 45 | 46 | # General information about the project. 47 | project = "Towel" 48 | copyright = "2010-2012, Feinheit GmbH and contributors" 49 | 50 | # The version info for the project you're documenting, acts as replacement for 51 | # |version| and |release|, also used in various other places throughout the 52 | # built documents. 53 | # 54 | # The short X.Y version. 55 | sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) 56 | import towel # noqa 57 | 58 | 59 | version = ".".join(map(str, towel.VERSION)) 60 | # The full version, including alpha/beta/rc tags. 61 | release = towel.__version__ 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # language = None 66 | 67 | # There are two options for replacing |today|: either, you set today to some 68 | # non-false value, then it is used: 69 | # today = '' 70 | # Else, today_fmt is used as the format for a strftime call. 71 | # today_fmt = '%B %d, %Y' 72 | 73 | # List of documents that shouldn't be included in the build. 74 | # unused_docs = [] 75 | 76 | # List of directories, relative to source directory, that shouldn't be searched 77 | # for source files. 78 | exclude_trees = ["_build"] 79 | 80 | # The reST default role (used for this markup: `text`) to use for all 81 | # documents. 82 | # default_role = None 83 | 84 | # If true, '()' will be appended to :func: etc. cross-reference text. 85 | add_function_parentheses = True 86 | 87 | # If true, the current module name will be prepended to all description 88 | # unit titles (such as .. function::). 89 | # add_module_names = True 90 | 91 | # If true, sectionauthor and moduleauthor directives will be shown in the 92 | # output. They are ignored by default. 93 | # show_authors = False 94 | 95 | # The name of the Pygments (syntax highlighting) style to use. 96 | pygments_style = "sphinx" 97 | 98 | # A list of ignored prefixes for module index sorting. 99 | # modindex_common_prefix = [] 100 | 101 | 102 | # -- Options for HTML output -------------------------------------------------- 103 | 104 | # The theme to use for HTML and HTML Help pages. Major themes that come with 105 | # Sphinx are currently 'default' and 'sphinxdoc'. 106 | html_theme_path = ["_theme"] 107 | html_theme = "nature" 108 | 109 | # Theme options are theme-specific and customize the look and feel of a theme 110 | # further. For a list of options available for each theme, see the 111 | # documentation. 112 | # html_theme_options = {} 113 | 114 | # Add any paths that contain custom themes here, relative to this directory. 115 | # html_theme_path = [] 116 | 117 | # The name for this set of Sphinx documents. If None, it defaults to 118 | # " v documentation". 119 | # html_title = None 120 | 121 | # A shorter title for the navigation bar. Default is the same as html_title. 122 | # html_short_title = None 123 | 124 | # The name of an image file (relative to this directory) to place at the top 125 | # of the sidebar. 126 | # html_logo = None 127 | 128 | # The name of an image file (within the static path) to use as favicon of the 129 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 130 | # pixels large. 131 | # html_favicon = None 132 | 133 | # Add any paths that contain custom static files (such as style sheets) here, 134 | # relative to this directory. They are copied after the builtin static files, 135 | # so a file named "default.css" will overwrite the builtin "default.css". 136 | html_static_path = ["_static"] 137 | 138 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 139 | # using the given strftime format. 140 | # html_last_updated_fmt = '%b %d, %Y' 141 | 142 | # If true, SmartyPants will be used to convert quotes and dashes to 143 | # typographically correct entities. 144 | # html_use_smartypants = True 145 | 146 | # Custom sidebar templates, maps document names to template names. 147 | # html_sidebars = {} 148 | 149 | # Additional templates that should be rendered to pages, maps page names to 150 | # template names. 151 | # html_additional_pages = {} 152 | 153 | # If false, no module index is generated. 154 | # html_use_modindex = True 155 | 156 | # If false, no index is generated. 157 | # html_use_index = True 158 | 159 | # If true, the index is split into individual pages for each letter. 160 | # html_split_index = False 161 | 162 | # If true, links to the reST sources are added to the pages. 163 | # html_show_sourcelink = True 164 | 165 | # If true, an OpenSearch description file will be output, and all pages will 166 | # contain a tag referring to it. The value of this option must be the 167 | # base URL from which the finished HTML is served. 168 | # html_use_opensearch = '' 169 | 170 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 171 | # html_file_suffix = '' 172 | 173 | # Output file base name for HTML help builder. 174 | htmlhelp_basename = "Toweldoc" 175 | 176 | 177 | # -- Options for LaTeX output ------------------------------------------------- 178 | 179 | # The paper size ('letter' or 'a4'). 180 | latex_paper_size = "a4" 181 | 182 | # The font size ('10pt', '11pt' or '12pt'). 183 | latex_font_size = "10pt" 184 | 185 | # Grouping the document tree into LaTeX files. List of tuples (source start 186 | # file, target name, title, author, documentclass [howto/manual]). 187 | latex_documents = [ 188 | ( 189 | "index", 190 | "Towel.tex", 191 | "Towel Documentation", 192 | "Feinheit GmbH and contributors", 193 | "manual", 194 | ), 195 | ] 196 | 197 | # The name of an image file (relative to this directory) to place at the top of 198 | # the title page. 199 | # latex_logo = None 200 | 201 | # For "manual" documents, if this is true, then toplevel headings are parts, 202 | # not chapters. 203 | # latex_use_parts = False 204 | 205 | # Additional stuff for the LaTeX preamble. 206 | # latex_preamble = '' 207 | 208 | # Documents to append as an appendix to all manuals. 209 | # latex_appendices = [] 210 | 211 | # If false, no module index is generated. 212 | # latex_use_modindex = True 213 | 214 | 215 | intersphinx_mapping = { 216 | "http://docs.python.org/": None, 217 | "django": ( 218 | "http://docs.djangoproject.com/en/dev/", 219 | "http://docs.djangoproject.com/en/dev/_objects/", 220 | ), 221 | } 222 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. _index: 2 | 3 | ================================== 4 | Towel - Keeping you DRY since 2010 5 | ================================== 6 | 7 | Towel is a collection of tools which make your life easier if you 8 | are building a web application using Django. It contains helpers and 9 | templates for creating paginated, searchable object lists, CRUD 10 | functionality helping you safely and easily create and update objects, 11 | and using Django's own proofed machinery to see what happens when 12 | you want to safely delete objects. 13 | 14 | 15 | Contents 16 | ======== 17 | 18 | .. toctree:: 19 | :maxdepth: 2 20 | 21 | installation 22 | api 23 | modelviews 24 | search_and_filter 25 | templatetags 26 | 27 | 28 | Autogenerated API Documentation 29 | =============================== 30 | 31 | .. toctree:: 32 | :maxdepth: 2 33 | 34 | autogen/api 35 | autogen/deletion 36 | autogen/forms 37 | autogen/managers 38 | autogen/modelview 39 | autogen/multitenancy 40 | autogen/paginator 41 | autogen/queryset_transform 42 | autogen/quick 43 | autogen/templatetags 44 | autogen/utils 45 | 46 | 47 | Indices and tables 48 | ================== 49 | 50 | * :ref:`genindex` 51 | * :ref:`modindex` 52 | * :ref:`search` 53 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | ========================= 4 | Installation instructions 5 | ========================= 6 | 7 | This document describes the steps needed to get Towel up and running. 8 | 9 | Towel is based on Django_, so you need a working Django_ installation 10 | first. Towel is mainly developed using the newest release of Django_, but 11 | should work with Django_ 1.4 up to the upcoming 1.7 and with Python_ 2.7 12 | and 3.3. Towel does not currently support Python_ 3.2 but patches adding 13 | support are welcome. 14 | 15 | Towel can be installed using the following command:: 16 | 17 | $ pip install Towel 18 | 19 | Towel has no dependencies apart from Django_. 20 | 21 | You should add ``towel`` to ``INSTALLED_APPS`` if you want to use 22 | the bundled templates and template tags. This isn't strictly 23 | required though. 24 | 25 | .. _Django: http://www.djangoproject.com/ 26 | .. _Python: http://www.python.org/ 27 | -------------------------------------------------------------------------------- /docs/nature/static/nature.css_t: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphinx stylesheet -- default theme 3 | * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | */ 5 | 6 | @import url("basic.css"); 7 | 8 | /* -- page layout ----------------------------------------------------------- */ 9 | 10 | body { 11 | font-family: Arial, sans-serif; 12 | font-size: 100%; 13 | background-color: #111; 14 | color: #555; 15 | margin: 0; 16 | padding: 0; 17 | } 18 | 19 | div.documentwrapper { 20 | float: left; 21 | width: 100%; 22 | } 23 | 24 | div.bodywrapper { 25 | margin: 0 0 0 230px; 26 | } 27 | 28 | hr{ 29 | border: 1px solid #B1B4B6; 30 | } 31 | 32 | div.document { 33 | background-color: #eee; 34 | } 35 | 36 | div.body { 37 | background-color: #ffffff; 38 | color: #3E4349; 39 | padding: 0 30px 30px 30px; 40 | font-size: 0.8em; 41 | } 42 | 43 | div.footer { 44 | color: #555; 45 | width: 100%; 46 | padding: 13px 0; 47 | text-align: center; 48 | font-size: 75%; 49 | } 50 | 51 | div.footer a { 52 | color: #444; 53 | text-decoration: underline; 54 | } 55 | 56 | div.related { 57 | background-color: #6BA81E; 58 | line-height: 32px; 59 | color: #fff; 60 | text-shadow: 0px 1px 0 #444; 61 | font-size: 0.80em; 62 | } 63 | 64 | div.related a { 65 | color: #E2F3CC; 66 | } 67 | 68 | div.sphinxsidebar { 69 | font-size: 0.75em; 70 | line-height: 1.5em; 71 | } 72 | 73 | div.sphinxsidebarwrapper{ 74 | padding: 20px 0; 75 | } 76 | 77 | div.sphinxsidebar h3, 78 | div.sphinxsidebar h4 { 79 | font-family: Arial, sans-serif; 80 | color: #222; 81 | font-size: 1.2em; 82 | font-weight: normal; 83 | margin: 0; 84 | padding: 5px 10px; 85 | background-color: #ddd; 86 | text-shadow: 1px 1px 0 white 87 | } 88 | 89 | div.sphinxsidebar h4{ 90 | font-size: 1.1em; 91 | } 92 | 93 | div.sphinxsidebar h3 a { 94 | color: #444; 95 | } 96 | 97 | 98 | div.sphinxsidebar p { 99 | color: #888; 100 | padding: 5px 20px; 101 | } 102 | 103 | div.sphinxsidebar p.topless { 104 | } 105 | 106 | div.sphinxsidebar ul { 107 | margin: 10px 20px; 108 | padding: 0; 109 | color: #000; 110 | } 111 | 112 | div.sphinxsidebar a { 113 | color: #444; 114 | } 115 | 116 | div.sphinxsidebar input { 117 | border: 1px solid #ccc; 118 | font-family: sans-serif; 119 | font-size: 1em; 120 | } 121 | 122 | div.sphinxsidebar input[type=text]{ 123 | margin-left: 20px; 124 | } 125 | 126 | /* -- body styles ----------------------------------------------------------- */ 127 | 128 | a { 129 | color: #005B81; 130 | text-decoration: none; 131 | } 132 | 133 | a:hover { 134 | color: #E32E00; 135 | text-decoration: underline; 136 | } 137 | 138 | div.body h1, 139 | div.body h2, 140 | div.body h3, 141 | div.body h4, 142 | div.body h5, 143 | div.body h6 { 144 | font-family: Arial, sans-serif; 145 | background-color: #BED4EB; 146 | font-weight: normal; 147 | color: #212224; 148 | margin: 30px 0px 10px 0px; 149 | padding: 5px 0 5px 10px; 150 | text-shadow: 0px 1px 0 white 151 | } 152 | 153 | div.body h1 { border-top: 20px solid white; margin-top: 0; font-size: 200%; } 154 | div.body h2 { font-size: 150%; background-color: #C8D5E3; } 155 | div.body h3 { font-size: 120%; background-color: #D8DEE3; } 156 | div.body h4 { font-size: 110%; background-color: #D8DEE3; } 157 | div.body h5 { font-size: 100%; background-color: #D8DEE3; } 158 | div.body h6 { font-size: 100%; background-color: #D8DEE3; } 159 | 160 | a.headerlink { 161 | color: #c60f0f; 162 | font-size: 0.8em; 163 | padding: 0 4px 0 4px; 164 | text-decoration: none; 165 | } 166 | 167 | a.headerlink:hover { 168 | background-color: #c60f0f; 169 | color: white; 170 | } 171 | 172 | div.body p, div.body dd, div.body li { 173 | line-height: 1.5em; 174 | } 175 | 176 | div.admonition p.admonition-title + p { 177 | display: inline; 178 | } 179 | 180 | div.highlight{ 181 | background-color: white; 182 | } 183 | 184 | div.note { 185 | background-color: #eee; 186 | border: 1px solid #ccc; 187 | } 188 | 189 | div.seealso { 190 | background-color: #ffc; 191 | border: 1px solid #ff6; 192 | } 193 | 194 | div.topic { 195 | background-color: #eee; 196 | } 197 | 198 | div.warning { 199 | background-color: #ffe4e4; 200 | border: 1px solid #f66; 201 | } 202 | 203 | p.admonition-title { 204 | display: inline; 205 | } 206 | 207 | p.admonition-title:after { 208 | content: ":"; 209 | } 210 | 211 | pre { 212 | padding: 10px; 213 | background-color: White; 214 | color: #222; 215 | line-height: 1.2em; 216 | border: 1px solid #C6C9CB; 217 | font-size: 1.2em; 218 | margin: 1.5em 0 1.5em 0; 219 | -webkit-box-shadow: 1px 1px 1px #d8d8d8; 220 | -moz-box-shadow: 1px 1px 1px #d8d8d8; 221 | } 222 | 223 | tt { 224 | background-color: #ecf0f3; 225 | color: #222; 226 | padding: 1px 2px; 227 | font-size: 1.2em; 228 | font-family: monospace; 229 | } 230 | -------------------------------------------------------------------------------- /docs/nature/static/pygments.css: -------------------------------------------------------------------------------- 1 | .c { 2 | color: #999988; 3 | font-style: italic; 4 | } /* Comment */ 5 | .k { 6 | font-weight: bold; 7 | } /* Keyword */ 8 | .o { 9 | font-weight: bold; 10 | } /* Operator */ 11 | .cm { 12 | color: #999988; 13 | font-style: italic; 14 | } /* Comment.Multiline */ 15 | .cp { 16 | color: #999999; 17 | font-weight: bold; 18 | } /* Comment.preproc */ 19 | .c1 { 20 | color: #999988; 21 | font-style: italic; 22 | } /* Comment.Single */ 23 | .gd { 24 | color: #000000; 25 | background-color: #ffdddd; 26 | } /* Generic.Deleted */ 27 | .ge { 28 | font-style: italic; 29 | } /* Generic.Emph */ 30 | .gr { 31 | color: #aa0000; 32 | } /* Generic.Error */ 33 | .gh { 34 | color: #999999; 35 | } /* Generic.Heading */ 36 | .gi { 37 | color: #000000; 38 | background-color: #ddffdd; 39 | } /* Generic.Inserted */ 40 | .go { 41 | color: #111; 42 | } /* Generic.Output */ 43 | .gp { 44 | color: #555555; 45 | } /* Generic.Prompt */ 46 | .gs { 47 | font-weight: bold; 48 | } /* Generic.Strong */ 49 | .gu { 50 | color: #aaaaaa; 51 | } /* Generic.Subheading */ 52 | .gt { 53 | color: #aa0000; 54 | } /* Generic.Traceback */ 55 | .kc { 56 | font-weight: bold; 57 | } /* Keyword.Constant */ 58 | .kd { 59 | font-weight: bold; 60 | } /* Keyword.Declaration */ 61 | .kp { 62 | font-weight: bold; 63 | } /* Keyword.Pseudo */ 64 | .kr { 65 | font-weight: bold; 66 | } /* Keyword.Reserved */ 67 | .kt { 68 | color: #445588; 69 | font-weight: bold; 70 | } /* Keyword.Type */ 71 | .m { 72 | color: #009999; 73 | } /* Literal.Number */ 74 | .s { 75 | color: #bb8844; 76 | } /* Literal.String */ 77 | .na { 78 | color: #008080; 79 | } /* Name.Attribute */ 80 | .nb { 81 | color: #999999; 82 | } /* Name.Builtin */ 83 | .nc { 84 | color: #445588; 85 | font-weight: bold; 86 | } /* Name.Class */ 87 | .no { 88 | color: #ff99ff; 89 | } /* Name.Constant */ 90 | .ni { 91 | color: #800080; 92 | } /* Name.Entity */ 93 | .ne { 94 | color: #990000; 95 | font-weight: bold; 96 | } /* Name.Exception */ 97 | .nf { 98 | color: #990000; 99 | font-weight: bold; 100 | } /* Name.Function */ 101 | .nn { 102 | color: #555555; 103 | } /* Name.Namespace */ 104 | .nt { 105 | color: #000080; 106 | } /* Name.Tag */ 107 | .nv { 108 | color: purple; 109 | } /* Name.Variable */ 110 | .ow { 111 | font-weight: bold; 112 | } /* Operator.Word */ 113 | .mf { 114 | color: #009999; 115 | } /* Literal.Number.Float */ 116 | .mh { 117 | color: #009999; 118 | } /* Literal.Number.Hex */ 119 | .mi { 120 | color: #009999; 121 | } /* Literal.Number.Integer */ 122 | .mo { 123 | color: #009999; 124 | } /* Literal.Number.Oct */ 125 | .sb { 126 | color: #bb8844; 127 | } /* Literal.String.Backtick */ 128 | .sc { 129 | color: #bb8844; 130 | } /* Literal.String.Char */ 131 | .sd { 132 | color: #bb8844; 133 | } /* Literal.String.Doc */ 134 | .s2 { 135 | color: #bb8844; 136 | } /* Literal.String.Double */ 137 | .se { 138 | color: #bb8844; 139 | } /* Literal.String.Escape */ 140 | .sh { 141 | color: #bb8844; 142 | } /* Literal.String.Heredoc */ 143 | .si { 144 | color: #bb8844; 145 | } /* Literal.String.Interpol */ 146 | .sx { 147 | color: #bb8844; 148 | } /* Literal.String.Other */ 149 | .sr { 150 | color: #808000; 151 | } /* Literal.String.Regex */ 152 | .s1 { 153 | color: #bb8844; 154 | } /* Literal.String.Single */ 155 | .ss { 156 | color: #bb8844; 157 | } /* Literal.String.Symbol */ 158 | .bp { 159 | color: #999999; 160 | } /* Name.Builtin.Pseudo */ 161 | .vc { 162 | color: #ff99ff; 163 | } /* Name.Variable.Class */ 164 | .vg { 165 | color: #ff99ff; 166 | } /* Name.Variable.Global */ 167 | .vi { 168 | color: #ff99ff; 169 | } /* Name.Variable.Instance */ 170 | .il { 171 | color: #009999; 172 | } /* Literal.Number.Integer.Long */ 173 | -------------------------------------------------------------------------------- /docs/nature/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = nature.css 4 | pygments_style = tango 5 | -------------------------------------------------------------------------------- /docs/search_and_filter.rst: -------------------------------------------------------------------------------- 1 | .. _search_and_filter: 2 | 3 | ================= 4 | Search and Filter 5 | ================= 6 | 7 | Towel does not distinguish between searching and filtering. 8 | There are different layers of filtering applied during a request and depending 9 | on your need you have to hook in your filter at the right place. 10 | 11 | 12 | Making lists searchable using the search form 13 | ============================================= 14 | 15 | Pagination is not enough for many use cases, we need more! Luckily, Towel 16 | has a pre-made solution for searching object lists too. 17 | 18 | :py:class:`towel.forms.SearchForm` can be used together with 19 | :py:class:`towel.managers.SearchManager` to build a low-cost implementation of 20 | full text search and filtering by model attributes. 21 | 22 | The method used to implement full text search is a bit stupid and cannot 23 | replace mature full text search solutions such as Apache Solr. It might just 24 | solve 80% of the problems with 20% of the effort though. 25 | 26 | Code talks. First, we extend our models definition with a 27 | :py:class:`~django.db.models.Manager` subclass with a simple search 28 | implementation:: 29 | 30 | from django.db import models 31 | from towel.managers import SearchManager 32 | 33 | class BookManager(SearchManager): 34 | search_fields = ('title', 'topic', 'authors__name', 35 | 'publisher__name', 'publisher__address') 36 | 37 | class Book(models.Model): 38 | # [...] 39 | 40 | objects = BookManager() 41 | 42 | :py:class:`~towel.managers.SearchManager` supports queries with multiple clauses; 43 | terms may be grouped using apostrophes, plus and minus signs may be optionally 44 | prepended to the terms to determine whether the given term should be included 45 | or not. Example:: 46 | 47 | +Django "Shop software" -Satchmo 48 | 49 | Please note that you can search fields from other models too. You should 50 | be careful when traversing many-to-many or reverse foreign key relations 51 | however, because you will get duplicated results if you do not call 52 | :py:meth:`~django.db.models.query.QuerySet.distinct` on the resulting queryset. 53 | 54 | The method :py:meth:`~towel.managers.SearchManager._search` does the heavy 55 | lifting when constructing a queryset. You should not need to override this 56 | method. If you want to customize the results further, f.e. apply a site-wide 57 | limit for the objects a certain logged in user may see, you should override 58 | :py:meth:`~towel.managers.SearchManager.search`. 59 | 60 | Next, we have to create a :class:`~towel.forms.SearchForm` subclass:: 61 | 62 | from django import forms 63 | from towel import forms as towel_forms 64 | from myapp.models import Author, Book, Publisher 65 | 66 | class BookSearchForm(towel_forms.SearchForm): 67 | publisher = forms.ModelChoiceField(Publisher.objects.all(), required=False) 68 | authors = forms.ModelMultipleChoiceField(Author.objects.all(), required=False) 69 | published_on__lte = forms.DateField(required=False) 70 | published_on__gte = forms.DateField(required=False) 71 | 72 | formfield_callback = towel_forms.towel_formfield_callback 73 | 74 | 75 | You have to add ``required=False`` to every field if you do not want validation 76 | errors on the first visit to the form (which would not make a lot of sense, but 77 | isn't actively harmful). 78 | 79 | As long as you only use search form fields whose names correspond to the keywords 80 | used in Django's ``.filter()`` calls or ``Q()`` objects you do not have to do 81 | anything else. 82 | 83 | The ``formfield_callback`` simply substitutes a few fields with whitespace-stripping 84 | equivalents, and adds CSS classes to ``DateInput`` and ``DateTimeInput`` so that 85 | they can be easily augmented by javascript code. 86 | 87 | .. warning:: 88 | 89 | If you want to be able to filter by multiple items, i.e. publishers 1 and 2, 90 | you have to define the ``publisher`` field in the ``SearchForm`` as 91 | :class:`~django.forms.ModelMultipleChoiceField`. Even if the model itself only 92 | has a simple ForeignKey Field. Otherwise only the last element of a series 93 | is used for filtering. 94 | 95 | To activate a search form, all you have to do is add an additional parameter 96 | when you instantiate a ModelView subclass:: 97 | 98 | from myapp.forms import BookSearchForm 99 | from myapp.models import Book 100 | from towel.modelview import ModelView 101 | 102 | urlpatterns = patterns('', 103 | url(r'^books/', include(ModelView(Book, 104 | search_form=BookSearchForm, 105 | paginate_by=20, 106 | ).urls)), 107 | ) 108 | 109 | 110 | You can now filter the list by providing the search keys as GET parameters:: 111 | 112 | localhost:8000/books/?author=2 113 | localhost:8000/books/?publisher=4&o=authors 114 | localhost:8000/books/?authors=4&authors=5&authors=6 115 | 116 | 117 | Advanced SearchForm features 118 | ---------------------------- 119 | 120 | The :class:`~towel.forms.SearchForm` has a ``post_init`` method, 121 | which receives the request and is useful if you have to further modify 122 | the queryset i.e. depending on the current user:: 123 | 124 | def post_init(self, request): 125 | self.access = getattr(request.user, 'access', None) 126 | self.fields['publisher'].queryset = Publisher.objects.for_user(request.user) 127 | 128 | 129 | The ordering is also defined in the :class:`~towel.forms.SearchForm`. 130 | You have to specify a dict called ``orderings`` which has the ordering key 131 | as first parameter. The second parameter can be a field name, an iterable of 132 | field names or a callable. The ordering keys are what is used in the URL:: 133 | 134 | class AddressSearchForm(SearchForm): 135 | orderings = { 136 | '': ('last_name', 'first_name'), # Default 137 | 'dob': 'dob', # Sort by date of birth 138 | 'random': lambda queryset: queryset.order_by('?'), 139 | } 140 | 141 | 142 | Persistent queries 143 | ================== 144 | 145 | When you pass the parameter ``s``, the search is stored in the session for 146 | that path. If the user returns to the object list, the filtering is applied again. 147 | 148 | The field is included in the SearchForm by default, but don't forget to 149 | add it to your template if you are using a custom form render method. 150 | 151 | To reset the filters, you have to pass ``?clear=1`` or ``?n``. 152 | 153 | Quick Rules 154 | =========== 155 | 156 | Another option for filtering are :doc:`Quick rules `. 157 | This allows for field-independent filtering like ``is:cool``. 158 | Quick rules are mapped to filter attributes using regular expressions. 159 | They go into the search form and are parsed automatically 160 | (as long as ``query_data`` is used inside the ``queryset`` method:: 161 | 162 | class BookSearchForm(towel_forms.SearchForm): 163 | quick_rules = [ 164 | (re.compile(r'has:publisher'), quick.static(publisher__isnull=False)), 165 | (re.compile(r'is:published'), quick.static(published_on__lt=timezone.now)), 166 | ] 167 | -------------------------------------------------------------------------------- /docs/templatetags.rst: -------------------------------------------------------------------------------- 1 | .. _templatetags: 2 | 3 | ============= 4 | Template tags 5 | ============= 6 | 7 | 8 | ModelView detail tags 9 | ===================== 10 | 11 | .. module:: towel.templatetags.modelview_detail 12 | 13 | .. function:: model_details 14 | 15 | Yields a list of ``(verbose_name, value)`` tuples for all local model 16 | fields:: 17 | 18 | {% load modelview_detail %} 19 | 20 | 21 | {% for title, value in object|model_details %} 22 | 23 | 24 | 26 | {% endfor %} 27 |
{{ title }}{{ value }} 25 |
28 | 29 | 30 | ModelView list tags 31 | =================== 32 | 33 | .. module:: towel.templatetags.modelview_list 34 | 35 | .. function:: model_row 36 | 37 | Requires a list of fields which should be shown in columns on a list page. 38 | The fields may also be callables. ForeignKey fields are automatically 39 | converted into links:: 40 | 41 | {% load modelview_list %} 42 | 43 | 44 | {% for object in object_list %} 45 | 46 | {% for title, value in object|model_row:"__unicode__,author %} 47 | 48 | {% endfor %} 49 | 50 | {% endfor %} 51 |
{{ value }}
52 | 53 | 54 | .. function:: pagination 55 | 56 | Uses ``towel/_pagination.html`` to display a nicely formatted pagination 57 | section. An additional parameter may be provided if the pagination should 58 | behave differently depending on where it is shown; it is passed to 59 | ``towel/_pagination.html`` as ``where``:: 60 | 61 | {% load modelview_list %} 62 | 63 | {% if paginator %}{% pagination page paginator "top" %}{% endif %} 64 | 65 | {# list / table code ... #} 66 | 67 | {% if paginator %}{% pagination page paginator "bottom" %}{% endif %} 68 | 69 | 70 | As long as ``paginate_by`` is set on the ModelView, a paginator object is 71 | always provided. The ``{% if paginator %}`` is used because you cannot 72 | be sure that pagination is used at all in a generic list template. 73 | 74 | This template tag needs the ``django.core.context_processors.request`` 75 | context processor. 76 | 77 | 78 | .. function:: querystring 79 | 80 | URL-encodes the passed ``dict`` in a format suitable for pagination. ``page`` 81 | and ``all`` are excluded by default:: 82 | 83 | {% load modelview_list %} 84 | 85 | Back to first page 86 | 87 | {# equivalent, but longer: #} 88 | Back to first page 89 | 90 | 91 | .. function:: ordering_link 92 | 93 | Shows a table column header suitable for use as a link to change the 94 | ordering of objects in a list:: 95 | 96 | {% ordering_link "" request title=_("Edition") %} {# default order #} 97 | {% ordering_link "customer" request title=_("Customer") %} 98 | {% ordering_link "state" request title=_("State") %} 99 | 100 | Required arguments are the field and the request. It is very much 101 | recommended to add a title too of course. 102 | 103 | ``ordering_link`` has an optional argument, ``base_url`` which is 104 | useful if you need to customize the link part before the question 105 | mark. The default behavior is to only add the query string, and nothing 106 | else to the ``href`` attribute. 107 | 108 | It is possible to specify a set of CSS classes too. The CSS classes 109 | ``'asc'`` and ``'desc'`` are added automatically by the code depending 110 | upon the ordering which would be selected if the ordering link were 111 | clicked (NOT the current ordering):: 112 | 113 | {% ordering_link "state" request title=_("State") classes="btn" %} 114 | 115 | The ``classes`` argument defaults to ``'ordering'``. 116 | 117 | 118 | Batch tags 119 | ========== 120 | 121 | .. module:: towel.templatetags.towel_batch_tags 122 | 123 | .. function:: batch_checkbox 124 | 125 | Returns the checkbox for batch processing:: 126 | 127 | {% load towel_batch_tags %} 128 | 129 | {% for object in object_list %} 130 | {# ... #} 131 | {% batch_checkbox batch_form object.id %} 132 | {# ... #} 133 | {% endfor %} 134 | 135 | 136 | Form tags 137 | ========= 138 | 139 | .. module:: towel.templatetags.towel_form_tags 140 | 141 | .. function:: form_items 142 | 143 | Returns the concatenated result of running ``{% form_item field %}`` on every 144 | form field. 145 | 146 | 147 | .. function:: form_item 148 | 149 | Uses ``towel/_form_item.html`` to render a form field. The default template 150 | renders a table row, and includes: 151 | 152 | * ``help_text`` after the form field in a ``p.help`` 153 | * ``invalid`` and ``required`` classes on the row 154 | 155 | 156 | .. function:: form_item_plain 157 | 158 | Uses ``towel/_form_item_plain.html`` to render a form field, f.e. inside a 159 | table cell. The default template puts the form field inside a ```` tag 160 | with various classes depending on the state of the form field such as 161 | ``invalid`` and ``required``. 162 | 163 | 164 | .. function:: form_errors 165 | 166 | Shows form and formset errors using ``towel/_form_errors.html``. You can 167 | pass a list of forms, formsets, lists containing forms and formsets and 168 | dicts containing forms and formsets as values. 169 | 170 | Variables which do not exist are silently ignored:: 171 | 172 | {% load towel_form_tags %} 173 | 174 | {% form_errors publisher_form books_formset %} 175 | 176 | 177 | .. function:: form_warnings 178 | 179 | Shows form and formset warnings using ``towel/_form_warnings.html``. You can 180 | pass a list of forms, formsets, lists containing forms and formsets and 181 | dicts containing forms and formsets as values. Also shows a checkbox which 182 | can be used to ignore warnings. This template tag does not work with 183 | Django's standard forms because they have do not have support for warnings. 184 | Use :py:class:`~towel.forms.WarningsForm` instead. 185 | 186 | Variables which do not exist are silently ignored:: 187 | 188 | {% load towel_form_tags %} 189 | 190 | {% form_warnings publisher_form books_formset %} 191 | 192 | 193 | .. function:: dynamic_formset 194 | 195 | This is a very convenient block tag which can be used to build dynamic 196 | formsets, which means formsets where new forms can be added with 197 | javascript (jQuery):: 198 | 199 | {% load towel_form_tags %} 200 | 201 | 202 | 203 | 204 | 205 |
{% csrf_token %} 206 | {% form_errors form formset %} 207 | 208 | 209 | {% for field in form %}{% form_item field %}{% endfor %} 210 |
211 | 212 |

Formset

213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | {% dynamic_formset formset "formset-prefix" %} 222 | 223 | 227 | 228 | 229 | 230 | {% enddynamic_formset %} 231 | 232 |
Field 1Field 2
224 | {{ form.id }} 225 | {% form_item_plain form.field1 %} 226 | {% form_item_plain form.field2 %}{{ form.DELETE }}
233 | 234 | 236 | 237 | 238 |
239 | 240 | The formset-prefix must correspond to the prefix used when initializing 241 | the FormSet in your Python code. You should pass ``extra=0`` when creating 242 | the FormSet class; any additional forms are better created using 243 | ``towel_add_subform``. 244 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=1.4 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | branch = True 3 | include = 4 | *towel* 5 | omit = 6 | *migrations* 7 | *tests* 8 | *.tox* 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | 5 | from setuptools import find_packages, setup 6 | 7 | 8 | def read(filename): 9 | return open(os.path.join(os.path.dirname(__file__), filename)).read() 10 | 11 | 12 | setup( 13 | name="towel", 14 | version=__import__("towel").__version__, 15 | description="Keeping you DRY since 2010", 16 | long_description=read("README.rst"), 17 | author="Matthias Kestenholz", 18 | author_email="mk@feinheit.ch", 19 | url="http://github.com/matthiask/towel/", 20 | license="BSD License", 21 | platforms=["OS Independent"], 22 | packages=find_packages(), 23 | package_data={ 24 | "": ["*.html", "*.txt"], 25 | "towel": [ 26 | "locale/*/*/*.*", 27 | "static/towel/*.*", 28 | "static/towel/*/*.*", 29 | "templates/*.*", 30 | "templates/*/*.*", 31 | "templates/*/*/*.*", 32 | "templates/*/*/*/*.*", 33 | ], 34 | }, 35 | install_requires=["Django>=3.2"], 36 | classifiers=[ 37 | "Development Status :: 5 - Production/Stable", 38 | "Environment :: Web Environment", 39 | "Framework :: Django", 40 | "Intended Audience :: Developers", 41 | "License :: OSI Approved :: BSD License", 42 | "Operating System :: OS Independent", 43 | "Programming Language :: Python", 44 | "Programming Language :: Python :: 3", 45 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 46 | "Topic :: Software Development", 47 | "Topic :: Software Development :: Libraries :: Application Frameworks", 48 | ], 49 | zip_safe=False, 50 | ) 51 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | from os.path import abspath, dirname 5 | 6 | 7 | if __name__ == "__main__": 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") 9 | 10 | sys.path.insert(0, dirname(dirname(abspath(__file__)))) 11 | 12 | from django.core.management import execute_from_command_line 13 | 14 | execute_from_command_line(sys.argv) 15 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/towel/4a8b7deda66b30072c38564381b60a5ec8e7ae87/tests/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.timezone import now 3 | 4 | from towel import deletion 5 | from towel.managers import SearchManager 6 | from towel.modelview import ModelViewURLs 7 | from towel.resources.urls import model_resource_urls 8 | 9 | 10 | class Group(models.Model): 11 | name = models.CharField(max_length=100) 12 | 13 | 14 | class PersonManager(SearchManager): 15 | search_fields = ("family_name", "given_name") 16 | 17 | 18 | class Person(models.Model): 19 | RELATIONSHIP_CHOICES = ( 20 | ("", "unspecified"), 21 | ("single", "single"), 22 | ("relation", "in a relationship"), 23 | ("married", "married"), 24 | ("divorced", "divorced"), 25 | ) 26 | 27 | created = models.DateTimeField(default=now) 28 | is_active = models.BooleanField(default=True) 29 | family_name = models.CharField(max_length=100) 30 | given_name = models.CharField(max_length=100) 31 | relationship = models.CharField( 32 | max_length=20, blank=True, choices=RELATIONSHIP_CHOICES 33 | ) 34 | groups = models.ManyToManyField(Group, related_name="members") 35 | 36 | objects = PersonManager() 37 | urls = ModelViewURLs(lambda obj: {"pk": obj.pk}) 38 | 39 | class Meta: 40 | ordering = ["family_name", "given_name"] 41 | 42 | def __str__(self): 43 | return f"{self.given_name} {self.family_name}" 44 | 45 | def get_absolute_url(self): 46 | return self.urls["detail"] 47 | 48 | 49 | class EmailManager(SearchManager): 50 | search_fields = ("person__family_name", "person__given_name", "email") 51 | 52 | 53 | class EmailAddress(deletion.Model): 54 | person = models.ForeignKey(Person, on_delete=models.CASCADE) 55 | email = models.EmailField() 56 | 57 | objects = EmailManager() 58 | urls = ModelViewURLs(lambda obj: {"pk": obj.pk}) 59 | 60 | class Meta: 61 | ordering = ["email"] 62 | verbose_name = "email address" 63 | verbose_name_plural = "email addresses" 64 | 65 | def __str__(self): 66 | return self.email 67 | 68 | def get_absolute_url(self): 69 | return self.urls["detail"] 70 | 71 | 72 | class Message(models.Model): 73 | """ 74 | This model is used to test the behavior of 75 | ``save_formset_deletion_allowed_if_only``. The presence of message 76 | instances should protect email addresses from getting deleted. 77 | """ 78 | 79 | sent_to = models.ForeignKey(EmailAddress, on_delete=models.CASCADE) 80 | message = models.TextField() 81 | 82 | # No get_absolute_url method on purpose; is automatically added by 83 | # ModelView 84 | 85 | class Meta: 86 | ordering = ["id"] 87 | 88 | 89 | class ResourceManager(SearchManager): 90 | search_fields = ("name",) 91 | 92 | 93 | @model_resource_urls() 94 | class Resource(models.Model): 95 | name = models.CharField(max_length=100) 96 | is_active = models.BooleanField(default=True) 97 | 98 | objects = ResourceManager() 99 | 100 | class Meta: 101 | ordering = ["id"] 102 | 103 | def __str__(self): 104 | return self.name 105 | -------------------------------------------------------------------------------- /tests/testapp/resources.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import messages 3 | from testapp.models import Resource 4 | 5 | from towel.forms import SearchForm 6 | from towel.resources.urls import resource_url_fn 7 | 8 | 9 | class ResourceSearchForm(SearchForm): 10 | is_active = forms.NullBooleanField(required=False) 11 | 12 | 13 | class ResourceViewMixin: 14 | def get_queryset(self): 15 | return super().get_queryset() 16 | 17 | def allow_delete(self, object=None, silent=True): 18 | if object is None: 19 | return True 20 | return self.allow_delete_if_only(object, silent=silent) 21 | 22 | def get_batch_actions(self): 23 | return super().get_batch_actions() + [ 24 | ("set_active", "Set active", self.set_active), 25 | ] 26 | 27 | def set_active(self, queryset): 28 | class SetActiveForm(forms.Form): 29 | is_active = forms.NullBooleanField() 30 | 31 | if "confirm" in self.request.POST: 32 | form = SetActiveForm(self.request.POST) 33 | if form.is_valid(): 34 | is_active = form.cleaned_data["is_active"] 35 | updated = queryset.update(is_active=is_active) 36 | messages.success(self.request, "%s have been updated." % updated) 37 | return queryset 38 | 39 | else: 40 | form = SetActiveForm() 41 | 42 | self.template_name_suffix = "_action" 43 | # context = resources.ModelResourceView.get_context_data(self, 44 | context = self.get_context_data( 45 | title="Set active", 46 | form=form, 47 | action_queryset=queryset, 48 | action_hidden_fields=self.batch_action_hidden_fields( 49 | queryset, [("batch-action", "set_active"), ("confirm", 1)] 50 | ), 51 | ) 52 | return self.render_to_response(context) 53 | 54 | 55 | resource_url = resource_url_fn( 56 | Resource, 57 | mixins=(ResourceViewMixin,), 58 | decorators=(), 59 | ) 60 | 61 | 62 | urlpatterns = [ 63 | resource_url( 64 | "list", 65 | url=r"^$", 66 | paginate_by=5, 67 | search_form=ResourceSearchForm, 68 | ), 69 | resource_url("detail", url=r"^(?P\d+)/$"), 70 | resource_url("add", url=r"^add/$"), 71 | resource_url("edit"), 72 | resource_url("delete"), 73 | ] 74 | -------------------------------------------------------------------------------- /tests/testapp/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | DEBUG = True 5 | SITE_ID = 1 6 | 7 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 8 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 9 | 10 | INSTALLED_APPS = [ 11 | "django.contrib.auth", 12 | "django.contrib.admin", 13 | "django.contrib.contenttypes", 14 | "django.contrib.sessions", 15 | "django.contrib.staticfiles", 16 | "django.contrib.messages", 17 | "testapp", 18 | "towel", 19 | ] 20 | 21 | MEDIA_ROOT = "/media/" 22 | STATIC_URL = "/static/" 23 | BASEDIR = os.path.dirname(__file__) 24 | MEDIA_ROOT = os.path.join(BASEDIR, "media/") 25 | STATIC_ROOT = os.path.join(BASEDIR, "static/") 26 | SECRET_KEY = "supersikret" 27 | 28 | ROOT_URLCONF = "testapp.urls" 29 | LANGUAGES = (("en", "English"), ("de", "German")) 30 | TEMPLATES = [ 31 | { 32 | "BACKEND": "django.template.backends.django.DjangoTemplates", 33 | "DIRS": [], 34 | "APP_DIRS": True, 35 | "OPTIONS": { 36 | "context_processors": [ 37 | "django.template.context_processors.debug", 38 | "django.template.context_processors.request", 39 | "django.contrib.auth.context_processors.auth", 40 | "django.contrib.messages.context_processors.messages", 41 | ], 42 | }, 43 | }, 44 | ] 45 | MIDDLEWARE = ( 46 | "django.middleware.common.CommonMiddleware", 47 | "django.contrib.sessions.middleware.SessionMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.locale.LocaleMiddleware", 52 | ) 53 | -------------------------------------------------------------------------------- /tests/testapp/templates/404.html: -------------------------------------------------------------------------------- 1 |

Page not found

2 | -------------------------------------------------------------------------------- /tests/testapp/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | {% load towel_form_tags %} 3 | 4 | 5 | {% block title %}{% endblock %} 6 | 7 | 8 |

9 | {% if messages %} 10 |
    11 | {% for message in messages %} 12 | {{ message }} 13 | {% endfor %} 14 |
15 | {% endif %} 16 | {% block content %}{% endblock %} 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/testapp/templates/resources/object_action.html: -------------------------------------------------------------------------------- 1 | {% extends base_template|default:"base.html" %} 2 | 3 | {% load i18n towel_form_tags verbose_name_tags %} 4 | 5 | {% block title %}{{ title }}{% if block.super %} - {{ block.super }}{% endif %}{% endblock %} 6 | 7 | {% block page-header %} 8 |

{{ title }}

9 | {% endblock %} 10 | 11 | {% block content %} 12 |
14 | {% csrf_token %} 15 | {% form_errors form %} 16 | {{ action_hidden_fields|safe }} 17 | 18 |
19 | 20 |
21 |
    22 | {% for item in action_queryset %} 23 |
  • {{ item }}
  • 24 | {% endfor %} 25 |
26 |
27 | {% for field in form %} 28 | {% if field.is_hidden %}{{ field }} 29 | {% else %}{% form_item field %} 30 | {% endif %} 31 | {% endfor %} 32 |
33 | 34 |
35 | 36 | 37 | {% trans "cancel"|capfirst %} 38 |
39 | 40 |
41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /tests/testapp/templates/resources/object_delete_confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends base_template|default:"base.html" %} 2 | 3 | {% load i18n towel_form_tags %} 4 | 5 | {% block title %}{{ title }} {{ object }} - {{ block.super }}{% endblock %} 6 | 7 | {% block page-header %} 8 |

{% blocktrans %}Delete {{ object }}{% endblocktrans %}

9 | {% endblock %} 10 | 11 | {% block content %} 12 |
13 | 14 |

{% blocktrans %}Do you really want to delete {{ object }}?{% endblocktrans %}

15 | 16 | {% if collected_objects %} 17 |

{% trans "You are about to delete the following objects:" %} 18 |

    19 | {% for opts, count in collected_objects %} 20 |
  • 21 | {{ count }} 22 | {% if count == 1 %}{{ opts.verbose_name }} 23 | {% else %}{{ opts.verbose_name_plural }}{% endif %} 24 |
  • 25 | {% endfor %} 26 |
27 | {% endif %} 28 | 29 | {% csrf_token %} 30 | {% form_errors form %} 31 | {% form_warnings form %} 32 | {% form_items form %} 33 | 34 | 35 | 36 | {% trans "cancel"|capfirst %} 37 |
38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /tests/testapp/templates/resources/object_detail.html: -------------------------------------------------------------------------------- 1 | {% extends base_template|default:"base.html" %} 2 | 3 | {% load i18n modelview_detail %} 4 | 5 | {% block title %}{{ object }} - {{ block.super }}{% endblock %} 6 | 7 | {% block page-header %} 8 |

{{ object }}

9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 | {% for title, value in object|model_details %} 14 | 15 | 16 | 17 | 18 | {% endfor %} 19 |
{{ title|capfirst }}{{ value }}
20 | {% endblock %} 21 | 22 | {% block sidebar %} 23 |
24 | 35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /tests/testapp/templates/resources/object_form.html: -------------------------------------------------------------------------------- 1 | {% extends base_template|default:"base.html" %} 2 | 3 | {% load i18n towel_form_tags verbose_name_tags %} 4 | 5 | {% block title %}{{ title }}{% if block.super %} - {{ block.super }}{% endif %}{% endblock %} 6 | 7 | {% block page-header %} 8 |

9 | {% if object %}{{ title }} 10 | {% else %}{{ title }} 11 | {% endif %} 12 |

13 | {% endblock %} 14 | 15 | {% block content %} 16 |
18 | {% csrf_token %} 19 | {% form_errors form %} 20 | 21 |
22 | {% for field in form %} 23 | {% if field.is_hidden %}{{ field }} 24 | {% else %}{% form_item field %} 25 | {% endif %} 26 | {% endfor %} 27 |
28 | 29 |
30 | 31 | 32 | {% trans "cancel"|capfirst %} 33 |
34 | 35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /tests/testapp/templates/resources/object_list.html: -------------------------------------------------------------------------------- 1 | {% extends base_template|default:"base.html" %} 2 | 3 | {% load i18n modelview_list towel_batch_tags towel_form_tags verbose_name_tags %} 4 | 5 | {% block title %}{{ verbose_name_plural|capfirst }} - {{ block.super }}{% endblock %} 6 | 7 | {% block content %} 8 | 9 | {% if batch_form %} 10 |
12 | {% csrf_token %} 13 | 14 | {% form_errors batch_form %} 15 | 16 | {{ batch_form.action }} 17 | 18 | {% trans "Action" %} 19 | 20 |
    21 | {% for action in batch_form.actions %} 22 |
  • {{ action.1 }}
  • 23 | {% endfor %} 24 |
25 | {% endif %} 26 | 27 | {% block objects %} 28 | 29 | {% if batch_form %} 30 | 31 | 32 | 33 | 34 | 35 | 36 | {% endif %} 37 | 38 | {% for object in object_list %} 39 | 40 | {% if batch_form %}{% endif %} 41 | 42 | 43 | {% endfor %} 44 | 45 |
{{ verbose_name }}
{% batch_checkbox batch_form object.id %}{{ object }}
46 | {% endblock %} 47 | 48 | {% if paginator %}{% pagination page paginator "bottom" %}{% endif %} 49 | 50 | {% if batch_form %} 51 |
52 | {% endif %} 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /tests/testapp/templates/resources/object_picker.html: -------------------------------------------------------------------------------- 1 | {% extends "modal.html" %} 2 | 3 | {% load i18n towel_region %} 4 | 5 | {% block title %} 6 | {% blocktrans %}Select a {{ verbose_name }}{% endblocktrans %} 7 | {% endblock %} 8 | 9 | {% block content %} 10 |
11 |
12 |
13 | 14 |
15 |
16 | 18 |
19 |
20 |
21 | {% region "picker-panel" fields="object_list" class="picker-panel" %} 22 | {% block objects %} 23 | 24 | {% for object in object_list %} 25 | 26 | 27 | 28 | {% endfor %} 29 |
{{ object }}
30 | {% endblock %} 31 | {% endregion %} 32 | 55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /tests/testapp/templates/testapp/emailaddress_list.html: -------------------------------------------------------------------------------- 1 | {% extends "modelview/object_list.html" %} 2 | 3 | {% load modelview_list towel_batch_tags %} 4 | 5 | {% block objects %} 6 | 7 | 8 | 9 | {% if batch_form %}{% endif %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% for object in object_list %} 17 | 18 | {% if batch_form %}{% endif %} 19 | 20 | {% for verbose_name, field in object|model_row:"person" %} 21 | 22 | {% endfor %} 23 | 24 | {% endfor %} 25 | 26 |
{% batch_checkbox batch_form object.id %}{{ object }}{{ field }}
27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /tests/testapp/templates/testapp/person_list.html: -------------------------------------------------------------------------------- 1 | {% extends "modelview/object_list.html" %} 2 | 3 | {% load modelview_list towel_batch_tags %} 4 | 5 | {% block objects %} 6 | 7 | 8 | 9 | {% if batch_form %}{% endif %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% for object in object_list %} 17 | 18 | {% if batch_form %}{% endif %} 19 | 20 | {% for verbose_name, field in object|model_row:"created,is_active" %} 21 | 22 | {% endfor %} 23 | 24 | {% endfor %} 25 | 26 |
{% ordering_link "name" request title="name" %}{% ordering_link "is_active" request title="is active" %}
{% batch_checkbox batch_form object.id %}{{ object }}{{ field }}
27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /tests/testapp/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/towel/4a8b7deda66b30072c38564381b60a5ec8e7ae87/tests/testapp/templatetags/__init__.py -------------------------------------------------------------------------------- /tests/testapp/templatetags/testapp_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | from towel.utils import parse_args_and_kwargs, resolve_args_and_kwargs 4 | 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.tag 10 | def testtag(parser, token): 11 | return TestNode(*parse_args_and_kwargs(parser, token.split_contents()[1:])) 12 | 13 | 14 | class TestNode(template.Node): 15 | def __init__(self, args, kwargs): 16 | self.args = args 17 | self.kwargs = kwargs 18 | 19 | def render(self, context): 20 | args, kwargs = resolve_args_and_kwargs(context, self.args, self.kwargs) 21 | 22 | return "ARGS: {}\nKWARGS: {}\n".format( 23 | ",".join(str(arg) for arg in args), 24 | ",".join(f"{k}={v}" for k, v in sorted(kwargs.items())), 25 | ) 26 | -------------------------------------------------------------------------------- /tests/testapp/test_deletion.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from testapp.models import EmailAddress, Person 3 | 4 | from towel import deletion 5 | 6 | 7 | class DeletionTest(TestCase): 8 | def test_deletion(self): 9 | person = Person.objects.create() 10 | 11 | email = person.emailaddress_set.create() 12 | self.assertEqual(EmailAddress.objects.count(), 1) 13 | email.delete() 14 | self.assertEqual(EmailAddress.objects.count(), 0) 15 | email = person.emailaddress_set.create() 16 | self.assertEqual(EmailAddress.objects.count(), 1) 17 | with deletion.protect(): 18 | email.delete() 19 | self.assertEqual(EmailAddress.objects.count(), 1) 20 | email.delete() 21 | self.assertEqual(EmailAddress.objects.count(), 0) 22 | -------------------------------------------------------------------------------- /tests/testapp/test_forms.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.test import TestCase 4 | from django.urls import reverse 5 | from django.utils import timezone 6 | from testapp.models import Message, Person 7 | 8 | 9 | class FormsTest(TestCase): 10 | def test_warningsform(self): 11 | person = Person.objects.create() 12 | emailaddress = person.emailaddress_set.create() 13 | 14 | self.assertEqual(self.client.get(person.urls["message"]).status_code, 200) 15 | self.assertEqual(self.client.post(person.urls["message"]).status_code, 200) 16 | 17 | response = self.client.post( 18 | person.urls["message"], 19 | {"sent_to": emailaddress.pk, "message": "Hallo Welt"}, 20 | ) 21 | self.assertRedirects(response, person.urls["detail"]) 22 | self.assertEqual(Message.objects.count(), 1) 23 | 24 | response = self.client.post( 25 | person.urls["message"], {"sent_to": emailaddress.pk, "message": " "} 26 | ) 27 | self.assertEqual(response.status_code, 200) 28 | self.assertContains(response, "Please review the following warnings:") 29 | 30 | response = self.client.post( 31 | person.urls["message"], 32 | {"sent_to": emailaddress.pk, "message": " hello ", "ignore_warnings": 1}, 33 | ) 34 | self.assertRedirects(response, person.urls["detail"]) 35 | self.assertEqual(Message.objects.count(), 2) 36 | 37 | def test_searchform(self): 38 | date = timezone.now().replace(year=2012, month=10, day=1) 39 | 40 | for i in range(100): 41 | Person.objects.create( 42 | given_name="Given %s" % i, 43 | family_name="Family %s" % i, 44 | is_active=bool(i % 3), 45 | created=date + timedelta(days=i), 46 | ) 47 | 48 | list_url = reverse("testapp_person_list") 49 | 50 | self.assertContains( 51 | self.client.get(list_url), 52 | "1 - 5 / 100", 53 | ) 54 | self.assertContains( 55 | self.client.get(list_url + "?query=42"), 56 | "1 - 1 / 1", 57 | ) 58 | self.assertContains( 59 | self.client.get(list_url + "?query=is:active"), 60 | "1 - 5 / 66", 61 | ) 62 | self.assertContains( 63 | self.client.get(list_url + "?query=is:inactive"), 64 | "1 - 5 / 34", 65 | ) 66 | self.assertContains( 67 | self.client.get(list_url + "?query=active:yes"), 68 | "1 - 5 / 66", 69 | ) 70 | self.assertContains( 71 | self.client.get(list_url + "?query=active:off"), 72 | "1 - 5 / 34", 73 | ) 74 | self.assertContains( 75 | self.client.get(list_url + "?query=year:2012"), 76 | "1 - 5 / 92", 77 | ) 78 | self.assertContains( 79 | self.client.get(list_url + '?query="Given+1"+year%3A2012'), 80 | "1 - 5 / 11", 81 | ) 82 | self.assertContains( 83 | self.client.get(list_url + '?query="%2BGiven+1"+year%3A2012'), 84 | "1 - 5 / 11", 85 | ) 86 | self.assertContains( 87 | self.client.get(list_url + '?query="-Given+1"+year%3A2012'), 88 | "1 - 5 / 81", 89 | ) 90 | 91 | # Form field 92 | self.assertContains( 93 | self.client.get(list_url + "?is_active=1"), 94 | "1 - 5 / 100", 95 | ) 96 | self.assertContains( 97 | self.client.get(list_url + "?is_active=2"), 98 | "1 - 5 / 66", 99 | ) 100 | self.assertContains( 101 | self.client.get(list_url + "?is_active=3"), 102 | "1 - 5 / 34", 103 | ) 104 | 105 | # Invalid query 106 | response = self.client.get(list_url + "?created__year=abc") 107 | self.assertEqual(response.status_code, 302) 108 | self.assertTrue(response["location"].endswith("?clear=1")) 109 | 110 | # Mixed quick (only inactive) and form field (only active) 111 | # Form field takes precedence 112 | self.assertContains( 113 | self.client.get(list_url + "?is_active=2&query=is:inactive"), 114 | "1 - 5 / 66", 115 | ) 116 | 117 | # Search form persistence 118 | self.assertContains( 119 | self.client.get(list_url + "?s=1&is_active=3"), 120 | "1 - 5 / 34", 121 | ) 122 | self.assertContains( 123 | self.client.get(list_url), 124 | "1 - 5 / 34", 125 | ) 126 | self.assertContains( 127 | self.client.get(list_url + "?clear=1"), 128 | "1 - 5 / 100", 129 | ) 130 | 131 | # Ordering 132 | self.assertContains( 133 | self.client.get(list_url), 134 | "Given 0 Family 0", 135 | ) 136 | response = self.client.get(list_url + "?o=name") 137 | self.assertContains(response, "Given 12 Family 12") 138 | self.assertContains( 139 | response, ' name' 140 | ) 141 | self.assertContains( 142 | response, ' is active' 143 | ) 144 | 145 | response = self.client.get(list_url + "?o=-name") 146 | self.assertContains(response, "Given 99 Family 99") 147 | self.assertContains( 148 | response, ' name' 149 | ) 150 | self.assertContains( 151 | response, ' is active' 152 | ) 153 | response = self.client.get(list_url + "?o=is_active") 154 | self.assertContains(response, "Given 14 Family 14") 155 | self.assertNotContains(response, "Given 12 Family 12") # inactive 156 | self.assertContains(response, ' name') 157 | self.assertContains( 158 | response, ' is active' 159 | ) 160 | 161 | # TODO multiple choice fields 162 | # TODO SearchForm.default 163 | 164 | 165 | # TODO autocompletion widget tests? 166 | -------------------------------------------------------------------------------- /tests/testapp/test_mt.py: -------------------------------------------------------------------------------- 1 | # TODO towel.mt tests 2 | -------------------------------------------------------------------------------- /tests/testapp/test_quick.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import date, timedelta 3 | 4 | from django.test import TestCase 5 | from testapp.models import Person 6 | 7 | from towel import quick 8 | 9 | 10 | QUICK_RULES = [ 11 | (re.compile(r"!!"), quick.static(important=True)), 12 | ( 13 | re.compile(r"@(?P\w+)"), 14 | quick.model_mapper(Person.objects.filter(is_active=True), "assigned_to"), 15 | ), 16 | ( 17 | re.compile(r"\^\+(?P\d+)"), 18 | lambda v: {"due": date.today() + timedelta(days=int(v["due"]))}, 19 | ), 20 | (re.compile(r"\^(?P[^\s]+)"), quick.due_mapper("due")), 21 | (re.compile(r"=(?P[\d\.]+)h"), quick.identity()), 22 | ( 23 | re.compile(r"relationship:\((?P[^\)]*)\)"), 24 | quick.model_choices_mapper(Person.RELATIONSHIP_CHOICES, "relationship"), 25 | ), 26 | ] 27 | 28 | 29 | class QuickTest(TestCase): 30 | def test_parse_quickadd(self): 31 | data, rest = quick.parse_quickadd("", QUICK_RULES) 32 | self.assertEqual(list(data.items()), []) 33 | self.assertEqual(rest, []) 34 | 35 | data, rest = quick.parse_quickadd("!! do this do that", QUICK_RULES) 36 | self.assertEqual(list(data.items()), [("important", True)]) 37 | self.assertEqual(" ".join(rest), "do this do that") 38 | 39 | p_muster = Person.objects.create(family_name="Muster") 40 | Person.objects.create(family_name="Blaa") 41 | Person.objects.create() 42 | data, rest = quick.parse_quickadd("@Muster Phone call !!", QUICK_RULES) 43 | self.assertEqual(data["assigned_to"], p_muster.pk) 44 | self.assertEqual(data["assigned_to_"], p_muster) 45 | self.assertEqual(data["important"], True) 46 | self.assertEqual(rest, ["Phone", "call"]) 47 | 48 | data, rest = quick.parse_quickadd("@Unknown Phone", QUICK_RULES) 49 | self.assertTrue("assigned_to" not in data) 50 | self.assertEqual(rest, ["Phone"]) 51 | # XXX Stop dropping unknowns? 52 | 53 | self.assertEqual( 54 | quick.parse_quickadd("^+3", QUICK_RULES)[0]["due"], 55 | date.today() + timedelta(days=3), 56 | ) 57 | self.assertEqual( 58 | quick.parse_quickadd("^+42", QUICK_RULES)[0]["due"], 59 | date.today() + timedelta(days=42), 60 | ) 61 | self.assertEqual( 62 | quick.parse_quickadd("^Today", QUICK_RULES)[0]["due"], 63 | date.today() + timedelta(days=0), 64 | ) 65 | self.assertEqual( 66 | quick.parse_quickadd("^Tomorrow", QUICK_RULES)[0]["due"], 67 | date.today() + timedelta(days=1), 68 | ) 69 | for name in "Monday,Tuesday,Wednesday,Thursday,Friday,Saturday," "Sunday".split( 70 | "," 71 | ): 72 | due = quick.parse_quickadd("^%s" % name, QUICK_RULES)[0]["due"] 73 | self.assertTrue(date.today() <= due < date.today() + timedelta(days=7)) 74 | 75 | self.assertEqual( 76 | quick.parse_quickadd("=0.3h", QUICK_RULES)[0]["estimated_hours"], 77 | "0.3", 78 | ) 79 | self.assertEqual( 80 | quick.parse_quickadd("=10.3h", QUICK_RULES)[0]["estimated_hours"], 81 | "10.3", 82 | ) 83 | self.assertEqual( 84 | quick.parse_quickadd("=37h", QUICK_RULES)[0]["estimated_hours"], 85 | "37", 86 | ) 87 | 88 | self.assertEqual( 89 | quick.parse_quickadd("relationship:(unspecified)", QUICK_RULES)[0][ 90 | "relationship" 91 | ], 92 | "", 93 | ) 94 | self.assertEqual( 95 | quick.parse_quickadd("relationship:(married)", QUICK_RULES)[0][ 96 | "relationship" 97 | ], 98 | "married", 99 | ) 100 | self.assertEqual( 101 | quick.parse_quickadd("relationship:(in a relationship)", QUICK_RULES)[0][ 102 | "relationship" 103 | ], 104 | "relation", 105 | ) 106 | self.assertTrue( 107 | "relation" 108 | not in quick.parse_quickadd("relationship:(stupidity)", QUICK_RULES)[0] 109 | ) 110 | -------------------------------------------------------------------------------- /tests/testapp/test_resources.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.test import TestCase 3 | from django.urls import reverse 4 | from django.utils.encoding import force_str 5 | from testapp.models import Resource 6 | 7 | 8 | class ResourceTest(TestCase): 9 | def test_list_view(self): 10 | for i in range(7): 11 | r = Resource.objects.create(name=f"Resource {i}") 12 | 13 | # paginate_by=5 14 | self.assertContains(self.client.get("/resources/"), 'name="batch_', 5) 15 | self.assertContains(self.client.get("/resources/?page=2"), 'name="batch_', 2) 16 | # Invalid page number -> first page 17 | self.assertContains(self.client.get("/resources/?page=abc"), 'name="batch_', 5) 18 | # Empty page -> last page 19 | self.assertContains(self.client.get("/resources/?page=42"), 'name="batch_', 2) 20 | 21 | self.assertContains(self.client.get(r.get_absolute_url()), "Resource 6") 22 | self.assertEqual(self.client.get("/resources/0/").status_code, 404) 23 | self.assertEqual(self.client.get("/resources/a/").status_code, 404) 24 | 25 | def test_crud(self): 26 | self.assertContains(self.client.get("/resources/add/"), "1 - 5 / 20
") 81 | 82 | response = self.client.post("/resources/", {"batchform": 1}) 83 | self.assertEqual(response.status_code, 200) 84 | self.assertContains(response, "
  • No items selected
  • ") 85 | 86 | self.assertEqual(Resource.objects.filter(is_active=False).count(), 0) 87 | data = { 88 | "batchform": 1, 89 | "batch-action": "set_active", 90 | } 91 | for pk in Resource.objects.values_list("id", flat=True)[:3]: 92 | data["batch_%s" % pk] = pk 93 | response = self.client.post("/resources/", data) 94 | self.assertContains(response, "Set active") 95 | if django.VERSION < (2, 2): 96 | self.assertContains(response, '') 97 | else: 98 | self.assertContains( 99 | response, '' 100 | ) 101 | data["confirm"] = 1 102 | data["is_active"] = 3 103 | response = self.client.post("/resources/", data, follow=True) 104 | self.assertRedirects(response, "/resources/") 105 | 106 | messages = [str(m) for m in response.context["messages"]] 107 | messages = str(messages) 108 | self.assertTrue("3 have been updated." in messages) 109 | self.assertTrue("Resource 0" in messages) 110 | self.assertTrue("Resource 1" in messages) 111 | self.assertTrue("Resource 2" in messages) 112 | self.assertEqual(Resource.objects.filter(is_active=False).count(), 3) 113 | -------------------------------------------------------------------------------- /tests/testapp/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.template import Context, Template 2 | from django.test import TestCase 3 | from testapp.models import EmailAddress, Person 4 | 5 | from towel.utils import related_classes, safe_queryset_and, substitute_with, tryreverse 6 | 7 | 8 | class UtilsTest(TestCase): 9 | def test_related_classes(self): 10 | """Test the functionality of towel.utils.related_classes""" 11 | person = Person.objects.create( 12 | family_name="Muster", 13 | given_name="Hans", 14 | ) 15 | EmailAddress.objects.create( 16 | person=person, 17 | email="hans@example.com", 18 | ) 19 | 20 | self.assertEqual( 21 | set(related_classes(person)), 22 | {Person, EmailAddress}, 23 | ) 24 | 25 | def test_safe_queryset_and(self): 26 | class AnyException(Exception): 27 | pass 28 | 29 | def _transform_nothing(queryset): 30 | raise AnyException 31 | 32 | qs1 = ( 33 | EmailAddress.objects.search("blub") 34 | .transform(_transform_nothing) 35 | .select_related() 36 | ) 37 | qs2 = EmailAddress.objects.distinct().reverse().select_related("person") 38 | qs3 = EmailAddress.objects.all() 39 | 40 | qs = safe_queryset_and(safe_queryset_and(qs1, qs2), qs3) 41 | 42 | self.assertEqual(qs._transform_fns, [_transform_nothing]) 43 | self.assertFalse(qs.query.standard_ordering) 44 | self.assertEqual(qs.query.select_related, {"person": {}}) 45 | self.assertTrue(qs.query.distinct) 46 | self.assertEqual(qs.count(), 0) 47 | self.assertRaises(AnyException, list, qs) 48 | 49 | qs = safe_queryset_and( 50 | EmailAddress.objects.select_related(), 51 | EmailAddress.objects.select_related(), 52 | ) 53 | 54 | self.assertTrue(qs.query.select_related) 55 | self.assertFalse(qs.query.distinct) 56 | 57 | qs = safe_queryset_and( 58 | EmailAddress.objects.all(), 59 | EmailAddress.objects.select_related(), 60 | ) 61 | 62 | self.assertTrue(qs.query.select_related) 63 | 64 | def test_tryreverse(self): 65 | self.assertEqual(tryreverse("asdf42"), None) 66 | self.assertEqual(tryreverse("admin:index"), "/admin/") 67 | 68 | def test_substitute_with(self): 69 | p1 = Person.objects.create() 70 | p2 = Person.objects.create() 71 | 72 | p1.emailaddress_set.create() 73 | p1.emailaddress_set.create() 74 | p1.emailaddress_set.create() 75 | p2.emailaddress_set.create() 76 | p2.emailaddress_set.create() 77 | 78 | self.assertEqual(Person.objects.count(), 2) 79 | self.assertEqual(EmailAddress.objects.count(), 5) 80 | 81 | substitute_with(p1, p2) 82 | 83 | p = Person.objects.get() 84 | self.assertEqual(p2, p) 85 | self.assertEqual(EmailAddress.objects.count(), 5) 86 | 87 | def test_template_tag_helpers(self): 88 | testcases = [ 89 | ("", ""), 90 | ("{% testtag %}", "ARGS: KWARGS:"), 91 | ("{% testtag 3 4 5 %}", "ARGS: 3,4,5 KWARGS:"), 92 | ('{% testtag 3 "4" 5 %}', "ARGS: 3,4,5 KWARGS:"), 93 | ('{% testtag abcd "42" %}', "ARGS: yay,42 KWARGS:"), 94 | ('{% testtag "abcd" "42" %}', "ARGS: abcd,42 KWARGS:"), 95 | ('{% testtag "abcd" "42" a=b %}', "ARGS: abcd,42 KWARGS: a="), 96 | ('{% testtag "abcd" a="b" "42" %}', "ARGS: abcd,42 KWARGS: a=b"), 97 | ('{% testtag bla="blub" blo="blob" %}', "ARGS: KWARGS: bla=blub,blo=blob"), 98 | ('{% testtag bla=blub blo="blob" %}', "ARGS: KWARGS: bla=blubber,blo=blob"), 99 | ] 100 | 101 | for test, result in testcases: 102 | t = Template("{% load testapp_tags %}" + test) 103 | self.assertHTMLEqual( 104 | t.render(Context({"abcd": "yay", "bla": "blaaa", "blub": "blubber"})), 105 | result, 106 | ) 107 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 3 | from django.urls import include, re_path 4 | 5 | from .views import emailaddress_views, message_views, person_views 6 | 7 | 8 | urlpatterns = [ 9 | re_path(r"^admin/", admin.site.urls), 10 | re_path(r"^persons/", include(person_views.urls)), 11 | re_path(r"^emailaddresses/", include(emailaddress_views.urls)), 12 | re_path(r"^messages/", include(message_views.urls)), 13 | re_path(r"^resources/", include("testapp.resources")), 14 | ] + staticfiles_urlpatterns() 15 | -------------------------------------------------------------------------------- /tests/testapp/views.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django import forms 4 | from django.contrib import messages 5 | from django.shortcuts import redirect 6 | 7 | from towel import quick 8 | from towel.forms import BatchForm, SearchForm, WarningsForm 9 | from towel.modelview import ModelView 10 | 11 | from .models import EmailAddress, Message, Person 12 | 13 | 14 | class PersonBatchForm(BatchForm): 15 | is_active = forms.NullBooleanField() 16 | 17 | def process(self): 18 | if self.cleaned_data.get("is_active") is not None: 19 | updated = self.batch_queryset.update( 20 | is_active=self.cleaned_data["is_active"] 21 | ) 22 | messages.success(self.request, "%s have been updated." % updated) 23 | 24 | return self.batch_queryset 25 | 26 | 27 | class PersonSearchForm(SearchForm): 28 | orderings = { 29 | "name": ("family_name", "given_name"), 30 | "is_active": ("-is_active", "family_name"), 31 | } 32 | 33 | quick_rules = [ 34 | (re.compile(r"^is:active$"), quick.static(is_active=True)), 35 | (re.compile(r"^is:inactive$"), quick.static(is_active=False)), 36 | (re.compile(r"^active:(?P\w+)$"), quick.bool_mapper("is_active")), 37 | ( 38 | re.compile(r"^year:(?P\d{4})$"), 39 | lambda values: {"created__year": values["year"]}, 40 | ), 41 | ] 42 | created__year = forms.IntegerField(required=False) 43 | is_active = forms.NullBooleanField(required=False) 44 | 45 | 46 | class PersonForm(forms.ModelForm): 47 | class Meta: 48 | model = Person 49 | fields = ("family_name", "given_name") 50 | 51 | 52 | class MessageForm(forms.ModelForm, WarningsForm): 53 | class Meta: 54 | model = Message 55 | fields = "__all__" 56 | 57 | def __init__(self, *args, **kwargs): 58 | person = kwargs.pop("person") 59 | super().__init__(*args, **kwargs) 60 | self.fields["sent_to"].queryset = person.emailaddress_set.all() 61 | 62 | def clean(self): 63 | data = super().clean() 64 | 65 | if not data.get("message", "").strip(): 66 | self.add_warning("Only spaces in message, really send?") 67 | 68 | return data 69 | 70 | 71 | class PersonModelView(ModelView): 72 | def additional_urls(self): 73 | return ((r"^%(detail)s/message/$", self.message),) 74 | 75 | def deletion_allowed(self, request, instance): 76 | return self.deletion_allowed_if_only(request, instance, [Person]) 77 | 78 | def save_formsets(self, request, form, formsets, change): 79 | self.save_formset_deletion_allowed_if_only( 80 | request, form, formsets["emails"], change, [EmailAddress] 81 | ) 82 | 83 | def message(self, request, *args, **kwargs): 84 | instance = self.get_object_or_404(request, *args, **kwargs) 85 | 86 | if request.method == "POST": 87 | form = MessageForm(request.POST, person=instance) 88 | 89 | if form.is_valid(): 90 | form.save() 91 | return redirect(instance) 92 | 93 | else: 94 | form = MessageForm(person=instance) 95 | 96 | return self.render( 97 | request, 98 | self.get_template(request, "form"), 99 | self.get_context( 100 | request, {self.template_object_name: instance, "form": form} 101 | ), 102 | ) 103 | 104 | 105 | person_views = PersonModelView( 106 | Person, 107 | search_form=PersonSearchForm, 108 | search_form_everywhere=True, 109 | batch_form=PersonBatchForm, 110 | form_class=PersonForm, 111 | paginate_by=5, 112 | inlineformset_config={"emails": {"model": EmailAddress}}, 113 | ) 114 | 115 | 116 | class EmailAddressSearchForm(SearchForm): 117 | default = { 118 | "person__is_active": True, 119 | "person__relationship": ("", "single"), 120 | } 121 | person__is_active = forms.NullBooleanField(required=False) 122 | person__relationship = forms.MultipleChoiceField( 123 | required=False, choices=Person.RELATIONSHIP_CHOICES 124 | ) 125 | 126 | 127 | emailaddress_views = ModelView( 128 | EmailAddress, 129 | paginate_by=5, 130 | search_form=EmailAddressSearchForm, 131 | ) 132 | 133 | 134 | message_views = ModelView( 135 | Message, 136 | paginate_by=5, 137 | ) 138 | -------------------------------------------------------------------------------- /towel/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Towel - Keeping you DRY since 2010 3 | """ 4 | VERSION = (0, 31, 0) 5 | __version__ = ".".join(map(str, VERSION)) 6 | -------------------------------------------------------------------------------- /towel/auth.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.backends import ModelBackend as _ModelBackend 2 | from django.contrib.auth.models import User 3 | 4 | 5 | class ModelBackend(_ModelBackend): 6 | """ 7 | Add the following to your ``settings.py`` to be able to login with 8 | email addresses too:: 9 | 10 | AUTHENTICATION_BACKENDS = ( 11 | 'towel.auth.ModelBackend', 12 | ) 13 | """ 14 | 15 | def authenticate(self, username=None, password=None): 16 | try: 17 | user = User.objects.get(username=username) 18 | except User.DoesNotExist: 19 | try: 20 | user = User.objects.get(email=username) 21 | except User.DoesNotExist: 22 | return None 23 | 24 | if user.check_password(password): 25 | return user 26 | 27 | return None 28 | -------------------------------------------------------------------------------- /towel/deletion.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper module to circumvent Django's (arguably) broken formset saving behavior 3 | where models are directly deleted even if ``commit=False`` is passed to the 4 | formset's ``save()`` method. 5 | 6 | Usage:: 7 | 8 | class SafeModel(deletion.Model): 9 | # fields etc. 10 | 11 | 12 | instance = SafeModel.objects.get(...) 13 | with deletion.protect(): 14 | instance.delete() # Does nothing 15 | instance.delete() # Actually deletes the instance! 16 | 17 | 18 | Saving formsets:: 19 | 20 | with deletion.protect(): 21 | objects = formset.save() 22 | 23 | for obj in formset.deleted_objects: # Django provides this attribute 24 | obj.delete() # Or do something else, like checking whether the instance 25 | # should really be deleted 26 | 27 | 28 | This is achieved by overriding the model's ``delete()`` method with a different 29 | version which does nothing if protection is active. If you override the 30 | deletion method for some reason too, you have to ensure that the threadlocal 31 | state is respected too. 32 | """ 33 | 34 | 35 | from contextlib import contextmanager 36 | from threading import local 37 | 38 | from django.db import models 39 | 40 | 41 | DEFAULT = None 42 | PROTECT = "protect" 43 | 44 | _deletion = local() 45 | 46 | 47 | def set_mode(mode): 48 | """ 49 | Sets the protection mode. The argument should be one of: 50 | 51 | - ``deletion.DEFAULT``: 52 | Standard behavior, instances are deleted. 53 | - ``deletion.PROTECT``: 54 | ``delete()`` invocations on models inheriting ``deletion.Model`` are 55 | ignored. 56 | """ 57 | _deletion.mode = mode 58 | 59 | 60 | @contextmanager 61 | def protect(): 62 | """ 63 | Wraps a code block with deletion protection 64 | 65 | Example:: 66 | 67 | from towel import deletion 68 | 69 | instance = SafeModel.objects.get(...) 70 | with deletion.protect(): 71 | # Does nothing 72 | instance.delete() 73 | 74 | # Actually deletes the instance 75 | instance.delete() 76 | """ 77 | set_mode(PROTECT) 78 | yield 79 | set_mode(DEFAULT) 80 | 81 | 82 | class Model(models.Model): 83 | """ 84 | Safe model base class, inherit this model instead of the standard 85 | :py:class:`django.db.models.Model` if you want to take advantage of 86 | the :py:mod:`towel.deletion` module. 87 | """ 88 | 89 | class Meta: 90 | abstract = True 91 | 92 | def delete(self, *args, **kwargs): 93 | """ 94 | Deletion is skipped if inside a :py:func:`~towel.deletion.protect` 95 | block. 96 | """ 97 | if getattr(_deletion, "mode", None) == PROTECT: 98 | return 99 | super().delete(*args, **kwargs) 100 | -------------------------------------------------------------------------------- /towel/django-admin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | PYTHONPATH=../../.. ../../../django/bin/django-admin.py $* 3 | -------------------------------------------------------------------------------- /towel/incubator/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/towel/4a8b7deda66b30072c38564381b60a5ec8e7ae87/towel/incubator/__init__.py -------------------------------------------------------------------------------- /towel/incubator/frankenresource.py: -------------------------------------------------------------------------------- 1 | import httplib 2 | from django.contrib.messages.api import get_messages 3 | 4 | from towel.api import APIException, Resource 5 | 6 | 7 | class FrankenResource(Resource): 8 | """ 9 | Really ugly and hacky way of reusing customizations made in a ``ModelView`` 10 | subclass for API resources. Reuses the following aspects of the 11 | ``ModelView`` instance: 12 | 13 | - Basic queryset filtering, i.e. ``get_query_set`` 14 | - Form handling and saving, i.e. ``get_form``, ``get_form_instance``, 15 | ``save_form``, ``save_model`` and ``post_save`` 16 | - Permissions management, i.e. ``adding_allowed``, ``editing_allowed``, 17 | ``deletion_allowed`` 18 | """ 19 | 20 | #: ``ModelView`` instance used for providing permissions and write 21 | #: access to the exposed model 22 | modelview = None 23 | 24 | def get_query_set(self): 25 | return self.modelview.get_query_set(self.request) 26 | 27 | def post_list(self, request, *args, **kwargs): 28 | """ 29 | POST handler. Only supports full creation of objects by posting to 30 | the listing endpoint currently. 31 | """ 32 | if not self.modelview.adding_allowed(request): 33 | raise APIException(status=httplib.FORBIDDEN) 34 | 35 | form_class = self.modelview.get_form(request, change=False) 36 | form = self.modelview.get_form_instance( 37 | request, form_class=form_class, change=False 38 | ) 39 | 40 | try: 41 | is_valid = form.is_valid() 42 | except TypeError as exc: 43 | # This can happen when POSTing something of type 44 | # application/json with a list instead of a single entry, 45 | # e.g. {"customer_id": ["1"]} 46 | raise APIException("Malformed data", data={"exception": "%s" % exc}) 47 | 48 | if not is_valid: 49 | raise APIException(data={"validation": form.errors}) 50 | 51 | instance = self.modelview.save_form(request, form, change=False) 52 | self.modelview.save_model(request, instance, form, change=False) 53 | self.modelview.post_save(request, instance, form, {}, change=False) 54 | 55 | data = self.api.serialize_instance( 56 | instance, build_absolute_uri=request.build_absolute_uri 57 | ) 58 | return self.serialize_response( 59 | data, status=httplib.CREATED, headers={"Location": data["__uri__"]} 60 | ) 61 | 62 | def put_detail(self, request, *args, **kwargs): 63 | """ 64 | PUT handler. Only supports update of existing resources. Sets are not 65 | supported. 66 | 67 | You are required to provide the full set of fields, otherwise 68 | validation fails. If you are looking for partial updates, have a look 69 | at PATCH. 70 | """ 71 | instance = self.detail_object_or_404() 72 | 73 | if not self.modelview.editing_allowed(request, instance): 74 | raise APIException(status=httplib.FORBIDDEN) 75 | 76 | # The ModelView code only does the right thing when method is POST 77 | request.method = "POST" 78 | 79 | form_class = self.modelview.get_form(request, instance=instance, change=True) 80 | form = self.modelview.get_form_instance( 81 | request, form_class=form_class, instance=instance, change=True 82 | ) 83 | 84 | if not form.is_valid(): 85 | raise APIException(data={"validation": form.errors}) 86 | 87 | instance = self.modelview.save_form(request, form, change=True) 88 | self.modelview.save_model(request, instance, form, change=True) 89 | self.modelview.post_save(request, instance, form, {}, change=True) 90 | 91 | data = self.api.serialize_instance( 92 | instance, build_absolute_uri=request.build_absolute_uri 93 | ) 94 | return self.serialize_response(data, status=httplib.OK) 95 | 96 | def patch_detail(self, request, *args, **kwargs): 97 | """ 98 | PATCH handler. Only supports update of existing resources. 99 | 100 | This handler offloads the work to the PUT handler. It starts with the 101 | serialized representation from the database, overwrites values using 102 | the data from the PATCH request and calls PUT afterwards. 103 | """ 104 | instance = self.detail_object_or_404() 105 | 106 | if not self.modelview.editing_allowed(request, instance): 107 | raise APIException(status=httplib.FORBIDDEN) 108 | 109 | data = self.api.serialize_instance( 110 | instance, build_absolute_uri=request.build_absolute_uri 111 | ) 112 | for key in request.POST: 113 | if isinstance(data[key], (list, tuple)): 114 | data[key] = request.POST.getlist(key) 115 | else: 116 | data[key] = request.POST[key] 117 | request.POST = data 118 | 119 | return self.put_detail(request, *args, **kwargs) 120 | 121 | def delete_detail(self, request, *args, **kwargs): 122 | """ 123 | DELETE handler. Only supports deletion of single items at the moment. 124 | """ 125 | instance = self.detail_object_or_404() 126 | 127 | if not self.modelview.deletion_allowed(request, instance): 128 | raise APIException( 129 | status=httplib.FORBIDDEN, 130 | data={ 131 | "messages": [ 132 | {"message": "%s" % msg, "tags": msg.tags} 133 | for msg in get_messages(request) 134 | ], 135 | }, 136 | ) 137 | 138 | instance.delete() 139 | return self.serialize_response({}, status=httplib.NO_CONTENT) 140 | -------------------------------------------------------------------------------- /towel/incubator/modelview.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.forms.models import model_to_dict 4 | from django.http import Http404, HttpResponse 5 | from django.shortcuts import get_object_or_404, render 6 | 7 | from towel.modelview import ModelView 8 | from towel.utils import app_model_label, changed_regions 9 | 10 | 11 | class EditLiveModelView(ModelView): 12 | #: The form class used for live editing. Should only contain fields 13 | #: for which editing through the editlive mechanism is allowed. 14 | editlive_form = None 15 | 16 | def editlive(self, request, *args, **kwargs): 17 | if not self.editlive_form: 18 | raise Http404("No live editing support.") 19 | 20 | instance = self.get_object_or_404(request, *args, **kwargs) 21 | 22 | data = model_to_dict( 23 | instance, 24 | fields=self.editlive_form._meta.fields, 25 | exclude=self.editlive_form._meta.exclude, 26 | ) 27 | 28 | for key, value in request.POST.items(): 29 | data[key] = value 30 | 31 | form = self.editlive_form(data, instance=instance, request=request) 32 | 33 | if form.is_valid(): 34 | return self.response_editlive(request, form.save(), form, {}) 35 | 36 | return HttpResponse( 37 | json.dumps({"!form-errors": dict(form.errors)}), 38 | content_type="application/json", 39 | ) 40 | 41 | def response_editlive(self, request, new_instance, form, formsets): 42 | regions = {} 43 | self.render_detail( 44 | request, {self.template_object_name: new_instance, "regions": regions} 45 | ) 46 | data = {"!form-errors": {}} 47 | data.update(changed_regions(regions, form.changed_data)) 48 | return HttpResponse(json.dumps(data), content_type="application/json") 49 | 50 | 51 | class ParentModelView(EditLiveModelView): 52 | def response_edit(self, request, new_instance, form, formsets): 53 | return self.response_editlive(request, new_instance, form, formsets) 54 | 55 | def render_form(self, request, context, change): 56 | if change: 57 | context.setdefault("base_template", "modal.html") 58 | return super().render_form(request, context, change=change) 59 | 60 | 61 | class InlineModelView(EditLiveModelView): 62 | parent_attr = "parent" 63 | 64 | @property 65 | def parent_class(self): 66 | return self.model._meta.get_field(self.parent_attr).related_model 67 | 68 | def get_object(self, request, *args, **kwargs): 69 | if "pk" in kwargs: 70 | kwargs[self.parent_attr] = kwargs.pop("parent") 71 | return super().get_object(request, *args, **kwargs) 72 | 73 | def add_view(self, request, parent): 74 | request._parent = get_object_or_404(self.parent_class, id=parent) 75 | return super().add_view(request) 76 | 77 | def save_model(self, request, instance, form, change): 78 | if hasattr(request, "_parent"): 79 | setattr(instance, self.parent_attr, request._parent) 80 | super().save_model(request, instance, form=form, change=change) 81 | 82 | def response_add(self, request, instance, *args, **kwargs): 83 | regions = {} 84 | render( 85 | request, 86 | "%s/%s_detail.html" % app_model_label(self.parent_class), 87 | {"object": getattr(instance, self.parent_attr), "regions": regions}, 88 | ) 89 | return HttpResponse( 90 | json.dumps( 91 | changed_regions(regions, ["%s_set" % self.model.__name__.lower()]) 92 | ), 93 | content_type="application/json", 94 | ) 95 | 96 | response_delete = response_editlive = response_edit = response_add 97 | # TODO what about response_adding_denied, response_editing_denied and 98 | # response_deletion_denied? 99 | -------------------------------------------------------------------------------- /towel/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/towel/4a8b7deda66b30072c38564381b60a5ec8e7ae87/towel/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /towel/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PACKAGE VERSION\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2013-05-24 08:45+0200\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 19 | 20 | #: forms.py:131 21 | msgid "No items selected" 22 | msgstr "Keine Elemente gewählt" 23 | 24 | #: forms.py:259 forms.py:260 25 | msgid "Query" 26 | msgstr "Suchbegriff" 27 | 28 | #: forms.py:499 29 | msgid "Ignore warnings" 30 | msgstr "Warnungen ignorieren" 31 | 32 | #: forms.py:642 33 | msgid "clear" 34 | msgstr "" 35 | 36 | #: modelview.py:95 37 | msgid "The new object has been successfully created." 38 | msgstr "Das neue Objekt wurde erfolgreich erstellt." 39 | 40 | #: modelview.py:97 41 | msgid "You are not allowed to add objects." 42 | msgstr "Es ist ihnen nicht erlaubt, Objekte hinzuzufügen." 43 | 44 | #: modelview.py:99 45 | msgid "The object has been successfully updated." 46 | msgstr "Das Objekt wurde erfolgreich aktualisiert." 47 | 48 | #: modelview.py:101 49 | msgid "You are not allowed to edit this object." 50 | msgstr "Es ist nicht erlaubt dieses Objekt zu bearbeiten." 51 | 52 | #: modelview.py:103 53 | msgid "The object has been successfully deleted." 54 | msgstr "Das Objekt wurde erfolgreich gelöscht." 55 | 56 | #: modelview.py:105 57 | msgid "You are not allowed to delete this object." 58 | msgstr "Es ist nicht erlaubt dieses Objekt zu löschen." 59 | 60 | #: modelview.py:107 61 | #, python-format 62 | msgid "" 63 | "Deletion not allowed: There are %(pretty_classes)s related to this object." 64 | msgstr "" 65 | "Löschen nicht erlaubt: Es sind %(pretty_classes)s mit diesem Objekt " 66 | "verbunden." 67 | 68 | #: modelview.py:667 resources/base.py:288 69 | msgid "The search query was invalid." 70 | msgstr "Die Suchanfrage war ungültig." 71 | 72 | #: modelview.py:704 resources/base.py:317 73 | #, python-format 74 | msgid "" 75 | "Processed the following items:
    \n" 76 | " %s" 77 | msgstr "" 78 | "Die folgenden Elemente wurden bearbeitet:
    \n" 79 | " %s" 80 | 81 | #: modelview.py:796 resources/base.py:474 82 | #, python-format 83 | msgid "Add %s" 84 | msgstr "%s hinzufügen" 85 | 86 | #: modelview.py:829 87 | #, python-format 88 | msgid "Change %s" 89 | msgstr "%s ändern" 90 | 91 | #: modelview.py:872 modelview.py:924 92 | msgid " and " 93 | msgstr " und " 94 | 95 | #: modelview.py:956 resources/base.py:684 96 | #, python-format 97 | msgid "Delete %s" 98 | msgstr "%s löschen" 99 | 100 | #: quick.py:151 101 | msgid "Today" 102 | msgstr "Heute" 103 | 104 | #: quick.py:152 105 | msgid "Tomorrow" 106 | msgstr "Morgen" 107 | 108 | #: resources/base.py:195 109 | #, python-format 110 | msgid "You are not allowed to delete %(verbose_name_plural)s." 111 | msgstr "Es ist nicht erlaubt %(verbose_name_plural)s zu löschen." 112 | 113 | #: resources/base.py:198 114 | #, python-format 115 | msgid "You are not allowed to delete this %(verbose_name)s." 116 | msgstr "Es ist nicht erlaubt dieses %(verbose_name)s zu löschen." 117 | 118 | #: resources/base.py:217 119 | #, python-format 120 | msgid "Deletion not allowed because of related objects: %s" 121 | msgstr "Löschen wegen folgenden verbundenen Objekten nicht erlaubt: %s" 122 | 123 | #: resources/base.py:303 124 | msgid "Action" 125 | msgstr "Aktion" 126 | 127 | #: resources/base.py:344 resources/base.py:389 128 | msgid "Delete selected" 129 | msgstr "Ausgewählte löschen" 130 | 131 | #: resources/base.py:373 132 | msgid "You are not allowed to delete any object in the selection." 133 | msgstr "Es ist nicht erlaubt, Objekte aus dieser Auswahl zu löschen." 134 | 135 | #: resources/base.py:379 136 | msgid "" 137 | "Deletion of some objects not allowed. Those have been excluded from the " 138 | "selection already." 139 | msgstr "" 140 | "Löschen einiger Objekte nicht erlaubt. Diese wurden schon aus der Auswahl " 141 | "entfernt." 142 | 143 | #: resources/base.py:383 144 | msgid "Deletion successful." 145 | msgstr "Löschen erfolgreich." 146 | 147 | #: resources/base.py:473 148 | #, python-format 149 | msgid "Edit %s" 150 | msgstr "%s bearbeiten" 151 | 152 | #: resources/base.py:517 153 | #, python-format 154 | msgid "The %(verbose_name)s has been successfully saved." 155 | msgstr "%(verbose_name)s wurde erfolgreich aktualisiert." 156 | 157 | #: resources/base.py:635 158 | #, python-format 159 | msgid "Select a %s" 160 | msgstr "%s auswählen" 161 | 162 | #: resources/base.py:720 163 | #, python-format 164 | msgid "The %(verbose_name)s has been successfully deleted." 165 | msgstr "%(verbose_name)s wurde erfolgreich gelöscht." 166 | 167 | #: templates/modelview/object_delete_confirmation.html:10 168 | #, python-format 169 | msgid "Do you really want to delete %(object)s?" 170 | msgstr "Wollen Sie %(object)s wirklich löschen?" 171 | 172 | #: templates/modelview/object_delete_confirmation.html:13 173 | msgid "You are about to delete the following objects:" 174 | msgstr "Die folgenden Objekte werden gelöscht:" 175 | 176 | #: templates/modelview/object_delete_confirmation.html:26 177 | #: templates/modelview/object_detail.html:20 178 | msgid "delete" 179 | msgstr "löschen" 180 | 181 | #: templates/modelview/object_delete_confirmation.html:28 182 | #: templates/modelview/object_form.html:43 183 | msgid "cancel" 184 | msgstr "abbrechen" 185 | 186 | #: templates/modelview/object_detail.html:21 187 | msgid "edit" 188 | msgstr "bearbeiten" 189 | 190 | #: templates/modelview/object_form.html:40 191 | msgid "save" 192 | msgstr "sichern" 193 | 194 | #: templates/modelview/object_form.html:41 195 | msgid "Save and continue editing" 196 | msgstr "Sichern und weiter bearbeiten" 197 | 198 | #: templates/modelview/object_list.html:11 199 | #, python-format 200 | msgid "Add %(verbose_name)s" 201 | msgstr "%(verbose_name)s hinzufügen" 202 | 203 | #: templates/modelview/object_list.html:27 204 | msgid "Search" 205 | msgstr "Suchen" 206 | 207 | #: templates/modelview/object_list.html:42 208 | msgid "object" 209 | msgstr "object" 210 | 211 | #: templates/modelview/object_list.html:61 212 | msgid "Batch form" 213 | msgstr "Stapelbearbeitung" 214 | 215 | #: templates/modelview/object_list.html:75 216 | msgid "Submit" 217 | msgstr "Abschicken" 218 | 219 | #: templates/towel/_form_errors.html:3 220 | msgid "Please correct the following errors:" 221 | msgstr "Bitte korrigieren Sie die folgenden Fehler:" 222 | 223 | #: templates/towel/_form_errors.html:8 templates/towel/_form_errors.html:24 224 | msgid "Form errors" 225 | msgstr "Formularfehler" 226 | 227 | #: templates/towel/_form_errors.html:19 228 | msgid "Fieldset errors" 229 | msgstr "Formularfehler" 230 | 231 | #: templates/towel/_form_warnings.html:3 232 | msgid "Please review the following warnings:" 233 | msgstr "Bitte prüfen Sie die folgenden Warnungen:" 234 | 235 | #: templates/towel/_pagination.html:17 236 | msgid "show all" 237 | msgstr "alle anzeigen" 238 | 239 | #: templatetags/modelview_detail.py:51 240 | msgid "yes" 241 | msgstr "ja" 242 | 243 | #: templatetags/modelview_detail.py:52 244 | msgid "no" 245 | msgstr "nein" 246 | 247 | #: templatetags/modelview_detail.py:53 248 | msgid "unknown" 249 | msgstr "unbekannt" 250 | 251 | #~ msgid "Save" 252 | #~ msgstr "Sichern" 253 | 254 | #~ msgid "" 255 | #~ "Deletion of %(instance)s not allowed: There are %(classes)s " 256 | #~ "related to this object." 257 | #~ msgstr "" 258 | #~ "Löschen von %(instance)s nicht erlaubt: Es sind %(classes)s mit " 259 | #~ "diesem Objekt verbunden." 260 | 261 | #~ msgid "reset" 262 | #~ msgstr "rücksetzen" 263 | 264 | #~ msgid "New object" 265 | #~ msgstr "Objekt erstellen" 266 | 267 | #~ msgid "Delete %(object)s?" 268 | #~ msgstr "%(object)s löschen?" 269 | 270 | #~ msgid "%(object)s will be deleted permanently!" 271 | #~ msgstr "%(object)s wird permanent gelöscht!" 272 | -------------------------------------------------------------------------------- /towel/managers.py: -------------------------------------------------------------------------------- 1 | import re 2 | from functools import reduce 3 | 4 | from django.db.models import Q 5 | 6 | from towel import queryset_transform 7 | 8 | 9 | def normalize_query( 10 | query_string, 11 | findterms=re.compile(r'"([^"]+)"|(\S+)').findall, 12 | normspace=re.compile(r"\s{2,}").sub, 13 | ): 14 | """ 15 | Splits the query string in individual keywords, getting rid of unnecessary 16 | spaces and grouping quoted words together. 17 | 18 | Example:: 19 | 20 | >>> normalize_query(' some random words "with quotes " and spaces') 21 | ['some', 'random', 'words', 'with quotes', 'and', 'spaces'] 22 | 23 | """ 24 | return [normspace(" ", (t[0] or t[1]).strip()) for t in findterms(query_string)] 25 | 26 | 27 | class SearchManager(queryset_transform.TransformManager): 28 | """ 29 | Stupid searching manager 30 | 31 | Does not use fulltext searching abilities of databases. Constructs a query 32 | searching specified fields for a freely definable search string. The 33 | individual terms may be grouped by using apostrophes, and can be prefixed 34 | with + or - signs to specify different searching modes:: 35 | 36 | +django "shop software" -satchmo 37 | 38 | Usage example:: 39 | 40 | class MyModelManager(SearchManager): 41 | search_fields = ('field1', 'name', 'related__field') 42 | 43 | class MyModel(models.Model): 44 | # ... 45 | 46 | objects = MyModelManager() 47 | 48 | MyModel.objects.search('yeah -no') 49 | """ 50 | 51 | search_fields = () 52 | 53 | def search(self, query): 54 | """ 55 | This implementation stupidly forwards to _search, which does the 56 | gruntwork. 57 | 58 | Put your customizations in here. 59 | """ 60 | 61 | return self._search(query) 62 | 63 | def _search(self, query, fields=None, queryset=None): 64 | if queryset is None: 65 | queryset = self.all() 66 | 67 | if fields is None: 68 | fields = self.search_fields 69 | 70 | if not query or not fields: 71 | return queryset 72 | 73 | for keyword in normalize_query(query): 74 | negate = False 75 | if len(keyword) > 1: 76 | if keyword[0] == "-": 77 | keyword = keyword[1:] 78 | negate = True 79 | elif keyword[0] == "+": 80 | keyword = keyword[1:] 81 | 82 | if negate: 83 | q = reduce( 84 | lambda p, q: p & q, 85 | (~Q(**{"%s__icontains" % f: keyword}) for f in fields), 86 | Q(), 87 | ) 88 | else: 89 | q = reduce( 90 | lambda p, q: p | q, 91 | (Q(**{"%s__icontains" % f: keyword}) for f in fields), 92 | Q(), 93 | ) 94 | 95 | queryset = queryset.filter(q) 96 | 97 | return queryset 98 | -------------------------------------------------------------------------------- /towel/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/towel/4a8b7deda66b30072c38564381b60a5ec8e7ae87/towel/models.py -------------------------------------------------------------------------------- /towel/mt/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Assumptions 3 | =========== 4 | 5 | * The following settings are required: 6 | 7 | * ``TOWEL_MT_CLIENT_MODEL``: 8 | The tenant model, e.g. ``clients.Client``. 9 | * ``TOWEL_MT_ACCESS_MODEL``: 10 | The model linking a Django user with a client, must have the following 11 | fields: 12 | 13 | * ``user``: Foreign key to ``auth.User``. 14 | * ``access``: An integer describing the access level of the given user. 15 | Higher numbers mean higher access. You have to define those numbers 16 | yourself. 17 | * The lowercased class name of the client model above as a foreign key 18 | to the client model. If your client model is named ``Customer``, the 19 | name of this foreign key must be ``customer``. 20 | 21 | * All model managers have a ``for_access()`` method with a single argument, 22 | an instance of the access model, which returns a queryset containing only 23 | the objects the current user is allowed to see. The access model should be 24 | available as ``request.access``, which means that you are free to put 25 | anything there which can be understood by the ``for_access()`` methods. The 26 | ``request.access`` attribute is made available by the 27 | ``towel.mt.middleware.LazyAccessMiddleware`` middleware. 28 | * ``towel.mt.modelview.ModelView`` automatically fills in a ``created_by`` 29 | foreign key pointing to ``auth.User`` if it exists. 30 | * The form classes in ``towel.mt.forms``, those being ``ModelForm``, ``Form`` 31 | and ``SearchForm`` all require the request (the two former on initialization, 32 | the latter on ``post_init``). Model choice fields are postprocessed to only 33 | contain values from the current tenant. This does not work if you customize 34 | the ``choices`` field at the same time as setting the ``queryset``. If you 35 | do that you're on your own. 36 | * The model authentication backend ``towel.mt.auth.ModelBackend`` also allows 37 | email addresses as username. It preloads the access and client model and 38 | assigns it to ``request.user`` if possible. This is purely a convenience -- 39 | you are not required to use the backend. 40 | """ 41 | 42 | 43 | def client_model(): 44 | from django.apps import apps 45 | from django.conf import settings 46 | 47 | return apps.get_model(*settings.TOWEL_MT_CLIENT_MODEL.split(".")) 48 | 49 | 50 | def access_model(): 51 | from django.apps import apps 52 | from django.conf import settings 53 | 54 | return apps.get_model(*settings.TOWEL_MT_ACCESS_MODEL.split(".")) 55 | 56 | 57 | class AccessDecorator: 58 | def __new__(cls): 59 | instance = object.__new__(cls) 60 | from towel import mt 61 | 62 | mt._access_decorator = instance 63 | return instance 64 | 65 | def __call__(self, minimal): 66 | """ 67 | Apply this decorator to all views which should only be reachable by 68 | authenticated users with sufficient permissions:: 69 | 70 | from towel.mt import access 71 | @access(access.MANAGEMENT) 72 | def view(request): 73 | # This view is only available for users with staff and 74 | # manager access. 75 | """ 76 | from django.contrib.auth.decorators import login_required 77 | from django.core.exceptions import PermissionDenied 78 | from django.http import HttpResponse 79 | from django.utils.functional import wraps 80 | 81 | def decorator(view_func): 82 | @login_required 83 | def inner(request, *args, **kwargs): 84 | if not request.access: 85 | return self.handle_missing(request, *args, **kwargs) 86 | 87 | check = self.check_access(request, minimal) 88 | 89 | if check is True: 90 | return view_func(request, *args, **kwargs) 91 | elif isinstance(check, HttpResponse): 92 | return check 93 | raise PermissionDenied("Insufficient permissions") 94 | 95 | fn = wraps(view_func)(inner) 96 | fn.original_fn = view_func 97 | return fn 98 | 99 | return decorator 100 | 101 | def check_access(self, request, minimal): 102 | return request.access.access >= minimal 103 | 104 | def handle_missing(self, request, *args, **kwargs): 105 | from django.core.exceptions import PermissionDenied 106 | 107 | raise PermissionDenied("Missing permissions") 108 | -------------------------------------------------------------------------------- /towel/mt/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Making ``towel.api`` multitenancy-aware 3 | ======================================= 4 | 5 | All you need is a view decorator handling the permissions and a resource 6 | subclass which makes sure that data is only ever shown from one tenant. 7 | """ 8 | 9 | 10 | from functools import wraps 11 | 12 | from django.http import HttpResponse 13 | from six.moves import http_client 14 | 15 | from towel import api 16 | from towel.utils import safe_queryset_and 17 | 18 | 19 | def api_access(minimal): 20 | """ 21 | Decorator which ensures that the current ``request.access`` model 22 | provides at least ``minimal`` access. 23 | """ 24 | 25 | def _decorator(func): 26 | @wraps(func) 27 | def _fn(request, *args, **kwargs): 28 | if not request.access: 29 | return HttpResponse("No access", status=http_client.UNAUTHORIZED) 30 | 31 | if request.access.access < minimal: 32 | return HttpResponse( 33 | "Insufficient access", status=http_client.UNAUTHORIZED 34 | ) 35 | 36 | return func(request, *args, **kwargs) 37 | 38 | return _fn 39 | 40 | return _decorator 41 | 42 | 43 | class Resource(api.Resource): 44 | """ 45 | Resource subclass which automatically applies filtering by 46 | ``request.access`` to all querysets used. 47 | """ 48 | 49 | def get_query_set(self): 50 | return safe_queryset_and( 51 | super().get_query_set(), 52 | self.model.objects.for_access(self.request.access), 53 | ) 54 | -------------------------------------------------------------------------------- /towel/mt/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | Authentication backend which preloads access and client models 3 | ============================================================== 4 | """ 5 | 6 | 7 | from django.contrib.auth.models import User 8 | 9 | from towel.auth import ModelBackend as _ModelBackend 10 | from towel.mt import access_model, client_model 11 | 12 | 13 | class ModelBackend(_ModelBackend): 14 | """ 15 | Custom authentication backend for towel-mt 16 | 17 | This authentication backend serves two purposes: 18 | 19 | 1. Allowing email addresses as usernames (``authenticate``) 20 | 2. Minimizing DB accesses by fetching additional information about the 21 | current user earlier (``get_user``) 22 | """ 23 | 24 | def get_user(self, user_id): 25 | Access = access_model() 26 | Client = client_model() 27 | try: 28 | access = Access.objects.select_related( 29 | "user", 30 | Client.__name__.lower(), 31 | ).get(user=user_id) 32 | 33 | # Ensure reverse accesses do not needlessly query the DB again. 34 | # Maybe Django already does that for us already... whatever. 35 | setattr(access.user, User.access.cache_name, access) 36 | return access.user 37 | except Access.DoesNotExist: 38 | pass 39 | 40 | try: 41 | # Fall back to raw user access 42 | return User.objects.get(id=user_id) 43 | except User.DoesNotExist: 44 | pass 45 | 46 | return None 47 | -------------------------------------------------------------------------------- /towel/mt/forms.py: -------------------------------------------------------------------------------- 1 | """ 2 | Forms 3 | ===== 4 | 5 | These three form subclasses will automatically add limitation by tenant 6 | to all form fields with a ``queryset`` attribute. 7 | 8 | .. warning:: 9 | 10 | If you customized the dropdown using ``choices`` you have to limit the 11 | choices by the current tenant yourself. 12 | """ 13 | 14 | 15 | from django import forms 16 | from django.core.exceptions import FieldDoesNotExist 17 | 18 | from towel import forms as towel_forms 19 | from towel.mt import client_model 20 | from towel.utils import safe_queryset_and 21 | 22 | 23 | def _process_fields(form, request): 24 | for field in form.fields.values(): 25 | if hasattr(field, "queryset"): 26 | model = field.queryset.model 27 | 28 | field.queryset = safe_queryset_and( 29 | field.queryset, 30 | model.objects.for_access(request.access), 31 | ) 32 | 33 | 34 | class Form(forms.Form): 35 | def __init__(self, *args, **kwargs): 36 | self.request = kwargs.pop("request") 37 | super().__init__(*args, **kwargs) 38 | _process_fields(self, self.request) 39 | 40 | 41 | class ModelForm(forms.ModelForm): 42 | def __init__(self, *args, **kwargs): 43 | self.request = kwargs.pop("request") 44 | super().__init__(*args, **kwargs) 45 | _process_fields(self, self.request) 46 | 47 | def save(self, commit=True): 48 | Client = client_model() 49 | attr = Client.__name__.lower() 50 | try: 51 | field = self.instance._meta.get_field(attr) 52 | except FieldDoesNotExist: 53 | field = None 54 | if field and field.related_model and issubclass(field.related_model, Client): 55 | setattr(self.instance, attr, getattr(self.request.access, attr)) 56 | 57 | return super().save(commit=commit) 58 | 59 | 60 | class SearchForm(towel_forms.SearchForm): 61 | def post_init(self, request): 62 | self.request = request 63 | _process_fields(self, self.request) 64 | 65 | 66 | class BatchForm(towel_forms.BatchForm): 67 | def __init__(self, *args, **kwargs): 68 | super().__init__(*args, **kwargs) 69 | _process_fields(self, self.request) 70 | -------------------------------------------------------------------------------- /towel/mt/middleware.py: -------------------------------------------------------------------------------- 1 | """ 2 | Middleware for a lazy ``request.access`` attribute 3 | ================================================== 4 | """ 5 | 6 | 7 | from django.db.models import ObjectDoesNotExist 8 | from django.utils.deprecation import MiddlewareMixin 9 | from django.utils.functional import SimpleLazyObject 10 | 11 | 12 | def get_access(request): 13 | try: 14 | return request.user.access 15 | except (AttributeError, ObjectDoesNotExist): 16 | return None 17 | 18 | 19 | class LazyAccessMiddleware(MiddlewareMixin): 20 | """ 21 | This middleware (or something equivalent providing a ``request.access`` 22 | attribute must be put in ``MIDDLEWARE_CLASSES`` to use the helpers in 23 | ``towel.mt``. 24 | """ 25 | 26 | def process_request(self, request): 27 | request.access = SimpleLazyObject(lambda: get_access(request)) 28 | -------------------------------------------------------------------------------- /towel/mt/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Models for multitenant Django projects 3 | ====================================== 4 | 5 | The models for ``towel.mt`` have to be provided by the project where 6 | ``towel.mt`` is used, that's why this file is empty. 7 | 8 | The simplest models might look like that:: 9 | 10 | from django.contrib.auth.models import User 11 | from django.db import models 12 | 13 | 14 | class Client(models.Model): 15 | name = models.CharField(max_length=100) 16 | 17 | 18 | class Access(models.Model): 19 | EMPLOYEE = 10 20 | MANAGEMENT = 20 21 | 22 | ACCESS_CHOICES = ( 23 | (EMPLOYEE, 'employee'), 24 | (MANAGEMENT, 'management'), 25 | ) 26 | 27 | client = models.ForeignKey(Client) 28 | user = models.OneToOneField(User) 29 | access = models.SmallIntegerField(choices=ACCESS_CHOICES) 30 | 31 | 32 | API methods can be protected as follows:: 33 | 34 | from towel.api import API 35 | from towel.api.decorators import http_basic_auth 36 | from towel.mt.api import Resource, api_access 37 | 38 | # Require a valid login and an associated Access model: 39 | api_v1 = API('v1', decorators=[ 40 | csrf_exempt, 41 | http_basic_auth, 42 | api_access(Access.EMPLOYEE), 43 | ]) 44 | api_v1.register(SomeModel, 45 | view_class=Resource, 46 | ) 47 | 48 | 49 | Other views:: 50 | 51 | from towel.mt import AccessDecorator 52 | 53 | # Do this once somewhere in your project 54 | access = AccessDecorator() 55 | 56 | 57 | @access(Access.MANAGEMENT) 58 | def management_only_view(request): 59 | # ... 60 | """ 61 | # Intentionally left empty. 62 | -------------------------------------------------------------------------------- /towel/mt/modelview.py: -------------------------------------------------------------------------------- 1 | """ 2 | ``ModelView`` 3 | ============= 4 | == 5 | As long as you use this class, everything should just work (tm). 6 | """ 7 | 8 | 9 | import towel.mt 10 | from towel import modelview as towel_modelview 11 | from towel.mt.forms import ModelForm 12 | 13 | 14 | class ModelView(towel_modelview.ModelView): 15 | """ 16 | This model view subclass ensures that all querysets are already 17 | restricted to the current client. 18 | 19 | Furthermore, it requires certain access levels when accessing its 20 | views; the required access defaults to ``access.MANAGER`` (the 21 | highest access level), but can be overridden by setting 22 | ``view_access`` or ``crud_access`` when instantiating the model 23 | view. 24 | """ 25 | 26 | #: Default access level for all views 27 | view_access = None 28 | 29 | #: Default access level for CRUD views, falls back to ``view_access`` 30 | #: if not explicitly set 31 | crud_access = None 32 | 33 | #: The editing form class, defaults to ``towel.mt.forms.ModelForm`` 34 | #: instead of ``django.forms.ModelForm`` 35 | form_class = ModelForm 36 | 37 | def view_decorator(self, func): 38 | return towel.mt._access_decorator(self.view_access)(func) 39 | 40 | def crud_view_decorator(self, func): 41 | return towel.mt._access_decorator(self.crud_access or self.view_access)(func) 42 | 43 | def get_query_set(self, request, *args, **kwargs): 44 | return self.model.objects.for_access(request.access) 45 | 46 | def get_form_instance( 47 | self, request, form_class, instance=None, change=None, **kwargs 48 | ): 49 | args = self.extend_args_if_post(request, []) 50 | kwargs.update( 51 | {"instance": instance, "request": request} # towel.mt.forms needs that 52 | ) 53 | 54 | return form_class(*args, **kwargs) 55 | -------------------------------------------------------------------------------- /towel/paginator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Drop-in replacement for Django's ``django.core.paginator`` with additional 3 | goodness 4 | 5 | Django's paginator class has a ``page_range`` method returning a list of all 6 | available pages. If you got lots and lots of pages this is not very helpful. 7 | Towel's page class (**not** paginator class!) sports a ``page_range`` method 8 | too which only returns a few pages at the beginning and at the end of the page 9 | range and a few pages around the current page. 10 | 11 | All you have to do to use this module is replacing all imports from 12 | ``django.core.paginator`` with ``towel.paginator``. All important classes and 13 | all exceptions are available inside this module too. 14 | 15 | The page range parameters can be customized by adding a ``PAGINATION`` setting. 16 | The defaults are as follows:: 17 | 18 | PAGINATION = { 19 | 'START': 6, # pages at the beginning of the range 20 | 'END': 6, # pages at the end of the range 21 | 'AROUND': 5, # pages around the current page 22 | } 23 | """ 24 | 25 | 26 | from django.conf import settings 27 | from django.core import paginator 28 | 29 | 30 | __all__ = ("InvalidPage", "PageNotAnInteger", "EmptyPage", "Paginator", "Page") 31 | 32 | 33 | # Import useful exceptions into the local scope 34 | InvalidPage = paginator.InvalidPage 35 | PageNotAnInteger = paginator.PageNotAnInteger 36 | EmptyPage = paginator.EmptyPage 37 | 38 | 39 | #: Paginator configuration 40 | PAGINATION = getattr( 41 | settings, 42 | "PAGINATION", 43 | { 44 | "START": 6, # items at the start 45 | "END": 6, # items at the end 46 | "AROUND": 5, # items around the current page 47 | }, 48 | ) 49 | 50 | 51 | def filter_adjacent(iterable): 52 | """Collapse identical adjacent values""" 53 | # Generate an object guaranteed to not exist inside the iterable 54 | current = type("Marker", (object,), {}) 55 | 56 | for item in iterable: 57 | if item != current: 58 | current = item 59 | yield item 60 | 61 | 62 | class Paginator(paginator.Paginator): 63 | """ 64 | Custom paginator returning a Page object with an additional page_range 65 | method which can be used to implement Digg-style pagination 66 | """ 67 | 68 | def page(self, number): 69 | return Page(paginator.Paginator.page(self, number)) 70 | 71 | 72 | class Page(paginator.Page): 73 | """ 74 | Page object for Digg-style pagination 75 | """ 76 | 77 | def __init__(self, page): 78 | # We do not call super.__init__, because we're only a wrapper / proxy 79 | self.__dict__ = page.__dict__ 80 | 81 | @property 82 | def page_range(self): 83 | """ 84 | Generates a list for displaying Digg-style pagination 85 | 86 | The page numbers which are left out are indicated with a ``None`` 87 | value. Please note that Django's paginator own ``page_range`` method 88 | isn't overwritten -- Django's ``page_range`` is a method of the 89 | ``Paginator`` class, not the ``Page`` class. 90 | 91 | Usage:: 92 | 93 | {% for p in page.page_range %} 94 | {% if p == page.number %} 95 | {{ p }} 96 | {% else %} 97 | {% if p is None %} 98 | … 99 | {% else %} 100 | {{ p }} 101 | {% endif %} 102 | {% endif %} 103 | {% endfor %} 104 | """ 105 | return filter_adjacent(self._generate_page_range()) 106 | 107 | def _generate_page_range(self): 108 | num_pages = self.paginator.num_pages 109 | 110 | for i in range(1, num_pages + 1): 111 | if i <= PAGINATION["START"]: 112 | yield i 113 | 114 | elif i > num_pages - PAGINATION["END"]: 115 | yield i 116 | 117 | elif abs(self.number - i) <= PAGINATION["AROUND"]: 118 | yield i 119 | 120 | else: 121 | yield None # Ellipsis marker 122 | -------------------------------------------------------------------------------- /towel/queryset_transform.py: -------------------------------------------------------------------------------- 1 | # Straight import from https://github.com/simonw/django-queryset-transform 2 | 3 | """ 4 | django_queryset_transform 5 | ========================= 6 | 7 | Allows you to register a transforming map function with a Django QuerySet 8 | that will be executed only when the QuerySet itself has been evaluated. 9 | 10 | This allows you to build optimisations like "fetch all tags for these 10 rows" 11 | while still benefiting from Django's lazy QuerySet evaluation. 12 | 13 | For example:: 14 | 15 | def lookup_tags(item_qs): 16 | item_pks = [item.pk for item in item_qs] 17 | m2mfield = Item._meta.get_field('tags')[0] 18 | tags_for_item = Tag.objects.filter( 19 | item__in = item_pks 20 | ).extra(select = { 21 | 'item_id': '%s.%s' % ( 22 | m2mfield.m2m_db_table(), m2mfield.m2m_column_name() 23 | ) 24 | }) 25 | tag_dict = {} 26 | for tag in tags_for_item: 27 | tag_dict.setdefault(tag.item_id, []).append(tag) 28 | for item in item_qs: 29 | item.fetched_tags = tag_dict.get(item.pk, []) 30 | 31 | qs = Item.objects.filter(name__contains = 'e').transform(lookup_tags) 32 | 33 | for item in qs: 34 | print(item, item.fetched_tags) 35 | 36 | Prints:: 37 | 38 | Winter comes to Ogglesbrook [, , , ] 39 | Summer now [, ] 40 | 41 | But only executes two SQL queries - one to fetch the items, and one to fetch 42 | ALL of the tags for those items. 43 | 44 | Since the transformer function can transform an evaluated QuerySet, it 45 | doesn't need to make extra database calls at all - it should work for things 46 | like looking up additional data from a cache.multi_get() as well. 47 | 48 | Originally inspired by http://github.com/lilspikey/django-batch-select/ 49 | 50 | 51 | 52 | LICENSE 53 | ======= 54 | 55 | Copyright (c) 2010, Simon Willison. 56 | All rights reserved. 57 | 58 | Redistribution and use in source and binary forms, with or without 59 | modification, are permitted provided that the following conditions are met: 60 | 61 | 1. Redistributions of source code must retain the above copyright notice, 62 | this list of conditions and the following disclaimer. 63 | 64 | 2. Redistributions in binary form must reproduce the above copyright 65 | notice, this list of conditions and the following disclaimer in the 66 | documentation and/or other materials provided with the distribution. 67 | 68 | 3. Neither the name of Django nor the names of its contributors may be used 69 | to endorse or promote products derived from this software without 70 | specific prior written permission. 71 | 72 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 73 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 74 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 75 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 76 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 77 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 78 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 79 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 80 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 81 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 82 | """ 83 | 84 | 85 | from django.db import models 86 | 87 | 88 | class TransformQuerySet(models.query.QuerySet): 89 | def __init__(self, *args, **kwargs): 90 | super().__init__(*args, **kwargs) 91 | self._transform_fns = [] 92 | self._orig_iterable_class = getattr(self, "_iterable_class", None) 93 | 94 | def _clone(self, *args, **kwargs): 95 | c = super()._clone(*args, **kwargs) 96 | c._transform_fns = self._transform_fns[:] 97 | return c 98 | 99 | def transform(self, *fn): 100 | c = self._clone() 101 | c._transform_fns.extend(fn) 102 | return c 103 | 104 | def _fetch_all(self): 105 | super()._fetch_all() 106 | if getattr(self, "_iterable_class", None) == self._orig_iterable_class: # noqa 107 | for fn in self._transform_fns: 108 | fn(self._result_cache) 109 | 110 | 111 | if hasattr(models.Manager, "from_queryset"): 112 | TransformManager = models.Manager.from_queryset(TransformQuerySet) 113 | 114 | else: 115 | 116 | class TransformManager(models.Manager): 117 | def get_queryset(self): 118 | return TransformQuerySet(self.model, using=self._db) 119 | 120 | def transform(self, *fn): 121 | return self.get_queryset().transform(*fn) 122 | -------------------------------------------------------------------------------- /towel/quick.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module beefs up the default full text search field to be a little 3 | bit more versatile. It allows specifying patterns such as ``is:unread`` 4 | or ``!important`` which are extracted from the query string and returned 5 | as standalone values allowing the implementation of a search syntax 6 | known from f.e. Google Mail. 7 | 8 | Quick rules always consist of two parts: A regular expression pulling 9 | values out of the query string and a mapper which maps the values from 10 | the regex to something else which may be directly usable by forms. 11 | 12 | Usage example:: 13 | 14 | QUICK_RULES = [ 15 | (re.compile(r'!!'), quick.static(important=True)), 16 | (re.compile(r'@(?P\\w+)'), 17 | quick.model_mapper(User.objects.all(), 'assigned_to')), 18 | (re.compile(r'\\^\\+(?P\\d+)'), 19 | lambda v: {'due': date.today() + timedelta(days=int(v['due']))}), 20 | (re.compile(r'=(?P[\\d\\.]+)h'), 21 | quick.identity()), 22 | ] 23 | 24 | data, rest = quick.parse_quickadd( 25 | request.POST.get('quick', ''), 26 | QUICK_RULES) 27 | 28 | data['notes'] = ' '.join(rest) # Everything which could not be parsed 29 | # is added to the ``notes`` field. 30 | form = TicketForm(data) 31 | 32 | .. note:: 33 | 34 | The mappers always get the regex matches ``dict`` and return a 35 | ``dict``. 36 | """ 37 | 38 | 39 | from datetime import date, timedelta 40 | 41 | from django.utils import dateformat 42 | from django.utils.datastructures import MultiValueDict 43 | from django.utils.encoding import force_str 44 | from django.utils.translation import gettext as _ 45 | 46 | 47 | def parse_quickadd(quick, regexes): 48 | """ 49 | The main workhorse. Named ``parse_quickadd`` for historic reasons, 50 | can be used not only for adding but for searching etc. too. In fact, 51 | :class:`towel.forms.SearchForm` supports quick rules out of the box 52 | when they are specified in ``quick_rules``. 53 | """ 54 | 55 | data = {} 56 | rest = [] 57 | 58 | while quick: 59 | for regexp, extract in regexes: 60 | match = regexp.match(quick) 61 | if match: 62 | for key, value in extract(match.groupdict()).items(): 63 | data.setdefault(key, []).append(value) 64 | 65 | quick = quick[len(match.group(0)) : 9999].strip() 66 | break 67 | 68 | else: 69 | splitted = quick.split(" ", 1) 70 | if len(splitted) < 2: 71 | rest.append(quick) 72 | break 73 | 74 | rest.append(splitted[0]) 75 | quick = splitted[1] 76 | 77 | return MultiValueDict(data), rest 78 | 79 | 80 | def identity(): 81 | """ 82 | Identity mapper. Returns the values from the regular expression 83 | directly. 84 | """ 85 | return lambda value: value 86 | 87 | 88 | def model_mapper(queryset, attribute): 89 | """ 90 | The regular expression needs to return a dict which is directly passed 91 | to ``queryset.get()``. As a speciality, this mapper returns both the 92 | primary key of the instance under the ``attribute`` name, and the instance 93 | itself as ``attribute_``. 94 | """ 95 | 96 | def _fn(values): 97 | try: 98 | instance = queryset.get(**values) 99 | return { 100 | attribute: instance.pk, 101 | attribute + "_": instance, 102 | } 103 | except (queryset.model.DoesNotExist, KeyError, TypeError, ValueError): 104 | return {} 105 | 106 | return _fn 107 | 108 | 109 | def static(**kwargs): 110 | """ 111 | Return a predefined ``dict`` when the given regex matches. 112 | """ 113 | return lambda values: kwargs 114 | 115 | 116 | def model_choices_mapper(data, attribute): 117 | """ 118 | Needs a ``value`` provided by the regular expression and returns 119 | the corresponding ``key`` value. 120 | 121 | Example:: 122 | 123 | class Ticket(models.Model): 124 | VISIBILITY_CHOICES = ( 125 | ('public', _('public')), 126 | ('private', _('private')), 127 | ) 128 | visibility = models.CharField(choices=VISIBILITY_CHOICES) 129 | 130 | QUICK_RULES = [ 131 | (re.compile(r'~(?P[^\\s]+)'), quick.model_choices_mapper( 132 | Ticket.VISIBILITY_CHOICES, 'visibility')), 133 | ] 134 | """ 135 | 136 | def _fn(values): 137 | reverse = {force_str(value): key for key, value in data} 138 | try: 139 | return {attribute: reverse[values["value"]]} 140 | except KeyError: 141 | return {} 142 | 143 | return _fn 144 | 145 | 146 | def due_mapper(attribute): 147 | """ 148 | Understands ``Today``, ``Tomorrow``, the following five localized 149 | week day names or (partial) dates such as ``20.12.`` and ``01.03.2012``. 150 | """ 151 | 152 | def _fn(values): 153 | today = date.today() 154 | due = values["due"] 155 | 156 | days = [ 157 | (dateformat.format(d, "l"), d) 158 | for d in [(today + timedelta(days=d)) for d in range(2, 7)] 159 | ] 160 | days.append((_("Today"), today)) 161 | days.append((_("Tomorrow"), today + timedelta(days=1))) 162 | days = {k.lower(): value for k, value in days} 163 | 164 | if due.lower() in days: 165 | return {attribute: days[due.lower()]} 166 | 167 | day = [today.year, today.month, today.day] 168 | try: 169 | for i, n in enumerate(due.split(".")): 170 | day[2 - i] = int(n, 10) 171 | except (IndexError, TypeError, ValueError): 172 | pass 173 | 174 | try: 175 | return {attribute: date(*day)} 176 | except (TypeError, ValueError): 177 | pass 178 | 179 | return {} 180 | 181 | return _fn 182 | 183 | 184 | def bool_mapper(attribute): 185 | """ 186 | Maps ``yes``, ``1`` and ``on`` to ``True`` and ``no``, ``0`` 187 | and ``off`` to ``False``. 188 | """ 189 | 190 | def _fn(values): 191 | if values["bool"].lower() in ("yes", "1", "on", "true"): 192 | return {attribute: True} 193 | elif values["bool"].lower() in ("no", "0", "off", "false"): 194 | return {attribute: False} 195 | return {} 196 | 197 | return _fn 198 | -------------------------------------------------------------------------------- /towel/resources/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .base import ( 3 | AddView, 4 | DeleteView, 5 | DetailView, 6 | EditView, 7 | FormView, 8 | ListView, 9 | LiveFormView, 10 | LiveUpdateAfterEditMixin, 11 | ModelResourceView, 12 | PickerView, 13 | ) 14 | -------------------------------------------------------------------------------- /towel/resources/inlines.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is mostly equivalent with Django's inline formsets mechanism, but 3 | used together with editlive. 4 | """ 5 | 6 | 7 | import json 8 | 9 | from django.core.exceptions import PermissionDenied 10 | from django.forms.models import model_to_dict 11 | from django.http import HttpResponse 12 | from django.shortcuts import get_object_or_404, redirect 13 | 14 | from towel.resources.base import DeleteView, DetailView, FormView, LiveFormView 15 | from towel.utils import changed_regions 16 | 17 | 18 | class ChildMixin: 19 | base_template = "modal.html" 20 | parent_attr = "parent" 21 | 22 | def get_parent_class(self): 23 | return self.model._meta.get_field(self.parent_attr).related_model 24 | 25 | def get_parent_queryset(self): 26 | return self.get_parent_class()._default_manager.all() 27 | 28 | def get_parent(self): 29 | return get_object_or_404( 30 | self.get_parent_queryset(), pk=self.kwargs[self.parent_attr] 31 | ) 32 | 33 | def update_parent(self): 34 | regions = DetailView.render_regions( 35 | self, model=self.parent.__class__, object=self.parent 36 | ) 37 | 38 | return HttpResponse( 39 | json.dumps( 40 | changed_regions(regions, ["%s_set" % self.model.__name__.lower()]) 41 | ), 42 | content_type="application/json", 43 | ) 44 | 45 | 46 | class ChildFormView(ChildMixin, FormView): 47 | def get_form_kwargs(self, **kwargs): 48 | kwargs["prefix"] = self.model.__name__.lower() 49 | return super(ChildMixin, self).get_form_kwargs(**kwargs) 50 | 51 | def form_valid(self, form): 52 | setattr(form.instance, self.parent_attr, self.parent) 53 | self.object = form.save() 54 | return self.update_parent() 55 | 56 | 57 | class ChildAddView(ChildFormView): 58 | def get(self, request, *args, **kwargs): 59 | if not self.allow_add(silent=False): 60 | return redirect(self.url("list")) 61 | self.parent = self.get_parent() 62 | form = self.get_form() 63 | return self.render_to_response(self.get_context_data(form=form)) 64 | 65 | def post(self, request, *args, **kwargs): 66 | if not self.allow_add(silent=False): 67 | return redirect(self.url("list")) 68 | self.parent = self.get_parent() 69 | form = self.get_form() 70 | if form.is_valid(): 71 | return self.form_valid(form) 72 | return self.form_invalid(form) 73 | 74 | 75 | class ChildEditView(ChildFormView): 76 | def get(self, request, *args, **kwargs): 77 | self.object = self.get_object() 78 | self.parent = getattr(self.object, self.parent_attr) 79 | if not self.allow_edit(self.object, silent=False): 80 | return redirect(self.object) 81 | form = self.get_form() 82 | context = self.get_context_data(form=form, object=self.object) 83 | return self.render_to_response(context) 84 | 85 | def post(self, request, *args, **kwargs): 86 | self.object = self.get_object() 87 | self.parent = getattr(self.object, self.parent_attr) 88 | if not self.allow_edit(self.object, silent=False): 89 | return redirect(self.object) 90 | form = self.get_form() 91 | if form.is_valid(): 92 | return self.form_valid(form) 93 | return self.form_invalid(form) 94 | 95 | 96 | class LiveChildFormView(ChildMixin, LiveFormView): 97 | def post(self, request, *args, **kwargs): 98 | self.object = self.get_object() 99 | self.parent = getattr(self.object, self.parent_attr) 100 | if not self.allow_edit(self.object, silent=True): 101 | raise PermissionDenied 102 | 103 | form_class = self.get_form_class() 104 | data = model_to_dict( 105 | self.object, 106 | fields=form_class._meta.fields, 107 | exclude=form_class._meta.exclude, 108 | ) 109 | 110 | for key, value in request.POST.items(): 111 | data[key] = value 112 | 113 | form = form_class(**self.get_form_kwargs(data=data)) 114 | 115 | if form.is_valid(): 116 | return self.form_valid(form) 117 | 118 | # TODO that's actually quite ugly 119 | return HttpResponse("%s" % form.errors) 120 | 121 | def form_valid(self, form): 122 | self.object = form.save() 123 | return self.update_parent() 124 | 125 | 126 | class ChildDeleteView(ChildMixin, DeleteView): 127 | def deletion_form_valid(self, form): 128 | """ 129 | On successful form validation, the object is deleted and the user is 130 | redirected to the list view of the model. 131 | """ 132 | self.parent = self.get_parent() 133 | self.object.delete() 134 | return self.update_parent() 135 | -------------------------------------------------------------------------------- /towel/resources/mt.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | 3 | from towel.utils import safe_queryset_and 4 | 5 | 6 | class MultitenancyMixin: 7 | def get_queryset(self): 8 | if self.queryset is not None: 9 | return safe_queryset_and( 10 | self.queryset, 11 | self.queryset.model._default_manager.for_access(self.request.access), 12 | ) 13 | elif self.model is not None: 14 | return self.model._default_manager.for_access(self.request.access) 15 | else: 16 | raise ImproperlyConfigured( 17 | "'%s' must define 'queryset' or 'model'" % self.__class__.__name__ 18 | ) 19 | 20 | def get_parent_queryset(self): 21 | # towel.resources.inlines.ChildFormView 22 | return self.get_parent_class()._default_manager.for_access(self.request.access) 23 | 24 | def get_form_kwargs(self, **kwargs): 25 | kwargs["request"] = self.request 26 | return super().get_form_kwargs(**kwargs) 27 | -------------------------------------------------------------------------------- /towel/resources/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | from django.urls import NoReverseMatch, reverse 3 | 4 | from towel import resources 5 | from towel.utils import app_model_label 6 | 7 | 8 | class _MRUHelper: 9 | def __init__(self, viewname_pattern, kwargs): 10 | self.viewname_pattern = viewname_pattern 11 | self.kwargs = kwargs 12 | 13 | def __getitem__(self, item): 14 | return self.url(item) 15 | 16 | def url(self, item, *args, **kwargs): 17 | kw = self.kwargs 18 | if kwargs: 19 | kw = kw.copy() 20 | kw["kwargs"].update(kwargs) 21 | 22 | try: 23 | return reverse(self.viewname_pattern % item, **kw) 24 | except NoReverseMatch as e: 25 | try: 26 | return reverse(self.viewname_pattern % item) 27 | except NoReverseMatch: 28 | # Re-raise exception with kwargs; it's more informative 29 | raise e 30 | 31 | 32 | def model_resource_urls( 33 | reverse_kwargs_fn=lambda object: {"pk": object.pk}, default="detail" 34 | ): 35 | """ 36 | Usage:: 37 | 38 | @model_resource_urls() 39 | class MyModel(models.Model): 40 | pass 41 | 42 | instance = MyModel.objects.get(...) 43 | instance.urls.url('detail') == instance.get_absolute_url() 44 | """ 45 | 46 | def _dec(cls): 47 | class _descriptor: 48 | def __get__(self, obj, objtype=None): 49 | viewname_pattern = "%s_%s_%%s" % app_model_label(obj) 50 | kwargs = {"kwargs": reverse_kwargs_fn(obj)} 51 | helper = obj.__dict__["urls"] = _MRUHelper(viewname_pattern, kwargs) 52 | return helper 53 | 54 | cls.urls = _descriptor() 55 | cls.get_absolute_url = lambda self: self.urls.url(default) 56 | return cls 57 | 58 | return _dec 59 | 60 | 61 | def resource_url_fn( 62 | model, urlconf_detail_re=r"(?P\d+)", mixins=(), decorators=(), **kwargs 63 | ): 64 | """ 65 | Returns a helper function most useful to easily create URLconf entries 66 | for model resources. 67 | 68 | The list of decorators should be ordered from the outside to the inside, 69 | in the same order as you would write them when using the ``@decorator`` 70 | syntax. 71 | 72 | Usage:: 73 | 74 | project_url = resource_url_fn( 75 | Project, 76 | mixins=(ProjectViewMixin,), 77 | decorators=(login_required,), 78 | ) 79 | urlpatterns = [ 80 | project_url('list', url=r'^$', paginate_by=50), 81 | project_url('detail', url=r'^(?P\\d+)/$'), 82 | project_url('add', url=r^add/$'), 83 | project_url('edit'), 84 | project_url('delete'), 85 | ] 86 | 87 | # the project URLs will be: 88 | # ^$ 89 | # ^(?P\\d+)/$ 90 | # ^add/$' 91 | # ^(?P\\d+)/edit/$ 92 | # ^(?P\\d+)/delete/$ 93 | 94 | The returned helper function comes with ``mixins`` and ``decorators`` 95 | arguments too. They default to the values passed into the 96 | ``resource_url_fn``. If you use those arguments, you have to pass the 97 | full list of mixins and/or decorators you need. You can pass an empty 98 | list if some view does not need any mixins and/or decorators. 99 | """ 100 | 101 | global_mixins = mixins 102 | global_decorators = decorators 103 | 104 | default_view_classes = { 105 | "list": resources.ListView, 106 | "detail": resources.DetailView, 107 | "add": resources.AddView, 108 | "edit": resources.EditView, 109 | "delete": resources.DeleteView, 110 | } 111 | 112 | def _fn( 113 | name, _sentinel=None, view=None, url=None, mixins=None, decorators=None, **kw 114 | ): 115 | 116 | if _sentinel is not None: 117 | raise TypeError("name is the only non-keyword") 118 | 119 | urlregex = rf"^{urlconf_detail_re}/{name}/$" if url is None else url 120 | 121 | urlname = "%s_%s_%s" % (app_model_label(model) + (name,)) 122 | 123 | mixins = global_mixins if mixins is None else mixins 124 | decorators = global_decorators if decorators is None else decorators 125 | 126 | kws = kwargs.copy() 127 | kws.update(kw) 128 | 129 | view = default_view_classes[name] if view is None else view 130 | view = type(view.__name__, mixins + (view,), {}) 131 | view = view.as_view(model=model, **kws) 132 | 133 | for dec in reversed(decorators): 134 | view = dec(view) 135 | 136 | return re_path(urlregex, view, name=urlname) 137 | 138 | return _fn 139 | -------------------------------------------------------------------------------- /towel/static/towel/towel.js: -------------------------------------------------------------------------------- 1 | function addInlineForm(slug, onComplete) { 2 | var totalForms = $("#id_" + slug + "-TOTAL_FORMS"), 3 | newId = parseInt(totalForms.val()) 4 | 5 | totalForms.val(newId + 1) 6 | var empty = $("#" + slug + "-empty"), 7 | attributes = ["id", "name", "for"], 8 | form = $(empty.html()) 9 | 10 | form.removeClass("empty").attr("id", slug + "-" + newId) 11 | 12 | for (var i = 0; i < attributes.length; ++i) { 13 | var attr = attributes[i] 14 | 15 | form.find("*[" + attr + "*=__prefix__]").each(function () { 16 | var el = $(this) 17 | el.attr(attr, el.attr(attr).replace(/__prefix__/, newId)) 18 | }) 19 | } 20 | 21 | // insert the form after the last sibling with the same tagName 22 | // cannot use siblings() here, because the empty element may be the 23 | // only one (if no objects exist until now) 24 | form 25 | .insertAfter(empty.parent().children("[id|=" + slug + "]:last")) 26 | .hide() 27 | .fadeIn() 28 | 29 | if (onComplete) onComplete(form) 30 | 31 | return false 32 | } 33 | 34 | // backwards compat 35 | window.towel_add_subform = addInlineForm 36 | -------------------------------------------------------------------------------- /towel/static/towel/towel_editlive.js: -------------------------------------------------------------------------------- 1 | ;(function ($) { 2 | var updateLive = function (data, context) { 3 | var context = $(context || document.body) 4 | 5 | $.each(data, function (key, value) { 6 | if (key == "!redirect") { 7 | window.location.href = value 8 | return false 9 | } else if (key == "!reload") { 10 | window.location.reload() 11 | return false 12 | } else if (key == "!form-errors") { 13 | context.find("small.error").remove() 14 | context.find(".error").removeClass("error") 15 | 16 | if (!value) return 17 | 18 | $.each(value, function (key, value) { 19 | var error = $(''), 20 | field = $("#id_" + key), 21 | container = field.closest(".field-" + key) 22 | 23 | if (value) { 24 | for (var i = 0; i < value.length; ++i) 25 | error.append(value[i] + "
    ") 26 | field.after(error) 27 | container.addClass("error") 28 | } 29 | }) 30 | 31 | return 32 | } else if (key[0] == "!") { 33 | // unknown command, skip. 34 | return 35 | } 36 | 37 | var elem = $("#" + key), 38 | update = elem.data("update") || "replace" 39 | 40 | switch (update) { 41 | case "append": 42 | elem.append(value) 43 | break 44 | case "prepend": 45 | elem.prepend(value) 46 | break 47 | default: 48 | elem.html(value) 49 | } 50 | 51 | elem.trigger("updateLive", [elem]) 52 | }) 53 | 54 | initializeForms() 55 | } 56 | if (!window.updateLive) window.updateLive = updateLive 57 | 58 | var editLive = function (action, attribute, value, callback, context) { 59 | var data = {} 60 | data[attribute] = value 61 | 62 | $.post(action, data, function (data) { 63 | if (typeof data == "string") { 64 | alert(data) 65 | } else { 66 | updateLive(data, context) 67 | } 68 | 69 | if (callback) { 70 | callback() 71 | } 72 | }) 73 | } 74 | 75 | var formFieldHandler = function (event) { 76 | var $this = $(this), 77 | action = $(this).data("action"), 78 | original = $this.data("original"), 79 | attribute = $this.data("attribute") 80 | 81 | if (!action || this.value == original) return 82 | 83 | editLive(action, $this.data("attribute"), this.value, function () { 84 | $this.trigger("editLive", [$this]) 85 | }) 86 | } 87 | 88 | $(document.body).on( 89 | "focusout", 90 | "input.editlive, textarea.editlive", 91 | formFieldHandler 92 | ) 93 | $(document.body).on("change", "input[type=hidden].editlive", formFieldHandler) 94 | 95 | $(document.body).on( 96 | "click", 97 | "input[type=checkbox].editlive", 98 | function (event) { 99 | var $this = $(this), 100 | action = $(this).data("action"), 101 | attribute = $this.data("attribute") 102 | 103 | if (!action) return 104 | 105 | editLive( 106 | action, 107 | $this.data("attribute"), 108 | $this.prop("checked") ? true : false, 109 | function () { 110 | $this.trigger("editLive", [$this]) 111 | } 112 | ) 113 | } 114 | ) 115 | 116 | $(document.body).on("click", "a.editlive, li.editlive", function (event) { 117 | event.stopPropagation() 118 | event.preventDefault() 119 | 120 | var $this = $(this), 121 | action = $(this).data("action"), 122 | value = $this.data("value"), 123 | original = $this.data("original") 124 | 125 | if (!action || value == original) return 126 | 127 | editLive(action, $this.data("attribute"), value) 128 | }) 129 | 130 | var initializeForms = function () { 131 | $("form.editlive") 132 | .not(".initialized") 133 | .each(function () { 134 | var $form = $(this), 135 | prefix = $form.data("form-prefix") || "", 136 | action = $form.attr("action") 137 | 138 | $form.on("submit", false) 139 | $form.addClass("initialized") 140 | $form.on("change", "input, textarea, select", function (event) { 141 | var source = $(this), 142 | name = this.name 143 | if ( 144 | this.tagName.toLowerCase() == "input" && 145 | source.attr("type") == "checkbox" 146 | ) { 147 | var source = $(this), 148 | name = this.name 149 | if (prefix) name = name.replace(prefix, "") 150 | editLive( 151 | action, 152 | name, 153 | this.checked, 154 | function () { 155 | source.trigger("editLive", [source]) 156 | }, 157 | $form 158 | ) 159 | } else { 160 | if (prefix) name = name.replace(prefix, "") 161 | editLive( 162 | action, 163 | name, 164 | this.value, 165 | function () { 166 | source.trigger("editLive", [source]) 167 | }, 168 | $form 169 | ) 170 | } 171 | }) 172 | }) 173 | } 174 | initializeForms() 175 | })(jQuery) 176 | -------------------------------------------------------------------------------- /towel/templates/modelview/object_delete_confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends base_template|default:"base.html" %} 2 | 3 | {% load i18n towel_form_tags %} 4 | 5 | {% block title %}{{ title }} {{ object }} - {{ block.super }}{% endblock %} 6 | 7 | {% block content %} 8 |

    {{ title }}: {{ object }}

    9 | 10 |

    {% blocktrans %}Do you really want to delete {{ object }}?{% endblocktrans %}

    11 | 12 | {% if collected_objects %} 13 |

    {% trans "You are about to delete the following objects:" %} 14 |

      15 | {% for opts, count in collected_objects %} 16 |
    • {{ count }} {% if count == 1 %}{{ opts.verbose_name }}{% else %}{{ opts.verbose_name_plural }}{% endif %}
    • 17 | {% endfor %} 18 |
    19 | {% endif %} 20 | 21 |
    {% csrf_token %} 22 | {% form_errors form %} 23 | {% form_warnings form %} 24 | {% form_items form %} 25 |
    26 | 27 | 28 | {% trans "cancel"|capfirst %} 29 |
    30 |
    31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /towel/templates/modelview/object_detail.html: -------------------------------------------------------------------------------- 1 | {% extends base_template|default:"base.html" %} 2 | 3 | {% load i18n modelview_detail %} 4 | 5 | {% block title %}{{ object }} - {{ block.super }}{% endblock %} 6 | 7 | {% block content %} 8 |

    {{ object }}

    9 | 10 | 11 | {% for title, value in object|model_details %} 12 | 13 | 14 | 15 | 16 | {% endfor %} 17 |
    {{ title|capfirst }}{{ value }}
    18 | 19 | 23 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /towel/templates/modelview/object_form.html: -------------------------------------------------------------------------------- 1 | {% extends base_template|default:"base.html" %} 2 | 3 | {% load i18n towel_form_tags verbose_name_tags %} 4 | 5 | {% block title %}{{ title }} - {{ block.super }}{% endblock %} 6 | 7 | {% block content %} 8 |

    9 | {% if object %}{{ title }}: {{ object }} 10 | {% else %}{{ title }} 11 | {% endif %} 12 |

    13 | 14 |
    {% csrf_token %} 15 | 16 | {% form_errors form formsets %} 17 | {% form_warnings form formsets %} 18 | 19 |
    20 | {% for field in form %}{% form_item field %}{% endfor %} 21 |
    22 | 23 | {% for key, formset in formsets.items %} 24 |
    25 | {{ formset|verbose_name_plural }} 26 | {{ formset.management_form }} 27 | {% dynamic_formset formset key %} 28 |
    29 | {% for field in form %}{% form_item field %}{% endfor %} 30 |
    31 | {% enddynamic_formset %} 32 | 36 |
    37 | {% endfor %} 38 | 39 |
    40 | 41 | 42 | 43 | {% trans "cancel"|capfirst %} 44 |
    45 | 46 |
    47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /towel/templates/modelview/object_list.html: -------------------------------------------------------------------------------- 1 | {% extends base_template|default:"base.html" %} 2 | 3 | {% load i18n modelview_list towel_batch_tags towel_form_tags %} 4 | 5 | {% block title %}{{ verbose_name_plural }} - {{ block.super }}{% endblock %} 6 | 7 | {% block content %} 8 | 9 |
    10 | {% if adding_allowed %} 11 | {% blocktrans %}Add {{ verbose_name }}{% endblocktrans %}{% endif %} 12 |
    13 | 14 | {% block search %} 15 | {% if search_form %} 16 | 30 | {% endif %} 31 | {% endblock %} 32 | 33 | {% if paginator %}{% pagination page paginator "top" %}{% endif %} 34 | 35 | {% if batch_form %}
    {% csrf_token %}{% endif %} 36 | 37 | {% block objects %} 38 | 39 | 40 | 41 | {% if batch_form %}{% endif %} 42 | 43 | 44 | 45 | 46 | {% for object in object_list %} 47 | 48 | {% if batch_form %}{% endif %} 49 | 50 | 51 | {% endfor %} 52 | 53 |
    {% trans "object"|capfirst %}
    {% batch_checkbox batch_form object.id %}{{ object }}
    54 | {% endblock %} 55 | 56 | {% if paginator %}{% pagination page paginator "bottom" %}{% endif %} 57 | 58 | {% if batch_form %} 59 | {% form_errors batch_form %} 60 |
    61 |

    {% trans "Batch form" %}

    62 | 63 | 64 | {% for field in batch_form %}{% form_item field %}{% endfor %}
    65 | 66 | {% if batch_items %} 67 | {% for item in batch_items %} 68 | {{ item }} 69 |
    70 | {% endfor %} 71 | {% endif %} 72 | 73 | {% block batch_buttons %} 74 |
    75 | 76 |
    77 | {% endblock %} 78 |
    79 |
    80 | {% endif %} 81 | 82 | {% endblock %} 83 | -------------------------------------------------------------------------------- /towel/templates/resources/object_delete_confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends base_template|default:"base.html" %} 2 | 3 | {% load i18n towel_form_tags %} 4 | 5 | {% block title %}{{ title }} {{ object }} - {{ block.super }}{% endblock %} 6 | 7 | {% block content %} 8 |

    {{ title }}: {{ object }}

    9 | 10 |

    {% blocktrans %}Do you really want to delete {{ object }}?{% endblocktrans %}

    11 | 12 | {% if collected_objects %} 13 |

    {% trans "You are about to delete the following objects:" %} 14 |

      15 | {% for opts, count in collected_objects %} 16 |
    • {{ count }} {% if count == 1 %}{{ opts.verbose_name }}{% else %}{{ opts.verbose_name_plural }}{% endif %}
    • 17 | {% endfor %} 18 |
    19 | {% endif %} 20 | 21 |
    {% csrf_token %} 22 | {% form_errors form %} 23 | {% form_warnings form %} 24 | {% form_items form %} 25 |
    26 | 27 | 28 | {% trans "cancel"|capfirst %} 29 |
    30 |
    31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /towel/templates/resources/object_detail.html: -------------------------------------------------------------------------------- 1 | {% extends base_template|default:"base.html" %} 2 | 3 | {% load i18n modelview_detail %} 4 | 5 | {% block title %}{{ object }} - {{ block.super }}{% endblock %} 6 | 7 | {% block content %} 8 |

    {{ object }}

    9 | 10 | 11 | {% for title, value in object|model_details %} 12 | 13 | 14 | 15 | 16 | {% endfor %} 17 |
    {{ title|capfirst }}{{ value }}
    18 | 19 | 23 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /towel/templates/resources/object_form.html: -------------------------------------------------------------------------------- 1 | {% extends base_template|default:"base.html" %} 2 | 3 | {% load i18n towel_form_tags verbose_name_tags %} 4 | 5 | {% block title %}{{ title }} - {{ block.super }}{% endblock %} 6 | 7 | {% block content %} 8 |

    9 | {% if object %}{{ title }}: {{ object }} 10 | {% else %}{{ title }} 11 | {% endif %} 12 |

    13 | 14 |
    {% csrf_token %} 15 | 16 | {% form_errors form formsets %} 17 | {% form_warnings form formsets %} 18 | 19 |
    20 | {% for field in form %}{% form_item field %}{% endfor %} 21 |
    22 | 23 | {% for key, formset in formsets.items %} 24 |
    25 | {{ formset|verbose_name_plural }} 26 | {{ formset.management_form }} 27 | {% dynamic_formset formset key %} 28 |
    29 | {% for field in form %}{% form_item field %}{% endfor %} 30 |
    31 | {% enddynamic_formset %} 32 | 36 |
    37 | {% endfor %} 38 | 39 |
    40 | 41 | 42 | 43 | {% trans "cancel"|capfirst %} 44 |
    45 | 46 |
    47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /towel/templates/resources/object_list.html: -------------------------------------------------------------------------------- 1 | {% extends base_template|default:"base.html" %} 2 | 3 | {% load i18n modelview_list towel_batch_tags towel_form_tags %} 4 | 5 | {% block title %}{{ verbose_name_plural }} - {{ block.super }}{% endblock %} 6 | 7 | {% block content %} 8 | 9 |
    10 | {% if adding_allowed %} 11 | {% blocktrans %}Add {{ verbose_name }}{% endblocktrans %}{% endif %} 12 |
    13 | 14 | {% block search %} 15 | {% if search_form %} 16 | 30 | {% endif %} 31 | {% endblock %} 32 | 33 | {% if paginator %}{% pagination page paginator "top" %}{% endif %} 34 | 35 | {% if batch_form %}
    {% csrf_token %}{% endif %} 36 | 37 | {% block objects %} 38 | 39 | 40 | 41 | {% if batch_form %}{% endif %} 42 | 43 | 44 | 45 | 46 | {% for object in object_list %} 47 | 48 | {% if batch_form %}{% endif %} 49 | 50 | 51 | {% endfor %} 52 | 53 |
    {% trans "object"|capfirst %}
    {% batch_checkbox batch_form object.id %}{{ object }}
    54 | {% endblock %} 55 | 56 | {% if paginator %}{% pagination page paginator "bottom" %}{% endif %} 57 | 58 | {% if batch_form %} 59 | {% form_errors batch_form %} 60 |
    61 |

    {% trans "Batch form" %}

    62 | 63 | 64 | {% for field in batch_form %}{% form_item field %}{% endfor %}
    65 | 66 | {% if batch_items %} 67 | {% for item in batch_items %} 68 | {{ item }} 69 |
    70 | {% endfor %} 71 | {% endif %} 72 | 73 | {% block batch_buttons %} 74 |
    75 | 76 |
    77 | {% endblock %} 78 |
    79 |
    80 | {% endif %} 81 | 82 | {% endblock %} 83 | -------------------------------------------------------------------------------- /towel/templates/towel/_form_errors.html: -------------------------------------------------------------------------------- 1 | {% load i18n verbose_name_tags %} 2 |
    3 | {% trans "Please correct the following errors:" %} 4 | 5 |
      6 | {% for form in forms %} 7 | {% if form.non_field_errors %} 8 |
    • {% trans "Form errors" %}: {{ form.non_field_errors }}
    • 9 | {% endif %} 10 | {% for field in form %} 11 | {% if field.errors %} 12 |
    • {{ field.label }}: {{ field.errors }}
    • 13 | {% endif %} 14 | {% endfor %} 15 | {% endfor %} 16 | 17 | {% for formset in formsets %} 18 | {% if formset.non_form_errors %} 19 |
    • {{ fieldset|verbose_name_plural|default:_("Fieldset errors") }}: 20 | {{ formset.non_form_errors }}
    • 21 | {% endif %} 22 | {% for form in formset.forms %} 23 | {% if form.non_field_errors %} 24 |
    • {% trans "Form errors" %}: {{ form.non_field_errors }}
    • 25 | {% endif %} 26 | {% for field in form %} 27 | {% if field.errors %} 28 |
    • {{ field.label }}: {{ field.errors }}
    • 29 | {% endif %} 30 | {% endfor %} 31 | {% endfor %} 32 | {% endfor %} 33 |
    34 |
    35 | -------------------------------------------------------------------------------- /towel/templates/towel/_form_item.html: -------------------------------------------------------------------------------- 1 | {% if "list" in type_class %} 2 |
    3 | 4 | {{ item }} 5 |
    6 | {% elif "hidden" in type_class %} 7 | {{ item }} 8 | {% else %} 9 |

    10 | {% if is_checkbox %}{{ item }}{% endif %} 11 | 12 | {% if not is_checkbox %}{{ item }}{% endif %} 13 |

    14 | {% endif %} 15 | -------------------------------------------------------------------------------- /towel/templates/towel/_form_item_plain.html: -------------------------------------------------------------------------------- 1 | 2 | {{ item }} 3 | 4 | -------------------------------------------------------------------------------- /towel/templates/towel/_form_warnings.html: -------------------------------------------------------------------------------- 1 | {% load i18n verbose_name_tags %} 2 |
    3 | {% trans "Please review the following warnings:" %} 4 | 5 |
      6 | {% for form in forms %} 7 | {% for warning in form.warnings %} 8 |
    • {{ warning }}
    • 9 | {% endfor %} 10 | {% if form.warnings %} 11 | 14 | {% endif %} 15 | {% endfor %} 16 | 17 | {% for formset in formsets %} 18 | {% for form in formset.forms %} 19 | {% for warning in form.warnings %} 20 |
    • {{ warning }}
    • 21 | {% endfor %} 22 | {% if form.warnings %} 23 | 26 | {% endif %} 27 | {% endfor %} 28 | {% endfor %} 29 |
    30 |
    31 | -------------------------------------------------------------------------------- /towel/templates/towel/_ordering_link.html: -------------------------------------------------------------------------------- 1 | {% comment %} 3 | nolinebreak{% endcomment %} {{ title|default:field }} 4 | -------------------------------------------------------------------------------- /towel/templates/towel/_pagination.html: -------------------------------------------------------------------------------- 1 | {% load i18n modelview_list %} 2 | 23 | -------------------------------------------------------------------------------- /towel/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/towel/4a8b7deda66b30072c38564381b60a5ec8e7ae87/towel/templatetags/__init__.py -------------------------------------------------------------------------------- /towel/templatetags/modelview_detail.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.db import models 3 | from django.utils.safestring import mark_safe 4 | from django.utils.translation import gettext as _ 5 | 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.filter 11 | def model_details(instance, fields=None): 12 | """ 13 | Returns a stream of ``verbose_name``, ``value`` pairs for the specified 14 | model instance:: 15 | 16 | 17 | {% for verbose_name, value in object|model_details %} 18 | 19 | 20 | 21 | 22 | {% endfor %} 23 |
    {{ verbose_name }}{{ value }}
    24 | """ 25 | 26 | if not fields: 27 | _fields = instance._meta.fields 28 | else: 29 | _fields = [instance._meta.get_field(f) for f in fields.split(",")] 30 | 31 | for f in _fields: 32 | if f.auto_created: 33 | continue 34 | 35 | if isinstance(f, models.ForeignKey): 36 | fk = getattr(instance, f.name) 37 | if hasattr(fk, "get_absolute_url"): 38 | try: 39 | value = mark_safe(f'{fk}') 40 | except Exception: # Whatever. 41 | value = fk 42 | else: 43 | value = fk 44 | 45 | elif f.choices: 46 | value = getattr(instance, "get_%s_display" % f.name)() 47 | 48 | elif isinstance(f, (models.BooleanField, models.NullBooleanField)): 49 | value = getattr(instance, f.name) 50 | value = {True: _("yes"), False: _("no"), None: _("unknown")}.get( 51 | value, value 52 | ) 53 | 54 | else: 55 | value = getattr(instance, f.name) 56 | 57 | yield (f.verbose_name, value) 58 | -------------------------------------------------------------------------------- /towel/templatetags/modelview_list.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.db import models 3 | from django.utils.safestring import mark_safe 4 | from django.utils.translation import gettext as _ 5 | 6 | from towel.templatetags import towel_resources 7 | 8 | 9 | register = template.Library() 10 | 11 | 12 | register.inclusion_tag("towel/_pagination.html", takes_context=True)( 13 | towel_resources.pagination 14 | ) 15 | register.inclusion_tag("towel/_ordering_link.html", takes_context=True)( 16 | towel_resources.ordering_link 17 | ) 18 | register.filter(towel_resources.querystring) 19 | 20 | 21 | @register.filter 22 | def model_row(instance, fields): 23 | """ 24 | Shows a row in a modelview object list: 25 | 26 | :: 27 | 28 | {% for object in object_list %} 29 | 30 | {% for verbose_name, field in object|model_row:"name,url" %} 31 | {{ field }} 32 | {% endfor %} 33 | 34 | {% endfor %} 35 | 36 | """ 37 | 38 | for name in fields.split(","): 39 | try: 40 | f = instance._meta.get_field(name) 41 | except models.FieldDoesNotExist: 42 | attr = getattr(instance, name) 43 | if hasattr(attr, "__call__"): 44 | yield (name, attr()) 45 | else: 46 | yield (name, attr) 47 | continue 48 | 49 | if isinstance(f, models.ForeignKey): 50 | fk = getattr(instance, f.name) 51 | if hasattr(fk, "get_absolute_url"): 52 | value = mark_safe(f'{fk}') 53 | else: 54 | value = fk 55 | 56 | elif f.choices: 57 | value = getattr(instance, "get_%s_display" % f.name)() 58 | 59 | elif isinstance(f, (models.BooleanField, models.NullBooleanField)): 60 | value = getattr(instance, f.name) 61 | value = {True: _("yes"), False: _("no"), None: _("unknown")}.get( 62 | value, value 63 | ) 64 | 65 | else: 66 | value = getattr(instance, f.name) 67 | 68 | yield (f.verbose_name, value) 69 | -------------------------------------------------------------------------------- /towel/templatetags/towel_batch_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.safestring import mark_safe 3 | 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.simple_tag 9 | def batch_checkbox(form, id): 10 | """ 11 | Checkbox which allows selecting objects for batch processing:: 12 | 13 | {% for object in object_list %} 14 | {% batch_checkbox batch_form object.id %} 15 | {{ object }} etc... 16 | {% endfor %} 17 | 18 | This tag returns an empty string if ``batch_form`` does not exist for some 19 | reason. This makes it easier to write templates when you don't know if the 20 | batch form will be available or not (f.e. because of a permissions 21 | requirement). 22 | """ 23 | 24 | if not form or not hasattr(form, "ids"): 25 | return "" 26 | 27 | cb = '' 28 | 29 | if id in form.ids: 30 | return cb % (id, id, 'checked="checked" ') 31 | 32 | return mark_safe(cb % (id, id, "")) 33 | -------------------------------------------------------------------------------- /towel/templatetags/towel_form_tags.py: -------------------------------------------------------------------------------- 1 | from django import forms, template 2 | from django.template.loader import render_to_string 3 | from django.utils.safestring import mark_safe 4 | 5 | 6 | register = template.Library() 7 | 8 | 9 | def _type_class(item): 10 | if isinstance(item.field.widget, forms.CheckboxInput): 11 | return "checkbox" 12 | elif isinstance(item.field.widget, forms.DateInput): 13 | return "date" 14 | elif isinstance(item.field.widget, forms.HiddenInput): 15 | return "hidden" 16 | elif isinstance( 17 | item.field.widget, (forms.RadioSelect, forms.CheckboxSelectMultiple) 18 | ): 19 | return "list" 20 | elif isinstance(item.field.widget, forms.Select): 21 | return "choice" 22 | return "default" 23 | 24 | 25 | @register.simple_tag 26 | def form_items(form): 27 | """ 28 | Render all form items:: 29 | 30 | {% form_items form %} 31 | """ 32 | return mark_safe( 33 | "".join( 34 | render_to_string( 35 | "towel/_form_item.html", 36 | { 37 | "item": field, 38 | "is_checkbox": isinstance(field.field.widget, forms.CheckboxInput), 39 | "type_class": _type_class(field), 40 | }, 41 | ) 42 | for field in form 43 | if field.name != "ignore_warnings" 44 | ) 45 | ) 46 | 47 | 48 | @register.inclusion_tag("towel/_form_item.html") 49 | def form_item(item, additional_classes=None): 50 | """ 51 | Helper for easy displaying of form items: 52 | 53 | :: 54 | 55 | {% for field in form %} 56 | {% form_item field %} 57 | {% endfor %} 58 | """ 59 | 60 | return { 61 | "item": item, 62 | "additional_classes": additional_classes, 63 | "is_checkbox": isinstance(item.field.widget, forms.CheckboxInput), 64 | "type_class": _type_class(item), 65 | } 66 | 67 | 68 | @register.inclusion_tag("towel/_form_item_plain.html") 69 | def form_item_plain(item, additional_classes=None): 70 | """ 71 | Helper for easy displaying of form items without any additional 72 | tags (table cells or paragraphs) or labels:: 73 | 74 | {% form_item_plain field %} 75 | """ 76 | 77 | return { 78 | "item": item, 79 | "additional_classes": additional_classes, 80 | "is_checkbox": isinstance(item.field.widget, forms.CheckboxInput), 81 | "type_class": _type_class(item), 82 | } 83 | 84 | 85 | @register.tag 86 | def form_errors(parser, token): 87 | """ 88 | Show all form and formset errors:: 89 | 90 | {% form_errors form formset1 formset2 %} 91 | 92 | Silently ignores non-existent variables. 93 | """ 94 | 95 | tokens = token.split_contents() 96 | 97 | return FormErrorsNode(*tokens[1:]) 98 | 99 | 100 | class FormErrorsNode(template.Node): 101 | def __init__(self, *items): 102 | self.items = [template.Variable(item) for item in items] 103 | 104 | def render(self, context): 105 | items = [] 106 | for item in self.items: 107 | try: 108 | var = item.resolve(context) 109 | if isinstance(var, dict): 110 | items.extend(var.values()) 111 | elif isinstance(var, (list, tuple)): 112 | items.extend(var) 113 | else: 114 | items.append(var) 115 | except template.VariableDoesNotExist: 116 | # We do not care too much 117 | pass 118 | 119 | errors = False 120 | has_non_field_errors = False 121 | 122 | form_list = [] 123 | formset_list = [] 124 | 125 | for i in items: 126 | if isinstance(i, forms.BaseForm): 127 | form_list.append(i) 128 | else: 129 | formset_list.append(i) 130 | 131 | if getattr(i, "non_field_errors", lambda: None)(): 132 | errors = True 133 | has_non_field_errors = True 134 | if getattr(i, "errors", None): 135 | errors = True 136 | 137 | if not errors: 138 | return "" 139 | 140 | return render_to_string( 141 | "towel/_form_errors.html", 142 | { 143 | "forms": form_list, 144 | "formsets": formset_list, 145 | "errors": errors, 146 | "has_non_field_errors": has_non_field_errors, 147 | }, 148 | ) 149 | 150 | 151 | @register.tag 152 | def form_warnings(parser, token): 153 | """ 154 | Show all form and formset warnings:: 155 | 156 | {% form_warnings form formset1 formset2 %} 157 | 158 | Silently ignores non-existent variables. 159 | """ 160 | 161 | tokens = token.split_contents() 162 | 163 | return FormWarningsNode(*tokens[1:]) 164 | 165 | 166 | class FormWarningsNode(template.Node): 167 | def __init__(self, *items): 168 | self.items = [template.Variable(item) for item in items] 169 | 170 | def render(self, context): 171 | items = [] 172 | for item in self.items: 173 | try: 174 | var = item.resolve(context) 175 | if isinstance(var, dict): 176 | items.extend(var.values()) 177 | elif isinstance(var, (list, tuple)): 178 | items.extend(var) 179 | else: 180 | items.append(var) 181 | except template.VariableDoesNotExist: 182 | # We do not care too much 183 | pass 184 | 185 | warnings = False 186 | 187 | form_list = [] 188 | formset_list = [] 189 | 190 | for i in items: 191 | if isinstance(i, forms.BaseForm): 192 | form_list.append(i) 193 | if getattr(i, "warnings", None): 194 | warnings = True 195 | else: 196 | formset_list.append(i) 197 | if any(getattr(f, "warnings", None) for f in i): 198 | warnings = True 199 | 200 | if not warnings: 201 | return "" 202 | 203 | return render_to_string( 204 | "towel/_form_warnings.html", 205 | {"forms": form_list, "formsets": formset_list, "warnings": True}, 206 | ) 207 | 208 | 209 | @register.tag 210 | def dynamic_formset(parser, token): 211 | """ 212 | Implements formsets where subforms can be added using the 213 | ``towel_add_subform`` javascript method:: 214 | 215 | {% dynamic_formset formset "activities" %} 216 | ... form code 217 | {% enddynamic_formset %} 218 | """ 219 | 220 | tokens = token.split_contents() 221 | nodelist = parser.parse(("enddynamic_formset",)) 222 | parser.delete_first_token() 223 | 224 | return DynamicFormsetNode(tokens[1], tokens[2], nodelist) 225 | 226 | 227 | class DynamicFormsetNode(template.Node): 228 | def __init__(self, formset, slug, nodelist): 229 | self.formset = template.Variable(formset) 230 | self.slug = template.Variable(slug) 231 | self.nodelist = nodelist 232 | 233 | def render(self, context): 234 | formset = self.formset.resolve(context) 235 | slug = self.slug.resolve(context) 236 | 237 | result = [] 238 | 239 | context.update( 240 | {"empty": True, "form_id": "%s-empty" % slug, "form": formset.empty_form} 241 | ) 242 | result.append('") 245 | context.pop() 246 | 247 | for idx, form in enumerate(formset.forms): 248 | context.update({"empty": False, "form_id": f"{slug}-{idx}", "form": form}) 249 | result.append(self.nodelist.render(context)) 250 | context.pop() 251 | 252 | return mark_safe("".join(result)) 253 | -------------------------------------------------------------------------------- /towel/templatetags/towel_region.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django import template 4 | from django.utils.html import conditional_escape 5 | from django.utils.safestring import mark_safe 6 | 7 | from towel.utils import parse_args_and_kwargs, resolve_args_and_kwargs 8 | 9 | 10 | register = template.Library() 11 | 12 | 13 | def flatatt(attrs): 14 | """ 15 | Convert a dictionary of attributes to a single string. 16 | The returned string will contain a leading space followed by key="value", 17 | XML-style pairs. It is assumed that the keys do not need to be 18 | XML-escaped. If the passed dictionary is empty, then return an empty 19 | string. 20 | """ 21 | return "".join([f' {k}="{conditional_escape(v)}"' for k, v in attrs.items()]) 22 | 23 | 24 | @register.tag 25 | def region(parser, token): 26 | """ 27 | Defines a live-updateable region:: 28 | 29 | {% region "identifier" fields="family_name,given_name" tag="div" %} 30 | {# Template code #} 31 | {% endregion %} 32 | 33 | The identifier should be a short string which is unique for the whole 34 | project, or at least for a given view. It is used to identify the region 35 | when live-updating, it should therefore be usable as a part of a HTML 36 | ``id`` attribute. The identifier should not start with an underscore, 37 | those names are reserved for internal bookkeeping. 38 | 39 | ``fields`` is a comma-separated list of fields (or other identifiers) 40 | which are used inside the given region. It is recommended to use the 41 | field and relation names here, but you are free to use anything you 42 | want. It can also be left empty if you purely want to update regions by 43 | their identifier. 44 | 45 | The ``tag`` argument defines the HTML tag used to render the region. 46 | The default tag is a ``div``. 47 | 48 | Additional keyword arguments will be rendered as attributes. This can 49 | be used to specify classes, data attributes or whatever you desire. 50 | """ 51 | 52 | nodelist = parser.parse(("endregion",)) 53 | parser.delete_first_token() 54 | 55 | return RegionNode( 56 | nodelist, *parse_args_and_kwargs(parser, token.split_contents()[1:]) 57 | ) 58 | 59 | 60 | class RegionNode(template.Node): 61 | def __init__(self, nodelist, args, kwargs): 62 | self.nodelist = nodelist 63 | self.args = args 64 | self.kwargs = kwargs 65 | 66 | def render(self, context): 67 | args, kwargs = resolve_args_and_kwargs(context, self.args, self.kwargs) 68 | return self._render(context, *args, **kwargs) 69 | 70 | def _render(self, context, identifier, fields="", tag="div", **kwargs): 71 | regions = context.get("regions") 72 | 73 | region_id = "twrg-%s" % identifier 74 | output = self.nodelist.render(context) 75 | 76 | if regions is not None: 77 | regions[region_id] = output 78 | dependencies = regions.setdefault("_dependencies", {}) 79 | 80 | for field in re.split(r"[,\s]+", str(fields)): 81 | dependencies.setdefault(field, []).append(region_id) 82 | 83 | kwargs["id"] = region_id 84 | 85 | return mark_safe( 86 | "<{tag} {attrs}>{output}".format( 87 | attrs=flatatt(kwargs), 88 | output=output, 89 | tag=tag, 90 | ) 91 | ) 92 | -------------------------------------------------------------------------------- /towel/templatetags/towel_resources.py: -------------------------------------------------------------------------------- 1 | import operator 2 | from functools import reduce 3 | 4 | from django import template 5 | from django.utils.http import urlencode 6 | 7 | 8 | register = template.Library() 9 | 10 | 11 | @register.inclusion_tag("towel/_pagination.html", takes_context=True) 12 | def pagination(context, page, paginator, where=None): 13 | """ 14 | Shows pagination links:: 15 | 16 | {% pagination current_page paginator %} 17 | 18 | The argument ``where`` can be used inside the pagination template to 19 | discern between pagination at the top and at the bottom of an object 20 | list (if you wish). The default object list template passes 21 | ``"top"`` or ``"bottom"`` to the pagination template. The default 22 | pagination template does nothing with this value though. 23 | """ 24 | 25 | return { 26 | "context": context, 27 | "page": page, 28 | "paginator": paginator, 29 | "where": where, 30 | } 31 | 32 | 33 | @register.filter 34 | def querystring(data, exclude="page,all"): 35 | """ 36 | Returns the current querystring, excluding specified GET parameters:: 37 | 38 | {% request.GET|querystring:"page,all" %} 39 | """ 40 | 41 | exclude = exclude.split(",") 42 | 43 | items = reduce( 44 | operator.add, 45 | ( 46 | list((k, v) for v in values) 47 | for k, values in data.lists() 48 | if k not in exclude 49 | ), 50 | [], 51 | ) 52 | 53 | return urlencode(sorted(items)) 54 | 55 | 56 | @register.inclusion_tag("towel/_ordering_link.html", takes_context=True) 57 | def ordering_link(context, field, request, title="", base_url="", **kwargs): 58 | """ 59 | Shows a table column header suitable for use as a link to change the 60 | ordering of objects in a list:: 61 | 62 | {% ordering_link "" request title=_("Edition") %} {# default order #} 63 | {% ordering_link "customer" request title=_("Customer") %} 64 | {% ordering_link "state" request title=_("State") %} 65 | 66 | Required arguments are the field and the request. It is very much 67 | recommended to add a title too of course. 68 | 69 | ``ordering_link`` has an optional argument, ``base_url`` which is 70 | useful if you need to customize the link part before the question 71 | mark. The default behavior is to only add the query string, and nothing 72 | else to the ``href`` attribute. 73 | 74 | It is possible to specify a set of CSS classes too. The CSS classes 75 | ``'asc'`` and ``'desc'`` are added automatically by the code depending 76 | upon the ordering which would be selected if the ordering link were 77 | clicked (NOT the current ordering):: 78 | 79 | {% ordering_link "state" request title=_("State") classes="btn" %} 80 | 81 | The ``classes`` argument defaults to ``'ordering'``. 82 | """ 83 | 84 | current = request.GET.get("o", "") 85 | 86 | # Automatically handle search form persistency 87 | data = request.GET.copy() 88 | if not data: 89 | form = context.get("search_form") 90 | if form is not None and getattr(form, "persistency", False): 91 | data = form.data 92 | 93 | ctx = { 94 | "querystring": querystring(data, exclude="page,all,o"), 95 | "field": field, 96 | "used": current in (field, "-%s" % field), 97 | "descending": current == field, 98 | "title": title, 99 | "base_url": base_url, 100 | } 101 | ctx.update(kwargs) 102 | return ctx 103 | -------------------------------------------------------------------------------- /towel/templatetags/verbose_name_tags.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | from django import template 4 | 5 | 6 | register = template.Library() 7 | 8 | 9 | PATHS = [ 10 | "_meta", 11 | "queryset.model._meta", 12 | "instance._meta", 13 | "model._meta", 14 | ] 15 | 16 | 17 | def _resolve(instance, last_part): 18 | for path in PATHS: 19 | o = instance 20 | found = True 21 | for part in itertools.chain(path.split("."), [last_part]): 22 | try: 23 | o = getattr(o, part) 24 | except AttributeError: 25 | found = False 26 | break 27 | 28 | if found: 29 | return o 30 | 31 | 32 | @register.filter 33 | def verbose_name(item): 34 | """ 35 | Pass in anything and it tries hard to return its ``verbose_name``:: 36 | 37 | {{ form|verbose_name }} 38 | {{ object|verbose_name }} 39 | {{ formset|verbose_name }} 40 | {{ object_list|verbose_name }} 41 | """ 42 | return _resolve(item, "verbose_name") 43 | 44 | 45 | @register.filter 46 | def verbose_name_plural(item): 47 | """ 48 | Pass in anything and it tries hard to return its ``verbose_name_plural``:: 49 | 50 | {{ form|verbose_name_plural }} 51 | {{ object|verbose_name_plural }} 52 | {{ formset|verbose_name_plural }} 53 | {{ object_list|verbose_name_plural }} 54 | """ 55 | return _resolve(item, "verbose_name_plural") 56 | -------------------------------------------------------------------------------- /towel/utils.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import re 3 | 4 | from django.db.models.deletion import Collector 5 | from django.urls import NoReverseMatch, reverse 6 | 7 | 8 | def related_classes(instance): 9 | """ 10 | Return all classes which would be deleted if the passed instance 11 | were deleted too by employing the cascade machinery of Django 12 | itself. Does **not** return instances, only classes. 13 | 14 | Note! When using Django 1.5, autogenerated models (many to many through 15 | models) are returned too. 16 | """ 17 | collector = Collector(using=instance._state.db) 18 | # We really do not want fast deletion, we absolutely need to know whether 19 | # there are related objects around! 20 | collector.can_fast_delete = lambda *args, **kwargs: False 21 | collector.collect([instance]) 22 | 23 | # Save collected objects for later referencing (well yes, it does return 24 | # instances but we don't have to tell anybody :-) 25 | instance._collected_objects = collector.data 26 | 27 | return collector.data.keys() 28 | 29 | 30 | def safe_queryset_and(head, *tail): 31 | """ 32 | Safe AND-ing of querysets. If one of both queries has its 33 | DISTINCT flag set, sets distinct on both querysets. Also takes extra 34 | care to preserve the result of the following queryset methods: 35 | 36 | * ``reverse()`` 37 | * ``transform()`` 38 | * ``select_related()`` 39 | * ``prefetch_related()`` 40 | """ 41 | 42 | def _merge(qs1, qs2): 43 | if qs1.query.distinct or qs2.query.distinct: 44 | res = qs1.distinct() & qs2.distinct() 45 | else: 46 | res = qs1 & qs2 47 | 48 | res._transform_fns = list( 49 | set(getattr(qs1, "_transform_fns", []) + getattr(qs2, "_transform_fns", [])) 50 | ) 51 | 52 | if not (qs1.query.standard_ordering and qs2.query.standard_ordering): 53 | res.query.standard_ordering = False 54 | 55 | select_related = [qs1.query.select_related, qs2.query.select_related] 56 | if False in select_related: 57 | # We are not interested in the default value 58 | select_related.remove(False) 59 | 60 | if len(select_related) == 1: 61 | res.query.select_related = select_related[0] 62 | elif len(select_related) == 2: 63 | if True in select_related: 64 | # Prefer explicit select_related to generic select_related() 65 | select_related.remove(True) 66 | 67 | if len(select_related) > 0: 68 | # If we have two explicit select_related calls, take any 69 | res.query.select_related = select_related[0] 70 | else: 71 | res = res.select_related() 72 | 73 | res._prefetch_related_lookups = list( 74 | set(qs1._prefetch_related_lookups) | set(qs2._prefetch_related_lookups) 75 | ) 76 | 77 | return res 78 | 79 | while tail: 80 | head = _merge(head, tail[0]) 81 | tail = tail[1:] 82 | return head 83 | 84 | 85 | _KWARG_RE = re.compile(r"(?:([-\w]+)=)?(.+)") 86 | 87 | 88 | def parse_args_and_kwargs(parser, bits): 89 | """ 90 | Parses template tag arguments and keyword arguments 91 | 92 | Returns a tuple ``args, kwargs``. 93 | 94 | Usage:: 95 | 96 | @register.tag 97 | def custom(parser, token): 98 | return CustomNode(*parse_args_and_kwargs(parser, 99 | token.split_contents()[1:])) 100 | 101 | class CustomNode(template.Node): 102 | def __init__(self, args, kwargs): 103 | self.args = args 104 | self.kwargs = kwargs 105 | 106 | def render(self, context): 107 | args, kwargs = resolve_args_and_kwargs(context, self.args, 108 | self.kwargs) 109 | return self._render(context, *args, **kwargs): 110 | 111 | def _render(self, context, ...): 112 | # The real workhorse 113 | """ 114 | args = [] 115 | kwargs = {} 116 | 117 | for bit in bits: 118 | match = _KWARG_RE.match(bit) 119 | key, value = match.groups() 120 | value = parser.compile_filter(value) 121 | if key: 122 | kwargs[str(key)] = value 123 | else: 124 | args.append(value) 125 | 126 | return args, kwargs 127 | 128 | 129 | def resolve_args_and_kwargs(context, args, kwargs): 130 | """ 131 | Resolves arguments and keyword arguments parsed by 132 | ``parse_args_and_kwargs`` using the passed context instance 133 | 134 | See ``parse_args_and_kwargs`` for usage instructions. 135 | """ 136 | return ( 137 | [v.resolve(context) for v in args], 138 | {k: v.resolve(context) for k, v in kwargs.items()}, 139 | ) 140 | 141 | 142 | def changed_regions(regions, fields): 143 | """ 144 | Returns a subset of regions which have to be updated when fields have 145 | been edited. To be used together with the ``{% regions %}`` template 146 | tag. 147 | 148 | Usage:: 149 | 150 | regions = {} 151 | render(request, 'detail.html', { 152 | 'object': instance, 153 | 'regions': regions, 154 | }) 155 | return HttpResponse( 156 | json.dumps(changed_regions(regions, ['emails', 'phones'])), 157 | content_type='application/json') 158 | """ 159 | dependencies = regions.get("_dependencies", {}) 160 | to_update = set(itertools.chain(*[dependencies.get(field, []) for field in fields])) 161 | 162 | return {key: value for key, value in regions.items() if key in to_update} 163 | 164 | 165 | def tryreverse(*args, **kwargs): 166 | """ 167 | Calls ``django.core.urlresolvers.reverse``, and returns ``None`` on 168 | failure instead of raising an exception. 169 | """ 170 | try: 171 | return reverse(*args, **kwargs) 172 | except NoReverseMatch: 173 | return None 174 | 175 | 176 | def substitute_with(to_delete, instance): 177 | """ 178 | Substitute the first argument with the second in all relations, 179 | and delete the first argument afterwards. 180 | """ 181 | assert to_delete.__class__ == instance.__class__ 182 | assert to_delete.pk != instance.pk 183 | 184 | fields = [ 185 | f 186 | for f in to_delete._meta.get_fields() 187 | if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete 188 | ] 189 | 190 | for related_object in fields: 191 | try: 192 | model = related_object.related_model 193 | except AttributeError: 194 | model = related_object.model 195 | 196 | queryset = model._base_manager.complex_filter( 197 | {related_object.field.name: to_delete.pk} 198 | ) 199 | 200 | queryset.update(**{related_object.field.name: instance.pk}) 201 | to_delete.delete() 202 | 203 | 204 | def app_model_label(model): 205 | """ 206 | Stop those deprecation warnings 207 | """ 208 | return model._meta.app_label, model._meta.model_name 209 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv] 2 | basepython = python3 3 | 4 | [testenv:docs] 5 | deps = 6 | Sphinx 7 | changedir = docs 8 | commands = make html 9 | skip_install = true 10 | allowlist_externals = make 11 | 12 | [testenv:tests] 13 | deps = 14 | Django 15 | coverage 16 | changedir = {toxinidir} 17 | skip_install = true 18 | setenv = 19 | PYTHONWARNINGS=always 20 | commands = 21 | coverage run tests/manage.py test -v 2 {posargs:testapp} 22 | coverage html 23 | --------------------------------------------------------------------------------