├── .coveragerc ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── .readthedocs.yaml ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── bread ├── __init__.py ├── bread.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_delete_breadtestmodel.py │ └── __init__.py ├── static │ └── js │ │ ├── URI.js │ │ └── bread.js ├── templates │ └── bread │ │ ├── browse.html │ │ ├── delete.html │ │ ├── edit.html │ │ ├── empty.html │ │ ├── includes │ │ ├── browse.html │ │ ├── delete.html │ │ ├── edit.html │ │ ├── label_value_read.html │ │ └── read.html │ │ ├── label_value_read.html │ │ └── read.html ├── templatetags │ ├── __init__.py │ └── bread_tags.py └── utils.py ├── dev-requirements.txt ├── docs ├── Makefile ├── changes.rst ├── conf.py ├── configuration.rst ├── contributing.rst ├── index.rst ├── installation.rst ├── requirements.txt ├── templates.rst └── urls.rst ├── maintain.sh ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── base.py ├── factories.py ├── models.py ├── test_add.py ├── test_browse.py ├── test_delete.py ├── test_edit.py ├── test_filters.py ├── test_forms.py ├── test_pagination.py ├── test_permissions.py ├── test_read.py ├── test_search.py ├── test_templates.py ├── test_urls.py └── test_utils.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | source = bread 4 | omit = */tests* 5 | 6 | [report] 7 | fail_under = 85 8 | show_missing = true 9 | skip_covered = true 10 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: 3 | # https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 4 | 5 | name: Upload Python Package 6 | 7 | on: 8 | release: 9 | types: [created] 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-python@v4 18 | with: 19 | python-version: "3.12" 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install setuptools wheel twine 24 | - name: Build and publish 25 | env: 26 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 27 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 28 | run: | 29 | python setup.py sdist bdist_wheel 30 | twine upload dist/* 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: lint-test 2 | 3 | on: 4 | pull_request: 5 | schedule: 6 | # run once a week on early monday mornings 7 | - cron: "22 2 * * 1" 8 | 9 | jobs: 10 | pre-commit: 11 | runs-on: ubuntu-24.04 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.12" 17 | - uses: pre-commit/action@v3.0.1 18 | 19 | test: 20 | runs-on: ubuntu-24.04 21 | strategy: 22 | matrix: 23 | # tox-gh-actions will only run the tox environments which match the currently 24 | # running python-version. See [gh-actions] in tox.ini for the mapping 25 | python-version: ["3.9", "3.10", "3.11", "3.12"] 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install -r dev-requirements.txt 36 | - name: Test with tox 37 | run: tox 38 | 39 | coverage: 40 | runs-on: ubuntu-24.04 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: actions/setup-python@v5 44 | with: 45 | python-version: "3.12" 46 | - name: Cache pip 47 | uses: actions/cache@v4 48 | with: 49 | path: ~/.cache/pip 50 | key: ${{ runner.os }}-pip-${{ hashFiles('dev-requirements.txt') }} 51 | restore-keys: | 52 | ${{ runner.os }}-pip- 53 | ${{ runner.os }}- 54 | - name: Install dependencies 55 | run: | 56 | python -m pip install --upgrade pip 57 | pip install -r dev-requirements.txt 58 | - name: Run coverage report 59 | run: | 60 | coverage run runtests.py 61 | coverage report 62 | 63 | build-docs: 64 | runs-on: ubuntu-24.04 65 | steps: 66 | - uses: actions/checkout@v4 67 | - uses: actions/setup-python@v5 68 | with: 69 | python-version: "3.12" 70 | - name: Cache pip 71 | uses: actions/cache@v4 72 | with: 73 | path: ~/.cache/pip 74 | key: ${{ runner.os }}-pip-${{ hashFiles('dev-requirements.txt') }} 75 | restore-keys: | 76 | ${{ runner.os }}-pip- 77 | ${{ runner.os }}- 78 | - name: Install dependencies 79 | run: | 80 | python -m pip install --upgrade pip 81 | pip install -r dev-requirements.txt 82 | - name: Build docs 83 | run: sphinx-build docs docs/_build/html 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | build 3 | dist 4 | .tox 5 | _build 6 | *.egg-info/ 7 | .coverage 8 | .envrc 9 | .direnv 10 | .vscode/ 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-case-conflict 7 | - id: check-json 8 | - id: check-merge-conflict 9 | - id: check-symlinks 10 | - id: check-yaml 11 | - id: debug-statements 12 | - id: detect-private-key 13 | - id: end-of-file-fixer 14 | - id: mixed-line-ending 15 | - id: trailing-whitespace 16 | 17 | - repo: https://github.com/psf/black 18 | rev: 23.7.0 19 | hooks: 20 | - id: black 21 | 22 | - repo: https://github.com/pycqa/isort 23 | rev: 5.12.0 24 | hooks: 25 | - id: isort 26 | 27 | - repo: https://github.com/PyCQA/flake8 28 | rev: 6.0.0 29 | hooks: 30 | - id: flake8 31 | 32 | - repo: https://github.com/asottile/pyupgrade 33 | rev: v3.19.0 34 | hooks: 35 | - id: pyupgrade 36 | args: [--py39-plus] 37 | 38 | - repo: https://github.com/adamchainz/django-upgrade 39 | rev: 1.24.0 40 | hooks: 41 | - id: django-upgrade 42 | args: [--target-version, "5.2"] 43 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | 3.11 3 | 3.10 4 | 3.9 5 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-20.04 11 | tools: 12 | python: "3.9" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # If using Sphinx, optionally build your docs in additional formats such as PDF 19 | # formats: 20 | # - pdf 21 | 22 | # Optionally declare the Python requirements required to build your docs 23 | python: 24 | install: 25 | - requirements: docs/requirements.txt 26 | - method: pip 27 | path: . 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | recursive-include docs *.txt *.rst 3 | recursive-include bread/static * 4 | recursive-include bread/templates * 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django Bread 2 | ============ 3 | 4 | Django Bread is a Django app to help build BREAD (Browse, Read, Edit, 5 | Add, Delete) views for Django models. 6 | 7 | It helps with default templates, url generation, permissions, filters, 8 | pagination, and more. 9 | 10 | This is relatively stable. We're using it in production and have attempted 11 | to document the important parts, but feedback is welcome. 12 | 13 | Breaking change in 1.0.0 14 | ------------------------ 15 | 16 | Version 1.0.0 includes a breaking change! If you're using the default 17 | view permissions, before upgrading, make sure you've 18 | migrated your users and groups that have "read_{model_name}" 19 | permissions to also have "view_{model_name}". From 1.0.0 on, that's the 20 | default permission a user needs to use the read views, because it's become the 21 | standard Django permission for read-only access since Django 2.1. 22 | 23 | If you're still on Django 2.0, don't upgrade django-bread until you 24 | can get to at least Django 2.1. (Hopefully that's not the case, since 25 | Django 2.0 has been out of support since April 1, 2019.) 26 | 27 | 28 | Supported versions 29 | ------------------ 30 | 31 | Django: 4.2, 5.0, 5.1, 5.2 32 | Python: 3.9, 3.10, 3.11, 3.12 33 | 34 | For Python 2.7 and/or Django 1.11 support, the 0.5 release series is identical (features-wise) 35 | to 0.6 and is available on PyPI: https://pypi.org/project/django-bread/#history 36 | 37 | 38 | Maintainer Information 39 | ---------------------- 40 | 41 | We use Github Actions to lint (using pre-commit, black, isort, and flake8), 42 | test (using tox and tox-gh-actions), calculate coverage (using coverage), and build 43 | documentation (using sphinx). 44 | 45 | We have a local script to do these actions locally, named ``maintain.sh``:: 46 | 47 | $ ./maintain.sh 48 | 49 | A Github Action workflow also builds and pushes a new package to PyPI whenever a new 50 | Release is created in Github. This uses a project-specific PyPI token, as described in 51 | the `PyPI documentation here `_. That token has been 52 | saved in the ``PYPI_PASSWORD`` settings for this repo, but has not been saved anywhere 53 | else so if it is needed for any reason, the current one should be deleted and a new one 54 | generated. 55 | 56 | As always, be sure to bump the version in ``bread/__init__.py`` before creating a 57 | Release, so that the proper version gets pushed to PyPI. 58 | 59 | 60 | Questions or Issues? 61 | -------------------- 62 | 63 | If you have questions, issues or requests for improvements please let us know on 64 | `Github `_. 65 | 66 | Development sponsored by `Caktus Consulting Group, LLC 67 | `_. 68 | -------------------------------------------------------------------------------- /bread/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.8" 2 | -------------------------------------------------------------------------------- /bread/bread.py: -------------------------------------------------------------------------------- 1 | import json 2 | from functools import reduce 3 | from operator import or_ 4 | from urllib.parse import urlencode 5 | 6 | from django.conf import settings 7 | from django.contrib.auth import REDIRECT_FIELD_NAME 8 | from django.contrib.auth.models import Permission 9 | from django.contrib.auth.views import redirect_to_login 10 | from django.contrib.contenttypes.models import ContentType 11 | from django.core.exceptions import ( 12 | EmptyResultSet, 13 | FieldError, 14 | ImproperlyConfigured, 15 | PermissionDenied, 16 | ) 17 | from django.db.models import Model, Q 18 | from django.forms.models import modelform_factory 19 | from django.http.response import HttpResponseBadRequest 20 | from django.urls import path, reverse_lazy 21 | from django.utils.html import escape 22 | from vanilla import CreateView, DeleteView, DetailView, ListView, UpdateView 23 | 24 | from .utils import get_verbose_name, validate_fieldspec 25 | 26 | 27 | class Http400(Exception): 28 | def __init__(self, msg): 29 | self.msg = msg 30 | 31 | 32 | """ 33 | About settings. 34 | 35 | You can provide a Django setting named BREAD as a dictionary. 36 | Here are the settings, all currently optional: 37 | 38 | DEFAULT_BASE_TEMPLATE: Default value for Bread's base_template argument 39 | """ 40 | 41 | 42 | # Helper to get settings from BREAD dictionary, or default 43 | def setting(name, default=None): 44 | BREAD = getattr(settings, "BREAD", {}) 45 | return BREAD.get(name, default) 46 | 47 | 48 | class BreadViewMixin: 49 | """We mix this into all the views for some common features""" 50 | 51 | bread = None # The Bread object using this view 52 | 53 | exclude = None 54 | form_class = None 55 | 56 | # Make this view require the appropriate permission 57 | @property 58 | def permission_required(self): 59 | return self.get_full_perm_name(self.perm_name) 60 | 61 | # Given a short permission name like 'change' or 'add', return 62 | # the full name like 'app_label.change_model' for this view's model. 63 | def get_full_perm_name(self, short_name): 64 | return "{app_name}.{perm_name}_{model_name}".format( 65 | app_name=self.bread.model._meta.app_label, 66 | model_name=self.bread.model._meta.object_name.lower(), 67 | perm_name=short_name, 68 | ) 69 | 70 | def __init__(self, *args, **kwargs): 71 | # Make sure the permission needed to use this view exists. 72 | super().__init__(*args, **kwargs) 73 | perm_name = "{}_{}".format( 74 | self.perm_name, 75 | self.bread.model._meta.object_name.lower(), 76 | ) 77 | perm = Permission.objects.filter( 78 | content_type=ContentType.objects.get_for_model(self.bread.model), 79 | codename=perm_name, 80 | ).first() 81 | if not perm: 82 | raise ImproperlyConfigured( 83 | "The view %r requires permission %s but there's no such permission" 84 | % (self, perm_name) 85 | ) 86 | 87 | # Override dispatch to get our own custom version of the braces 88 | # PermissionRequired mixin. Here's how ours behaves: 89 | # 90 | # If user is not logged in, redirect to a login page. 91 | # Else, if user does not have the required permission, return 403. 92 | # Else, carry on. 93 | def dispatch(self, request, *args, **kwargs): 94 | # Make sure that the permission_required attribute is set on the 95 | # view, or raise a configuration error. 96 | if self.permission_required is None: # pragma: no cover 97 | raise ImproperlyConfigured( 98 | "'BreadViewMixin' requires " 99 | "'permission_required' attribute to be set." 100 | ) 101 | 102 | # Check if the user is logged in 103 | if not request.user.is_authenticated: 104 | return redirect_to_login( 105 | request.get_full_path(), settings.LOGIN_URL, REDIRECT_FIELD_NAME 106 | ) 107 | 108 | # Check to see if the request's user has the required permission. 109 | has_permission = request.user.has_perm(self.permission_required) 110 | 111 | if not has_permission: # If the user lacks the permission 112 | raise PermissionDenied # return a forbidden response. 113 | 114 | try: 115 | return super().dispatch(request, *args, **kwargs) 116 | except Http400 as e: 117 | return HttpResponseBadRequest(content=escape(e.msg).encode("utf-8")) 118 | 119 | def get_template_names(self): 120 | """Return Django Vanilla templates (app-specific), then 121 | Customized template via Bread object, then 122 | Django Bread template 123 | """ 124 | vanilla_templates = super().get_template_names() 125 | 126 | # template_name_suffix may have a leading underscore (to make it work well with Django 127 | # Vanilla Views). If it does, then we strip the underscore to get our 'view' name. 128 | # e.g. template_name_suffix '_browse' -> view 'browse' 129 | view = self.template_name_suffix.lstrip("_") 130 | default_template = "bread/%s.html" % view 131 | if self.bread.template_name_pattern: 132 | custom_template = self.bread.template_name_pattern.format( 133 | app_label=self.bread.model._meta.app_label, 134 | model=self.bread.model._meta.object_name.lower(), 135 | view=view, 136 | ) 137 | return vanilla_templates + [custom_template] + [default_template] 138 | return vanilla_templates + [default_template] 139 | 140 | def _get_new_url(self, **query_parms): 141 | """Return a new URL consisting of this request's URL, with any specified 142 | query parms updated or added""" 143 | request_kwargs = self.request.GET.copy() 144 | request_kwargs.update(query_parms) 145 | return self.request.path + "?" + urlencode(request_kwargs, doseq=True) 146 | 147 | def get_context_data(self, **kwargs): 148 | data = super().get_context_data(**kwargs) 149 | 150 | # Add data from the Bread object 151 | data.update(self.bread.get_additional_context_data()) 152 | 153 | # Add 'may_' to the context for each view, so the templates can 154 | # tell if the current user may use the named view. 155 | data["may_browse"] = "B" in self.bread.views and self.request.user.has_perm( 156 | self.get_full_perm_name("browse") 157 | ) 158 | data["may_read"] = "R" in self.bread.views and self.request.user.has_perm( 159 | self.get_full_perm_name("read") 160 | ) 161 | data["may_edit"] = "E" in self.bread.views and self.request.user.has_perm( 162 | self.get_full_perm_name("change") 163 | ) 164 | data["may_add"] = "A" in self.bread.views and self.request.user.has_perm( 165 | self.get_full_perm_name("add") 166 | ) 167 | data["may_delete"] = "D" in self.bread.views and self.request.user.has_perm( 168 | self.get_full_perm_name("delete") 169 | ) 170 | return data 171 | 172 | def get_form(self, data=None, files=None, **kwargs): 173 | form_class = self.form_class or self.bread.form_class 174 | if not form_class: 175 | form_class = modelform_factory( 176 | self.bread.model, 177 | fields="__all__", 178 | exclude=self.exclude or self.bread.exclude, 179 | ) 180 | return form_class(data=data, files=files, **kwargs) 181 | 182 | @property 183 | def success_url(self): 184 | return reverse_lazy(self.bread.browse_url_name()) 185 | 186 | 187 | # The individual view classes we'll use and customize in the 188 | # omnibus class below: 189 | class BrowseView(BreadViewMixin, ListView): 190 | # Include in any colspec to allow Django ORM annotations. 191 | # Will skip init-time validation for that column. 192 | is_annotation = object() 193 | 194 | # Configurable: 195 | columns = [] 196 | filterset = None # Class 197 | paginate_by = None 198 | perm_name = "browse" # Not a default Django permission 199 | search_fields = [] 200 | search_terms = None 201 | template_name_suffix = "_browse" 202 | 203 | _valid_sorting_columns = [] # indices of columns that are valid in ordering parms 204 | 205 | def __init__(self, *args, **kwargs): 206 | super().__init__(*args, **kwargs) 207 | # Internal use 208 | self.filter = None 209 | 210 | # See which columns we can sort on 211 | self._valid_sorting_columns = [] 212 | for i in range(len(self.columns)): 213 | fieldspec = self.get_sort_field_name_for_column(i) 214 | if fieldspec: 215 | try: 216 | # In Django 3.1.13+, order_by args are validated here 217 | queryset = super().get_queryset().order_by(fieldspec) 218 | # Force Django < 3.1.13 to build the query here so it will validate the order_by args 219 | str(queryset.query) 220 | except FieldError: 221 | pass 222 | else: 223 | self._valid_sorting_columns.append(i) 224 | 225 | def get_sort_field_name_for_column(self, column_number): 226 | """ 227 | Returns the name to use in an `order_by` call on a queryset 228 | to sort by the 'column_number'-th column. 229 | """ 230 | column = self.columns[column_number] 231 | sortspec = column[-1] 232 | return sortspec() if callable(sortspec) else sortspec 233 | 234 | def get_queryset(self): 235 | qset = super().get_queryset() 236 | 237 | # Make a copy of the query parms. We need to remove the 238 | # sorting and search parms from this as we process them, 239 | # so the filter won't try to use them. That also means we 240 | # need to do the filtering last. 241 | query_parms = self.request.GET.copy() 242 | 243 | # Now search 244 | # QueryDict.pop() always returns a list 245 | q = query_parms.pop("q", [False])[0] 246 | if self.search_fields and q: 247 | qset, use_distinct = self.get_search_results(self.request, qset, q) 248 | if use_distinct: 249 | qset = qset.distinct() 250 | 251 | # Sort? 252 | o = query_parms.pop("o", [False])[0] 253 | if o: 254 | order_by = [] 255 | for o_field in o.split(","): 256 | prefix = "" 257 | if o_field.startswith("-"): 258 | prefix = "-" 259 | o_field = o_field[1:] 260 | try: 261 | column_number = int(o_field) 262 | except ValueError: 263 | raise Http400( 264 | "{} is not a valid integer in sorting param o={!r}".format( 265 | o_field, o 266 | ) 267 | ) 268 | if column_number not in self._valid_sorting_columns: 269 | raise Http400( 270 | "%d is not a valid column number to sort on. " 271 | "The valid column numbers are %r" 272 | % (column_number, self._valid_sorting_columns) 273 | ) 274 | order_by.append( 275 | "%s%s" 276 | % (prefix, self.get_sort_field_name_for_column(column_number)) 277 | ) 278 | # Add any ordering from the model's Meta data that isn't already included. 279 | # That will make the rest of the sort stable, if the model has some default sort order. 280 | default_ordering = getattr( 281 | self, "default_ordering", qset.model._meta.ordering 282 | ) 283 | order_by_without_leading_dashes = [x.lstrip("-") for x in order_by] 284 | for order_spec in default_ordering: 285 | if order_spec.lstrip("-") not in order_by_without_leading_dashes: 286 | order_by.append(order_spec) 287 | qset = qset.order_by(*order_by) 288 | # Validate those parms 289 | try: 290 | str(qset.query) # unused, just evaluate it to make it compile the query 291 | except FieldError as e: 292 | raise Http400( 293 | "There is an invalid column for sorting in the ordering parameter: %s" 294 | % str(e) 295 | ) 296 | except EmptyResultSet: 297 | # It can throw this but we don't care 298 | pass 299 | 300 | # Now filter 301 | if self.filterset is not None: 302 | self.filter = self.filterset( 303 | query_parms, queryset=qset, request=self.request 304 | ) 305 | qset = self.filter.qs 306 | 307 | return qset 308 | 309 | def get_context_data(self, **kwargs): 310 | data = super().get_context_data(**kwargs) 311 | data["o"] = self.request.GET.get("o", "") 312 | data["q"] = self.request.GET.get("q", "") 313 | data["columns"] = self.columns 314 | data["valid_sorting_columns_json"] = json.dumps(self._valid_sorting_columns) 315 | data["has_filter"] = self.filterset is not None 316 | data["has_search"] = bool(self.search_fields) 317 | if self.search_fields and self.search_terms: 318 | data["search_terms"] = self.search_terms 319 | else: 320 | data["search_terms"] = "" 321 | data["filter"] = self.filter 322 | if data.get("is_paginated", False): 323 | page = data["page_obj"] 324 | num_pages = data["paginator"].num_pages 325 | if page.has_next(): 326 | if page.next_page_number() != num_pages: 327 | data["next_url"] = self._get_new_url(page=page.next_page_number()) 328 | data["last_url"] = self._get_new_url(page=num_pages) 329 | if page.has_previous(): 330 | data["first_url"] = self._get_new_url(page=1) 331 | if page.previous_page_number() != 1: 332 | data["previous_url"] = self._get_new_url( 333 | page=page.previous_page_number() 334 | ) 335 | return data 336 | 337 | # The following is copied from the Django admin and tweaked 338 | def get_search_results(self, request, queryset, search_term): 339 | """ 340 | Returns a tuple containing a queryset to implement the search, 341 | and a boolean indicating if the results may contain duplicates. 342 | """ 343 | 344 | # Apply keyword searches. 345 | def construct_search(field_name): 346 | if field_name.startswith("^"): 347 | return "%s__istartswith" % field_name[1:] 348 | elif field_name.startswith("="): 349 | return "%s__iexact" % field_name[1:] 350 | elif field_name.startswith("@"): 351 | return "%s__search" % field_name[1:] 352 | else: 353 | return "%s__icontains" % field_name 354 | 355 | use_distinct = False 356 | search_fields = self.search_fields 357 | if search_fields and search_term: 358 | orm_lookups = [ 359 | construct_search(str(search_field)) for search_field in search_fields 360 | ] 361 | for bit in search_term.split(): 362 | or_queries = [Q(**{orm_lookup: bit}) for orm_lookup in orm_lookups] 363 | queryset = queryset.filter(reduce(or_, or_queries)) 364 | if not use_distinct: 365 | opts = self.bread.model._meta 366 | for search_spec in orm_lookups: 367 | # The function lookup_needs_distinct() was renamed 368 | # to lookup_spawns_duplicates() in Django 4.0 369 | # https://docs.djangoproject.com/en/4.2/releases/4.0/#:~:text=The%20undocumented%20django.contrib.admin.utils.lookup_needs_distinct()%20function%20is%20renamed%20to%20lookup_spawns_duplicates(). 370 | try: 371 | from django.contrib.admin.utils import lookup_spawns_duplicates 372 | 373 | if lookup_spawns_duplicates(opts, search_spec): 374 | use_distinct = True 375 | break 376 | except ImportError: 377 | from django.contrib.admin.utils import lookup_spawns_duplicates 378 | 379 | if lookup_spawns_duplicates(opts, search_spec): 380 | use_distinct = True 381 | break 382 | 383 | return queryset, use_distinct 384 | 385 | 386 | class ReadView(BreadViewMixin, DetailView): 387 | """ 388 | The read view makes a form, not because we're going to submit 389 | changes, but just as a handy container for the object's data that 390 | we can iterate over in the template to display it if we don't want 391 | to make a custom template for this model. 392 | """ 393 | 394 | perm_name = "view" # Default Django permission 395 | template_name_suffix = "_read" 396 | 397 | def get_context_data(self, **kwargs): 398 | data = super().get_context_data(**kwargs) 399 | data["form"] = self.get_form(instance=self.object) 400 | return data 401 | 402 | 403 | class LabelValueReadView(ReadView): 404 | """A alternative read view that displays data from (label, value) pairs rather than a form. 405 | 406 | The (label, value) pairs are derived from a class attribute called fields. The tuples in 407 | fields are manipulated according to the rules below before being passed as simple strings 408 | to the template. 409 | 410 | Unlike ReadView, you must subclass LabelValueReadView to make it useful. In most cases, your 411 | subclass only needs to populate the fields attribute. 412 | 413 | Specifically, fields should be an iterable of 2-tuples of (label, evaluator). 414 | 415 | The label should be a string, or None. If it's None, the evaluator must be a Django model 416 | field. The label is created from the field's verbose_name attribute. 417 | 418 | The evaluator is evaluated in one of the following 5 modes, in this order -- 419 | 1) a string that matches an attribute on self.object. Resolves to the value of the attribute. 420 | 2) a string that matches a method name on self.object. Resolves to the value of the method 421 | call. 422 | 3) a string that's neither of the above. Resolves to itself. 423 | 4) a non-instance function that accepts the context data dict as a parameter. Resolves to the 424 | value of the function. (Note that self.object is available to the called function via 425 | context_data['object'].) 426 | 5) None of the above. Resolves to str(evaluator). 427 | 428 | Some examples: 429 | fields = ((None, 'id'), # Mode 1: self.object.id 430 | (_('The length'), '__len__'), # Mode 2: self.object.__len__() 431 | (_('Foo'), 'bar'), # Mode 3: 'bar' 432 | (_('Stuff'), 'frob_all_the_things'), # Mode 4: frob_all_the_things(context_data) 433 | (_('Answer'), 42), # Mode 5: '42' 434 | ) 435 | """ 436 | 437 | template_name_suffix = "_label_value_read" 438 | fields = [] 439 | 440 | def get_context_data(self, **kwargs): 441 | context_data = super().get_context_data(**kwargs) 442 | 443 | context_data["read_fields"] = [ 444 | self.get_field_label_value(label, value, context_data) 445 | for label, value in self.fields 446 | ] 447 | 448 | return context_data 449 | 450 | def get_field_label_value(self, label, evaluator, context_data): 451 | """Given a 2-tuple from fields, return the corresponding (label, value) tuple. 452 | 453 | Implements the modes described in the class docstring. (q.v.) 454 | """ 455 | value = "" 456 | if isinstance(evaluator, str): 457 | if hasattr(self.object, evaluator): 458 | # This is an instance attr or method 459 | attr = getattr(self.object, evaluator) 460 | # Modes #1 and #2. 461 | value = attr() if callable(attr) else attr 462 | if label is None: 463 | # evaluator refers to a model field (we hope). 464 | label = get_verbose_name(self.object, evaluator) 465 | else: 466 | # It's a simple string (Mode #3) 467 | value = evaluator 468 | else: 469 | if callable(evaluator): 470 | # This is a non-instance method (Mode #4) 471 | value = evaluator(context_data) 472 | else: 473 | # Mode #5 474 | value = str(evaluator) 475 | return label, value 476 | 477 | 478 | class EditView(BreadViewMixin, UpdateView): 479 | perm_name = "change" # Default Django permission 480 | template_name_suffix = "_edit" 481 | 482 | def form_invalid(self, form): 483 | # Return a 400 if the form isn't valid 484 | rsp = super().form_invalid(form) 485 | rsp.status_code = 400 486 | return rsp 487 | 488 | 489 | class AddView(BreadViewMixin, CreateView): 490 | perm_name = "add" # Default Django permission 491 | template_name_suffix = "_edit" # Yes 'edit' not 'add' 492 | 493 | def form_invalid(self, form): 494 | # Return a 400 if the form isn't valid 495 | rsp = super().form_invalid(form) 496 | rsp.status_code = 400 497 | return rsp 498 | 499 | 500 | class DeleteView(BreadViewMixin, DeleteView): 501 | perm_name = "delete" # Default Django permission 502 | template_name_suffix = "_delete" 503 | 504 | 505 | class Bread: 506 | """ 507 | Provide a set of BREAD views for a model. 508 | 509 | Example usage: 510 | 511 | bread_views_for_model = Bread(Model, other kwargs...) 512 | ... 513 | urlpatterns += bread_views_for_model.get_urls() 514 | 515 | See `get_urls` for the resulting URL names and paths. 516 | 517 | It is expected that you subclass `Bread` and customize it by at least 518 | setting attributes on the subclass. 519 | 520 | Below, refers to the lowercased model name, e.g. 'model'. 521 | 522 | Each view requires a permission. The expected permissions are named: 523 | 524 | * browse_ (not a default Django permission) 525 | * read_ (not a default Django permission) 526 | * change_ (this is a default Django permission, used on the Edit view) 527 | * add_ (this is a default Django permission) 528 | * delete_ (this is a default Django permission) 529 | 530 | Parameters: 531 | 532 | Assumes templates with the following names: 533 | 534 | Browse - /browse.html 535 | Read - /read.html 536 | Edit - /edit.html 537 | Add - /add.html 538 | Delete - /delete.html 539 | 540 | but defaults to bread/.html if those aren't found. The bread/.html 541 | templates are very generic, but you can pass 'base_template' as the name of a template 542 | that they should extend. They will supply `{% block title %}` and `{% block content %}`. 543 | 544 | OR, you can pass in template_name_pattern as a string that will be used to come up with 545 | a template name by substituting `{app_label}`, `{model}` (lower-cased model name), and 546 | `{view}` (`browse`, `read`, etc.). 547 | 548 | """ 549 | 550 | browse_view = BrowseView 551 | read_view = ReadView 552 | edit_view = EditView 553 | add_view = AddView 554 | delete_view = DeleteView 555 | 556 | exclude = [] # Names of fields not to show 557 | views = "BREAD" 558 | base_template = setting("DEFAULT_BASE_TEMPLATE", "base.html") 559 | namespace = "" 560 | template_name_pattern = None 561 | plural_name = None 562 | form_class = None 563 | 564 | def __init__(self): 565 | self.name = self.model._meta.object_name.lower() 566 | self.views = self.views.upper() 567 | 568 | if not self.plural_name: 569 | self.plural_name = self.name + "s" 570 | 571 | if not issubclass(self.model, Model): 572 | raise TypeError( 573 | "'model' argument for Bread must be a " 574 | "subclass of Model; it is %r" % self.model 575 | ) 576 | 577 | if self.browse_view.columns: 578 | for colspec in self.browse_view.columns: 579 | if any(entry == self.browse_view.is_annotation for entry in colspec): 580 | # this column renders an annotation which is not present on the 581 | # model class until querytime. 582 | continue 583 | column = colspec[1] 584 | validate_fieldspec(self.model, column) 585 | 586 | if hasattr(self, "paginate_by") or hasattr(self, "columns"): 587 | raise ValueError( 588 | "The 'paginate_by' and 'columns' settings have been moved " 589 | "from the Bread class to the BrowseView class." 590 | ) 591 | if hasattr(self, "filter"): 592 | raise ValueError( 593 | "The 'filter' setting has been renamed to 'filterset' and moved " 594 | "to the BrowseView." 595 | ) 596 | if hasattr(self, "filterset"): 597 | raise ValueError( 598 | "The 'filterset' setting should be on the BrowseView, not " 599 | "the Bread view." 600 | ) 601 | 602 | def get_additional_context_data(self): 603 | """ 604 | This returns a dictionary which will be added to each view's context data. 605 | Any class subclassing bread should be sure to call its superclass method 606 | and include the results. 607 | """ 608 | data = {} 609 | data["bread"] = self 610 | # Provide references to useful Model Meta attributes 611 | data["verbose_name"] = self.model._meta.verbose_name 612 | data["verbose_name_plural"] = self.model._meta.verbose_name_plural 613 | 614 | # Template that the default bread templates should extend 615 | data["base_template"] = self.base_template 616 | return data 617 | 618 | ##### 619 | # B # 620 | ##### 621 | def browse_url_name(self, include_namespace=True): 622 | """Return the URL name for browsing this model""" 623 | return self.get_url_name("browse", include_namespace) 624 | 625 | def get_browse_view(self): 626 | """Return a view method for browsing.""" 627 | 628 | return self.browse_view.as_view( 629 | bread=self, 630 | model=self.model, 631 | ) 632 | 633 | ##### 634 | # R # 635 | ##### 636 | def read_url_name(self, include_namespace=True): 637 | return self.get_url_name("read", include_namespace) 638 | 639 | def get_read_view(self): 640 | return self.read_view.as_view( 641 | bread=self, 642 | model=self.model, 643 | ) 644 | 645 | ##### 646 | # E # 647 | ##### 648 | def edit_url_name(self, include_namespace=True): 649 | return self.get_url_name("edit", include_namespace) 650 | 651 | def get_edit_view(self): 652 | return self.edit_view.as_view( 653 | bread=self, 654 | model=self.model, 655 | ) 656 | 657 | ##### 658 | # A # 659 | ##### 660 | def add_url_name(self, include_namespace=True): 661 | return self.get_url_name("add", include_namespace) 662 | 663 | def get_add_view(self): 664 | return self.add_view.as_view( 665 | bread=self, 666 | model=self.model, 667 | ) 668 | 669 | ##### 670 | # D # 671 | ##### 672 | def delete_url_name(self, include_namespace=True): 673 | return self.get_url_name("delete", include_namespace) 674 | 675 | def get_delete_view(self): 676 | return self.delete_view.as_view( 677 | bread=self, 678 | model=self.model, 679 | ) 680 | 681 | ########## 682 | # Common # 683 | ########## 684 | def get_url_name(self, view_name, include_namespace=True): 685 | if include_namespace: 686 | url_namespace = self.namespace + ":" if self.namespace else "" 687 | else: 688 | url_namespace = "" 689 | if view_name == "browse": 690 | return f"{url_namespace}{view_name}_{self.plural_name}" 691 | else: 692 | return f"{url_namespace}{view_name}_{self.name}" 693 | 694 | def get_urls(self, prefix=True): 695 | """ 696 | Return urlpatterns to add for this model's BREAD interface. 697 | 698 | By default, these will be of the form: 699 | 700 | Operation Name URL 701 | --------- -------------------- -------------------------- 702 | Browse browse_ / 703 | Read read_ // 704 | Edit edit_ //edit/ 705 | Add add_ /add/ 706 | Delete delete_ //delete/ 707 | 708 | Example usage: 709 | 710 | urlpatterns += my_bread.get_urls() 711 | 712 | If a restricted set of views is passed in the 'views' parameter, then 713 | only URLs for those views will be included. 714 | 715 | If prefix is False, ``/`` will not be included on 716 | the front of the URLs. 717 | 718 | """ 719 | 720 | prefix = "%s/" % self.plural_name if prefix else "" 721 | 722 | urlpatterns = [] 723 | if "B" in self.views: 724 | urlpatterns.append( 725 | path( 726 | "%s" % prefix, 727 | self.get_browse_view(), 728 | name=self.browse_url_name(include_namespace=False), 729 | ) 730 | ) 731 | 732 | if "R" in self.views: 733 | urlpatterns.append( 734 | path( 735 | "%s/" % prefix, 736 | self.get_read_view(), 737 | name=self.read_url_name(include_namespace=False), 738 | ) 739 | ) 740 | 741 | if "E" in self.views: 742 | urlpatterns.append( 743 | path( 744 | "%s/edit/" % prefix, 745 | self.get_edit_view(), 746 | name=self.edit_url_name(include_namespace=False), 747 | ) 748 | ) 749 | 750 | if "A" in self.views: 751 | urlpatterns.append( 752 | path( 753 | "%sadd/" % prefix, 754 | self.get_add_view(), 755 | name=self.add_url_name(include_namespace=False), 756 | ) 757 | ) 758 | 759 | if "D" in self.views: 760 | urlpatterns.append( 761 | path( 762 | "%s/delete/" % prefix, 763 | self.get_delete_view(), 764 | name=self.delete_url_name(include_namespace=False), 765 | ) 766 | ) 767 | return urlpatterns 768 | -------------------------------------------------------------------------------- /bread/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [] 6 | 7 | operations = [ 8 | migrations.CreateModel( 9 | name="BreadTestModel", 10 | fields=[ 11 | ( 12 | "id", 13 | models.AutoField( 14 | verbose_name="ID", 15 | serialize=False, 16 | auto_created=True, 17 | primary_key=True, 18 | ), 19 | ), 20 | ("name", models.CharField(max_length=10)), 21 | ], 22 | options={ 23 | "ordering": ["name"], 24 | "permissions": [("browse_breadtestmodel", "can browse BreadTestModel")], 25 | }, 26 | bases=(models.Model,), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /bread/migrations/0002_delete_breadtestmodel.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("bread", "0001_initial"), 7 | ] 8 | 9 | operations = [ 10 | migrations.DeleteModel( 11 | name="BreadTestModel", 12 | ), 13 | ] 14 | -------------------------------------------------------------------------------- /bread/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caktus/django_bread/46939a78385a3e310f9d122165b42bc097c64d97/bread/migrations/__init__.py -------------------------------------------------------------------------------- /bread/static/js/URI.js: -------------------------------------------------------------------------------- 1 | /*! URI.js v1.15.1 http://medialize.github.io/URI.js/ */ 2 | /* build contains: URI.js */ 3 | (function(p,v){"object"===typeof exports?module.exports=v(require("./punycode"),require("./IPv6"),require("./SecondLevelDomains")):"function"===typeof define&&define.amd?define(["./punycode","./IPv6","./SecondLevelDomains"],v):p.URI=v(p.punycode,p.IPv6,p.SecondLevelDomains,p)})(this,function(p,v,u,l){function d(a,b){var c=1<=arguments.length,f=2<=arguments.length;if(!(this instanceof d))return c?f?new d(a,b):new d(a):new d;if(void 0===a){if(c)throw new TypeError("undefined is not a valid argument for URI"); 4 | a="undefined"!==typeof location?location.href+"":""}this.href(a);return void 0!==b?this.absoluteTo(b):this}function r(a){return a.replace(/([.*+?^=!:${}()|[\]\/\\])/g,"\\$1")}function w(a){return void 0===a?"Undefined":String(Object.prototype.toString.call(a)).slice(8,-1)}function h(a){return"Array"===w(a)}function C(a,b){var c={},d,g;if("RegExp"===w(b))c=null;else if(h(b))for(d=0,g=b.length;d]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?\u00ab\u00bb\u201c\u201d\u2018\u2019]))/ig;d.findUri={start:/\b(?:([a-z][a-z0-9.+-]*:\/\/)|www\.)/gi,end:/[\s\r\n]|$/,trim:/[`!()\[\]{};:'".,<>?\u00ab\u00bb\u201c\u201d\u201e\u2018\u2019]+$/};d.defaultPorts={http:"80",https:"443",ftp:"21",gopher:"70",ws:"80",wss:"443"};d.invalid_hostname_characters= 9 | /[^a-zA-Z0-9\.-]/;d.domAttributes={a:"href",blockquote:"cite",link:"href",base:"href",script:"src",form:"action",img:"src",area:"href",iframe:"src",embed:"src",source:"src",track:"src",input:"src",audio:"src",video:"src"};d.getDomAttribute=function(a){if(a&&a.nodeName){var b=a.nodeName.toLowerCase();return"input"===b&&"image"!==a.type?void 0:d.domAttributes[b]}};d.encode=A;d.decode=decodeURIComponent;d.iso8859=function(){d.encode=escape;d.decode=unescape};d.unicode=function(){d.encode=A;d.decode= 10 | decodeURIComponent};d.characters={pathname:{encode:{expression:/%(24|26|2B|2C|3B|3D|3A|40)/ig,map:{"%24":"$","%26":"&","%2B":"+","%2C":",","%3B":";","%3D":"=","%3A":":","%40":"@"}},decode:{expression:/[\/\?#]/g,map:{"/":"%2F","?":"%3F","#":"%23"}}},reserved:{encode:{expression:/%(21|23|24|26|27|28|29|2A|2B|2C|2F|3A|3B|3D|3F|40|5B|5D)/ig,map:{"%3A":":","%2F":"/","%3F":"?","%23":"#","%5B":"[","%5D":"]","%40":"@","%21":"!","%24":"$","%26":"&","%27":"'","%28":"(","%29":")","%2A":"*","%2B":"+","%2C":",", 11 | "%3B":";","%3D":"="}}},urnpath:{encode:{expression:/%(21|24|27|28|29|2A|2B|2C|3B|3D|40)/ig,map:{"%21":"!","%24":"$","%27":"'","%28":"(","%29":")","%2A":"*","%2B":"+","%2C":",","%3B":";","%3D":"=","%40":"@"}},decode:{expression:/[\/\?#:]/g,map:{"/":"%2F","?":"%3F","#":"%23",":":"%3A"}}}};d.encodeQuery=function(a,b){var c=d.encode(a+"");void 0===b&&(b=d.escapeQuerySpace);return b?c.replace(/%20/g,"+"):c};d.decodeQuery=function(a,b){a+="";void 0===b&&(b=d.escapeQuerySpace);try{return d.decode(b?a.replace(/\+/g, 12 | "%20"):a)}catch(c){return a}};var t={encode:"encode",decode:"decode"},y,B=function(a,b){return function(c){try{return d[b](c+"").replace(d.characters[a][b].expression,function(c){return d.characters[a][b].map[c]})}catch(f){return c}}};for(y in t)d[y+"PathSegment"]=B("pathname",t[y]),d[y+"UrnPathSegment"]=B("urnpath",t[y]);t=function(a,b,c){return function(f){var g;g=c?function(a){return d[b](d[c](a))}:d[b];f=(f+"").split(a);for(var e=0,m=f.length;ed)return a.charAt(0)===b.charAt(0)&&"/"===a.charAt(0)?"/":"";if("/"!==a.charAt(d)||"/"!==b.charAt(d))d=a.substring(0,d).lastIndexOf("/");return a.substring(0,d+1)};d.withinString=function(a,b,c){c||(c={});var f=c.start||d.findUri.start,e=c.end||d.findUri.end,k=c.trim||d.findUri.trim,m=/[a-z0-9-]=["']?$/i;for(f.lastIndex=0;;){var n=f.exec(a);if(!n)break;n=n.index;if(c.ignoreHtml){var h=a.slice(Math.max(n-3,0), 23 | n);if(h&&m.test(h))continue}var h=n+a.slice(n).search(e),l=a.slice(n,h).replace(k,"");c.ignore&&c.ignore.test(l)||(h=n+l.length,l=b(l,n,h,a),a=a.slice(0,n)+l+a.slice(h),f.lastIndex=n+l.length)}f.lastIndex=0;return a};d.ensureValidHostname=function(a){if(a.match(d.invalid_hostname_characters)){if(!p)throw new TypeError('Hostname "'+a+'" contains characters other than [A-Z0-9.-] and Punycode.js is not available');if(p.toASCII(a).match(d.invalid_hostname_characters))throw new TypeError('Hostname "'+ 24 | a+'" contains characters other than [A-Z0-9.-]');}};d.noConflict=function(a){if(a)return a={URI:this.noConflict()},l.URITemplate&&"function"===typeof l.URITemplate.noConflict&&(a.URITemplate=l.URITemplate.noConflict()),l.IPv6&&"function"===typeof l.IPv6.noConflict&&(a.IPv6=l.IPv6.noConflict()),l.SecondLevelDomains&&"function"===typeof l.SecondLevelDomains.noConflict&&(a.SecondLevelDomains=l.SecondLevelDomains.noConflict()),a;l.URI===this&&(l.URI=G);return this};e.build=function(a){if(!0===a)this._deferred_build= 25 | !0;else if(void 0===a||this._deferred_build)this._string=d.build(this._parts),this._deferred_build=!1;return this};e.clone=function(){return new d(this)};e.valueOf=e.toString=function(){return this.build(!1)._string};e.protocol=x("protocol");e.username=x("username");e.password=x("password");e.hostname=x("hostname");e.port=x("port");e.query=E("query","?");e.fragment=E("fragment","#");e.search=function(a,b){var c=this.query(a,b);return"string"===typeof c&&c.length?"?"+c:c};e.hash=function(a,b){var c= 26 | this.fragment(a,b);return"string"===typeof c&&c.length?"#"+c:c};e.pathname=function(a,b){if(void 0===a||!0===a){var c=this._parts.path||(this._parts.hostname?"/":"");return a?(this._parts.urn?d.decodeUrnPath:d.decodePath)(c):c}this._parts.path=this._parts.urn?a?d.recodeUrnPath(a):"":a?d.recodePath(a):"/";this.build(!b);return this};e.path=e.pathname;e.href=function(a,b){var c;if(void 0===a)return this.toString();this._string="";this._parts=d._parts();var f=a instanceof d,e="object"===typeof a&&(a.hostname|| 27 | a.path||a.pathname);a.nodeName&&(e=d.getDomAttribute(a),a=a[e]||"",e=!1);!f&&e&&void 0!==a.pathname&&(a=a.toString());if("string"===typeof a||a instanceof String)this._parts=d.parse(String(a),this._parts);else if(f||e)for(c in f=f?a._parts:a,f)q.call(this._parts,c)&&(this._parts[c]=f[c]);else throw new TypeError("invalid input");this.build(!b);return this};e.is=function(a){var b=!1,c=!1,f=!1,e=!1,k=!1,m=!1,h=!1,l=!this._parts.urn;this._parts.hostname&&(l=!1,c=d.ip4_expression.test(this._parts.hostname), 28 | f=d.ip6_expression.test(this._parts.hostname),b=c||f,k=(e=!b)&&u&&u.has(this._parts.hostname),m=e&&d.idn_expression.test(this._parts.hostname),h=e&&d.punycode_expression.test(this._parts.hostname));switch(a.toLowerCase()){case "relative":return l;case "absolute":return!l;case "domain":case "name":return e;case "sld":return k;case "ip":return b;case "ip4":case "ipv4":case "inet4":return c;case "ip6":case "ipv6":case "inet6":return f;case "idn":return m;case "url":return!this._parts.urn;case "urn":return!!this._parts.urn; 29 | case "punycode":return h}return null};var H=e.protocol,I=e.port,J=e.hostname;e.protocol=function(a,b){if(void 0!==a&&a&&(a=a.replace(/:(\/\/)?$/,""),!a.match(d.protocol_expression)))throw new TypeError('Protocol "'+a+"\" contains characters other than [A-Z0-9.+-] or doesn't start with [A-Z]");return H.call(this,a,b)};e.scheme=e.protocol;e.port=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0!==a&&(0===a&&(a=null),a&&(a+="",":"===a.charAt(0)&&(a=a.substring(1)),a.match(/[^0-9]/))))throw new TypeError('Port "'+ 30 | a+'" contains characters other than [0-9]');return I.call(this,a,b)};e.hostname=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0!==a){var c={};d.parseHost(a,c);a=c.hostname}return J.call(this,a,b)};e.host=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a)return this._parts.hostname?d.buildHost(this._parts):"";d.parseHost(a,this._parts);this.build(!b);return this};e.authority=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a)return this._parts.hostname? 31 | d.buildAuthority(this._parts):"";d.parseAuthority(a,this._parts);this.build(!b);return this};e.userinfo=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a){if(!this._parts.username)return"";var c=d.buildUserinfo(this._parts);return c.substring(0,c.length-1)}"@"!==a[a.length-1]&&(a+="@");d.parseUserinfo(a,this._parts);this.build(!b);return this};e.resource=function(a,b){var c;if(void 0===a)return this.path()+this.search()+this.hash();c=d.parse(a);this._parts.path=c.path;this._parts.query= 32 | c.query;this._parts.fragment=c.fragment;this.build(!b);return this};e.subdomain=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a){if(!this._parts.hostname||this.is("IP"))return"";var c=this._parts.hostname.length-this.domain().length-1;return this._parts.hostname.substring(0,c)||""}c=this._parts.hostname.length-this.domain().length;c=this._parts.hostname.substring(0,c);c=new RegExp("^"+r(c));a&&"."!==a.charAt(a.length-1)&&(a+=".");a&&d.ensureValidHostname(a);this._parts.hostname= 33 | this._parts.hostname.replace(c,a);this.build(!b);return this};e.domain=function(a,b){if(this._parts.urn)return void 0===a?"":this;"boolean"===typeof a&&(b=a,a=void 0);if(void 0===a){if(!this._parts.hostname||this.is("IP"))return"";var c=this._parts.hostname.match(/\./g);if(c&&2>c.length)return this._parts.hostname;c=this._parts.hostname.length-this.tld(b).length-1;c=this._parts.hostname.lastIndexOf(".",c-1)+1;return this._parts.hostname.substring(c)||""}if(!a)throw new TypeError("cannot set domain empty"); 34 | d.ensureValidHostname(a);!this._parts.hostname||this.is("IP")?this._parts.hostname=a:(c=new RegExp(r(this.domain())+"$"),this._parts.hostname=this._parts.hostname.replace(c,a));this.build(!b);return this};e.tld=function(a,b){if(this._parts.urn)return void 0===a?"":this;"boolean"===typeof a&&(b=a,a=void 0);if(void 0===a){if(!this._parts.hostname||this.is("IP"))return"";var c=this._parts.hostname.lastIndexOf("."),c=this._parts.hostname.substring(c+1);return!0!==b&&u&&u.list[c.toLowerCase()]?u.get(this._parts.hostname)|| 35 | c:c}if(a)if(a.match(/[^a-zA-Z0-9-]/))if(u&&u.is(a))c=new RegExp(r(this.tld())+"$"),this._parts.hostname=this._parts.hostname.replace(c,a);else throw new TypeError('TLD "'+a+'" contains characters other than [A-Z0-9]');else{if(!this._parts.hostname||this.is("IP"))throw new ReferenceError("cannot set TLD on non-domain host");c=new RegExp(r(this.tld())+"$");this._parts.hostname=this._parts.hostname.replace(c,a)}else throw new TypeError("cannot set TLD empty");this.build(!b);return this};e.directory= 36 | function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a||!0===a){if(!this._parts.path&&!this._parts.hostname)return"";if("/"===this._parts.path)return"/";var c=this._parts.path.length-this.filename().length-1,c=this._parts.path.substring(0,c)||(this._parts.hostname?"/":"");return a?d.decodePath(c):c}c=this._parts.path.length-this.filename().length;c=this._parts.path.substring(0,c);c=new RegExp("^"+r(c));this.is("relative")||(a||(a="/"),"/"!==a.charAt(0)&&(a="/"+a));a&&"/"!==a.charAt(a.length- 37 | 1)&&(a+="/");a=d.recodePath(a);this._parts.path=this._parts.path.replace(c,a);this.build(!b);return this};e.filename=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a||!0===a){if(!this._parts.path||"/"===this._parts.path)return"";var c=this._parts.path.lastIndexOf("/"),c=this._parts.path.substring(c+1);return a?d.decodePathSegment(c):c}c=!1;"/"===a.charAt(0)&&(a=a.substring(1));a.match(/\.?\//)&&(c=!0);var f=new RegExp(r(this.filename())+"$");a=d.recodePath(a);this._parts.path= 38 | this._parts.path.replace(f,a);c?this.normalizePath(b):this.build(!b);return this};e.suffix=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a||!0===a){if(!this._parts.path||"/"===this._parts.path)return"";var c=this.filename(),f=c.lastIndexOf(".");if(-1===f)return"";c=c.substring(f+1);c=/^[a-z0-9%]+$/i.test(c)?c:"";return a?d.decodePathSegment(c):c}"."===a.charAt(0)&&(a=a.substring(1));if(c=this.suffix())f=a?new RegExp(r(c)+"$"):new RegExp(r("."+c)+"$");else{if(!a)return this; 39 | this._parts.path+="."+d.recodePath(a)}f&&(a=d.recodePath(a),this._parts.path=this._parts.path.replace(f,a));this.build(!b);return this};e.segment=function(a,b,c){var d=this._parts.urn?":":"/",e=this.path(),k="/"===e.substring(0,1),e=e.split(d);void 0!==a&&"number"!==typeof a&&(c=b,b=a,a=void 0);if(void 0!==a&&"number"!==typeof a)throw Error('Bad segment "'+a+'", must be 0-based integer');k&&e.shift();0>a&&(a=Math.max(e.length+a,0));if(void 0===b)return void 0===a?e:e[a];if(null===a||void 0===e[a])if(h(b)){e= 40 | [];a=0;for(var m=b.length;a 9 | {% include "bread/includes/browse.html" %} 10 | 11 | {% endblock content %} 12 | -------------------------------------------------------------------------------- /bread/templates/bread/delete.html: -------------------------------------------------------------------------------- 1 | {% extends base_template %} 2 | 3 | {% block content %} 4 |
5 | {% include 'bread/includes/delete.html' %} 6 |
7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /bread/templates/bread/edit.html: -------------------------------------------------------------------------------- 1 | {% extends base_template %} 2 | 3 | {% block content %} 4 |
5 | {% include 'bread/includes/edit.html' %} 6 |
7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /bread/templates/bread/empty.html: -------------------------------------------------------------------------------- 1 | {# mostly empty template for tests to use as base #} 2 | {% block content %}{% endblock %} 3 | -------------------------------------------------------------------------------- /bread/templates/bread/includes/browse.html: -------------------------------------------------------------------------------- 1 | {% load i18n bread_tags %} 2 | {% if is_paginated %} 3 | {% if first_url %}[{% trans "First" %}]{% endif %} 4 | {% if previous_url %}[{% trans "Previous" %}]{% endif %} 5 | {% blocktrans trimmed with number=page_obj.number num_pages=paginator.num_pages %} 6 | Showing page {{ number }} of {{ num_pages }} 7 | {% endblocktrans %} 8 | {% if next_url %}[{% trans "Next" %}]{% endif %} 9 | {% if last_url %}[{% trans "Last" %}]{% endif %} 10 | {% endif %} 11 | 12 | {% if has_filter or has_search %} 13 |
14 | {% if has_search %} 15 |
16 |

{% blocktrans with names=verbose_name_plural %}Search {{ names }}{% endblocktrans %}

17 |

18 | 19 | {% blocktrans %}Searches in these fields: {{ search_terms }} {% endblocktrans %} 20 |

21 |
22 | {% endif %} 23 | 24 | 25 | {% if has_filter %} 26 | {% if filter.form.non_field_errors %} 27 |
28 | {% for err in filter.form.non_field_errors %} 29 |
30 | {{ err }} 31 |
32 | {% endfor %} 33 |
34 | {% endif %} 35 | 36 | {% for field in filter.form %} 37 |
38 | {{ field.label_tag }} 39 | {{ field }} 40 | {% if field.errors %} 41 |
42 | {{ field.errors }} 43 |
44 | {% endif %} 45 | {% endfor %} 46 | {% endif %} 47 | 48 | {% if o != "" %}{% endif %} 49 | 50 | 51 |
52 | {% endif %} 53 | 54 | 55 | {% if columns %} 56 | 60 | 61 | {% for col in columns %} 62 | {# label #} 63 | {% endfor %} 64 | 65 | {% endif %} 66 | {% for object in view.object_list %} 67 | 68 | {% if columns %} 69 | {% for col in columns %} 70 | 77 | {% endfor %} 78 | {% else %} 79 | 86 | {% endif %} 87 | {% if may_edit %} 88 | 91 | {% elif debug %} 92 | 93 | {% endif %} 94 | 95 | {% endfor %} 96 |
{{ col.0 }}
71 | {% if may_read %} 72 | {{ object|getter:col.1 }} 73 | {% else %} 74 | {{ object|getter:col.1 }} 75 | {% endif %} 76 | 80 | {% if may_read %} 81 | {{ object }} 82 | {% else %} 83 | {{ object }} 84 | {% endif %} 85 | 89 | {% trans "Edit" %} 90 | You do not have edit permission.
97 |
98 | 99 | {% if not exclude_actions %} 100 | {% if may_add %} 101 | {% trans "Add" %} 102 | {% elif debug %} 103 | You do not have add permission. 104 | {% endif %} 105 | {% endif %} 106 | -------------------------------------------------------------------------------- /bread/templates/bread/includes/delete.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% blocktrans with name=view.object %}Really delete {{ name }}?{% endblocktrans %} 4 |
5 | {% csrf_token %} 6 |
7 | 8 |
9 |
10 | {% trans "Back to list" %} 11 | -------------------------------------------------------------------------------- /bread/templates/bread/includes/edit.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |
4 | {% csrf_token %} 5 | {% if form.non_field_errors %} 6 |
7 | {% for err in form.non_field_errors %} 8 |
9 | {{ err }} 10 |
11 | {% endfor %} 12 |
13 | {% endif %} 14 | 15 | {% for field in form %} 16 |
17 | {{ field.label_tag }} 18 | {{ field }} 19 | {% if field.errors %} 20 |
21 | {{ field.errors }} 22 |
23 | {% endif %} 24 | {% endfor %} 25 | 26 |
27 | 28 |
29 | {% trans "Back to list" %} 30 | -------------------------------------------------------------------------------- /bread/templates/bread/includes/label_value_read.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |
4 | {% for label, value in read_fields %} 5 |
6 | : {{ value }} 7 |
8 | {% endfor %} 9 |
10 | 11 | {% if not exclude_actions %} 12 | {% if may_edit %} 13 |
14 | {% trans "Edit" %} 15 | {% elif debug %} 16 |
17 | You do not have edit permission. 18 | {% endif %} 19 | 20 |
21 | {% trans "Back to list" %} 22 | 23 | {% if may_delete %} 24 |
25 | {% trans "Delete" %} 26 | {% elif debug %} 27 |
28 | You do not have delete permission. 29 | {% endif %} 30 | {% endif %} 31 | -------------------------------------------------------------------------------- /bread/templates/bread/includes/read.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |
4 | {# we have a form, just to make it easy to go through the data and display it #} 5 | {% for field in form %} 6 |
7 | {{ field.label_tag }} {{ field.value }} 8 | {% endfor %} 9 |
10 | 11 | {% if not exclude_actions %} 12 | {% if may_edit %} 13 |
14 | {% trans "Edit" %} 15 | {% elif debug %} 16 |
17 | You do not have edit permission. 18 | {% endif %} 19 | 20 |
21 | {% trans "Back to list" %} 22 | 23 | {% if may_delete %} 24 |
25 | {% trans "Delete" %} 26 | {% elif debug %} 27 |
28 | You do not have delete permission. 29 | {% endif %} 30 | {% endif %} 31 | -------------------------------------------------------------------------------- /bread/templates/bread/label_value_read.html: -------------------------------------------------------------------------------- 1 | {% extends base_template %} 2 | 3 | {% block content %} 4 | {% include 'bread/includes/label_value_read.html' %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /bread/templates/bread/read.html: -------------------------------------------------------------------------------- 1 | {% extends base_template %} 2 | 3 | {% block content %} 4 | {% include 'bread/includes/read.html' %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /bread/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caktus/django_bread/46939a78385a3e310f9d122165b42bc097c64d97/bread/templatetags/__init__.py -------------------------------------------------------------------------------- /bread/templatetags/bread_tags.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django import template 4 | from django.core.exceptions import ObjectDoesNotExist 5 | 6 | from bread.utils import get_model_field 7 | 8 | logger = logging.getLogger(__name__) 9 | register = template.Library() 10 | 11 | 12 | @register.filter(name="getter") 13 | def getter(value, arg): 14 | """ 15 | Given an object `value`, return the value of the attribute named `arg`. 16 | `arg` can contain `__` to drill down recursively into the values. 17 | If the final result is a callable, it is called and its return 18 | value used. 19 | """ 20 | try: 21 | return get_model_field(value, arg) 22 | except ObjectDoesNotExist: 23 | pass 24 | except Exception: 25 | logger.exception(f"Something blew up: {value}|getter: {arg}") 26 | -------------------------------------------------------------------------------- /bread/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django has a convenient notation to refer to a 3 | field on a model, even when referenced via one or 4 | more foreign key links: 5 | 6 | https://docs.djangoproject.com/en/dev/topics/db/queries/#lookups-that-span-relationships 7 | 8 | E.g. if you have a model with a "link" field that's 9 | a foreign key to another model with a "name" field, you 10 | can refer to that "name" field in a query as 11 | "link__name" 12 | 13 | The get_model_field method below allows you to do 14 | the same thing in your own code -- you could get the 15 | value of that "name" field using 16 | 17 | get_model_field(my_model_instance, "link__name") 18 | 19 | Of course you could just write ``my_model_instance.link.name`` 20 | if you already knew the field names, but this is helpful for 21 | things like the "search" parameter in the admin that give 22 | a list of strings that need to refer to possibly nested 23 | model fields. 24 | 25 | This provides an additional feature over the lookup syntax 26 | in queryset filters: if the object that the string eventually 27 | resolves to is a callable, it will be called to get its return 28 | value, similar to how references to context variables in templates 29 | work. 30 | """ 31 | import inspect 32 | 33 | from django.core.exceptions import FieldDoesNotExist, ValidationError 34 | from django.db.models import Model 35 | from django.db.models.fields.related import RelatedField 36 | from django.db.models.fields.reverse_related import OneToOneRel 37 | 38 | 39 | def get_value_or_result(model_instance, attribute_name): 40 | attr = getattr(model_instance, attribute_name) 41 | if callable(attr): 42 | return attr() 43 | return attr 44 | 45 | 46 | def get_model_field(model_instance, spec): 47 | if model_instance is None: 48 | raise ValueError("None passed into get_model_field") 49 | if not isinstance(model_instance, Model): 50 | raise ValueError( 51 | "%r should be an instance of a model but it is a %s" 52 | % (model_instance, type(model_instance)) 53 | ) 54 | 55 | if spec.startswith("__") and spec.endswith("__"): 56 | # It's a dunder method; don't split it. 57 | name_parts = [spec] 58 | else: 59 | name_parts = spec.split("__", 1) 60 | 61 | value = getattr(model_instance, name_parts[0]) 62 | if callable(value): 63 | value = value() 64 | if len(name_parts) > 1 and value is not None: 65 | return get_model_field(value, name_parts[1]) 66 | return value 67 | 68 | 69 | def get_verbose_name(an_object, field_name, title_cap=True): 70 | """Given a model or model instance, return the verbose_name of the model's field. 71 | 72 | If title_cap is True (the default), the verbose_name will be returned with the first letter 73 | of each word capitalized which makes the verbose_name look nicer in labels. 74 | 75 | If field_name doesn't refer to a model field, raises a FieldDoesNotExist error. 76 | """ 77 | # get_field() can raise FieldDoesNotExist which I simply propogate up to the caller. 78 | try: 79 | field = an_object._meta.get_field(field_name) 80 | except TypeError: 81 | # TypeError happens if the caller is very confused and passes an unhashable type such 82 | # as {} or []. I convert that into a FieldDoesNotExist exception for simplicity. 83 | raise FieldDoesNotExist(f"No field named {str(field_name)}") 84 | 85 | verbose_name = field.verbose_name 86 | 87 | if title_cap: 88 | # Title cap the label using this stackoverflow-approved technique: 89 | # http://stackoverflow.com/questions/1549641/how-to-capitalize-the-first-letter-of-each-word-in-a-string-python 90 | verbose_name = " ".join(word.capitalize() for word in verbose_name.split()) 91 | 92 | return verbose_name 93 | 94 | 95 | def has_required_args(func): 96 | """ 97 | Return True if the function has any required arguments. 98 | """ 99 | spec = inspect.getfullargspec(func) 100 | num_args = len(spec.args) 101 | # If first arg is 'self', we can ignore one arg 102 | if num_args and spec.args[0] == "self": 103 | num_args -= 1 104 | # If there are defaults, we can ignore the same number of args 105 | if spec.defaults: 106 | num_args -= len(spec.defaults) 107 | # Do we still have args without defaults? 108 | return num_args > 0 109 | 110 | 111 | def validate_fieldspec(model, spec): 112 | """ 113 | Given a model class and a string that refers to a possibly nested 114 | field on that model (as used in `get_model_field`). 115 | 116 | Raises a ValidationError with a useful error message if the spec 117 | does not appear to be valid. 118 | 119 | Otherwise just returns. 120 | """ 121 | if not issubclass(model, Model): 122 | raise TypeError( 123 | "First argument to validate_fieldspec must be a " 124 | "subclass of Model; it is %r" % model 125 | ) 126 | parts = spec.split("__", 1) 127 | rest_of_spec = parts[1] if len(parts) > 1 else None 128 | 129 | # What are the possibilities for what parts[0] is on our model? 130 | # - It could be a field 131 | # - simple (not a key) 132 | # - key 133 | # - It could be a non-field 134 | # - class variable 135 | # - method 136 | # - It could not exist 137 | 138 | try: 139 | field = model._meta.get_field(parts[0]) 140 | except FieldDoesNotExist: 141 | # Not a field - is there an attribute of some sort? 142 | if not hasattr(model, parts[0]): 143 | raise ValidationError( 144 | "There is no field or attribute named '%s' on model '%s'" 145 | % (parts[0], model) 146 | ) 147 | if rest_of_spec: 148 | raise ValidationError( 149 | "On model '%s', '%s' is not a field, but the spec tries to refer through " 150 | "it to '%s'." % (model, parts[0], rest_of_spec) 151 | ) 152 | attr = getattr(model, parts[0]) 153 | if callable(attr): 154 | if has_required_args(attr): 155 | raise ValidationError( 156 | "On model '%s', '%s' is callable and has required arguments; it is not " 157 | "valid to use in a field spec" % (model, parts[0]) 158 | ) 159 | else: 160 | # It's a field 161 | # Is it a key? 162 | if isinstance(field, RelatedField) or isinstance(field, OneToOneRel): 163 | # Yes, refers to another model 164 | if rest_of_spec: 165 | # Recurse! 166 | validate_fieldspec(model=field.related_model, spec=rest_of_spec) 167 | # Well, it's okay if it just returns a reference to another record 168 | else: 169 | # Simple field 170 | # Is there more spec? 171 | if rest_of_spec: 172 | raise ValidationError( 173 | "On model '%s', '%s' is not a key field, but the spec tries to refer through " 174 | "it to '%s'." % (model, parts[0], rest_of_spec) 175 | ) 176 | # Simple field, no more spec, looks good 177 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | . # <- install ourselves 2 | coverage 3 | django>=4.2,<6.0 4 | factory_boy==3.2.1 5 | flake8 6 | pre-commit 7 | sphinx 8 | tox 9 | tox-gh-actions 10 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoBread.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoBread.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoBread" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoBread" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/changes.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | .. _changes: 4 | 5 | Change Log 6 | ========== 7 | 8 | 1.0.8 - May 23, 2025 9 | -------------------- 10 | 11 | * Add support for Django 5.2 12 | 13 | 1.0.7 - Nov 7, 2024 14 | ------------------- 15 | 16 | * Add support for Django 5.x and removes support for deprecated versions 17 | * Add support for Python 3.11 and 3.12 18 | * Remove support for Python 3.8 19 | 20 | 21 | 1.0.6 - Jan 22, 2024 22 | -------------------- 23 | 24 | * Updated BrowseView.get_queryset to pass the request object to the filterset class 25 | 26 | 1.0.5 - Aug 3, 2023 27 | -------------------- 28 | 29 | * Add support for Django 4.x and removes support for deprecated versions 30 | * Remove support for Python 3.7 31 | 32 | 33 | 1.0.4 - Mar 24, 2022 34 | -------------------- 35 | 36 | * Add support for traversing reverse ``OneToOneRel`` fields 37 | 38 | 1.0.3 - Mar 23, 2022 39 | -------------------- 40 | 41 | * Add Django 3.2 and Python 3.10 support 42 | 43 | 1.0.2 - Dec 16, 2020 44 | -------------------- 45 | 46 | * Switch from Travis to Github Actions. There are no functional 47 | changes in the package, but I'm using this release to test the 48 | Github Actions release workflow. 49 | 50 | 1.0.1 - Dec 2, 2020 51 | ------------------- 52 | 53 | * Drop specific version dependencies in setup.py for 54 | django-filter and django-vanilla-views 55 | * Update supported versions of Python and Django, 56 | dropping Django 2.1 and adding 3.0 and 3.1, 57 | and dropping Python 3.5 and 3.6 and adding 3.8 and 3.9. 58 | 59 | Supported versions in this release are: 60 | 61 | Django: 2.2, 3.0, 3.1 62 | Python: 3.7, 3.8, 3.9 63 | 64 | 1.0.0 - Oct 20, 2020 65 | -------------------- 66 | 67 | * BREAKING CHANGE: by default, read views now need Django's 68 | "view_" permission instead of the nonstandard 69 | "read_". 70 | * Drop support for Django 2.0 71 | 72 | Supported versions in this release are: 73 | 74 | Django: 2.1, 2.2 75 | Python: 3.5, 3.6, 3.7 76 | 77 | 0.6.0 - Apr 19, 2019 78 | -------------------- 79 | 80 | * Add support for Python 3.7 81 | * Add support for Django 2.0, 2.1 and 2.2 82 | * Drop support for Python < 3 83 | * Drop support for Django <= 1.11 84 | 85 | 0.5.1 - Dec 1, 2017 86 | ------------------- 87 | 88 | * Allow specifying default ordering on views for cases when we 89 | don't control the model and can't change the ordering there. 90 | 91 | 0.5.0 - Nov 30, 2017 92 | -------------------- 93 | 94 | * Use models' default ordering to make pagination more stable 95 | when user is sorting. 96 | 97 | 0.4.0 - Oct 31, 2017 98 | -------------------- 99 | 100 | * Add support for Python 3.6 101 | * Add support for Django 1.11 102 | * Drop support for Django 1.9 (no longer supported by Django) 103 | * Drop support for Python 3.4 104 | 105 | Supported versions in this release are: 106 | 107 | Django: 1.8, 1.10, 1.11 108 | Python: 2.7, 3.5, 3.6 109 | 110 | 111 | 0.3.0 - Oct 19, 2016 112 | -------------------- 113 | 114 | * Add CI coverage for Python 3.5 115 | * Add support for Django 1.9 and 1.10 116 | 117 | 0.2.4 - Aug 21, 2015 118 | -------------------- 119 | 120 | * Add migration to drop BreadTest table (#64) 121 | 122 | 0.2.3 - Jun 11, 2015 123 | -------------------- 124 | 125 | * New release because 0.2.2 didn't have the right 126 | version number internally. 127 | 128 | 0.2.2 - Jun 9, 2015 129 | ------------------- 130 | 131 | * Allow not sorting on columns (#60) 132 | 133 | 0.2.1 - Jun 6, 2015 134 | ------------------- 135 | 136 | * Handle exception from related object does not exist (#58) 137 | 138 | 0.2.0 - Jun 3, 2015 139 | ------------------- 140 | 141 | * Add Bread.get_additional_context_data(). (#57) 142 | 143 | 0.1.9 - Jun 2, 2015 144 | ------------------- 145 | 146 | * Fix setting form_class on individual views (#55) 147 | 148 | 0.1.8 - May 21, 2015 149 | -------------------- 150 | 151 | * Fix template so files can be uploaded from forms 152 | * Fix javascript to not fail if `o_field` is not defined. 153 | 154 | 0.1.7 - May 21, 2015 155 | -------------------- 156 | 157 | * Tweaks to sorting (includes breaking changes to how sorted columns 158 | are formatted; see docs). 159 | * Fix searches with non-ASCII characters. 160 | 161 | 0.1.6 - May 19, 2015 162 | -------------------- 163 | 164 | * Sortable columns in browse view 165 | 166 | 0.1.5 - May 14, 2015 167 | -------------------- 168 | 169 | * Fix displaying search parameter in search field with results 170 | * Fix filters disappearing if there are no results 171 | 172 | 0.1.4 - May 7, 2015 173 | ------------------- 174 | 175 | * Add search 176 | * Add doc for LabelValueReadView 177 | * More flexible template resolution 178 | 179 | 0.1.3 - May 6, 2015 180 | ------------------- 181 | 182 | * Add LabelValueReadView 183 | 184 | 0.1.2 - May 6, 2015 185 | ------------------- 186 | 187 | * Use six for python 2/3 compatibility 188 | * expose model verbose names to templates 189 | 190 | 0.1.1 - April 30, 2015 191 | ---------------------- 192 | 193 | * Allow omitting model names from URL patterns 194 | 195 | 0.1.0 196 | ----- 197 | 198 | * Breaking changes to how Bread views are configured. 199 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # Django Bread documentation build configuration file, created by 3 | # sphinx-quickstart on Wed Apr 22 11:14:08 2015. 4 | # 5 | # This file is execfile()d with the current directory set to its 6 | # containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | from bread import __version__ 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ------------------------------------------------ 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | # needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be 27 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 28 | # ones. 29 | extensions = [ 30 | "sphinx.ext.autodoc", 31 | "sphinx.ext.intersphinx", 32 | ] 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ["_templates"] 36 | 37 | # The suffix(es) of source filenames. 38 | # You can specify multiple suffix as a list of string: 39 | # source_suffix = ['.rst', '.md'] 40 | source_suffix = ".rst" 41 | 42 | # The encoding of source files. 43 | # source_encoding = 'utf-8-sig' 44 | 45 | # The master toctree document. 46 | master_doc = "index" 47 | 48 | # General information about the project. 49 | project = "Django Bread" 50 | copyright = "2015-2023, Caktus Consulting, LLC" 51 | author = "Caktus Consulting, LLC" 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = ".".join(__version__.split(".")[0:2]) 59 | # The full version, including alpha/beta/rc tags. 60 | release = __version__ 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # 65 | # This is also used if you do content translation via gettext catalogs. 66 | # Usually you set "language" from the command line for these cases. 67 | language = None 68 | 69 | # There are two options for replacing |today|: either, you set today to some 70 | # non-false value, then it is used: 71 | # today = '' 72 | # Else, today_fmt is used as the format for a strftime call. 73 | # today_fmt = '%B %d, %Y' 74 | 75 | # List of patterns, relative to source directory, that match files and 76 | # directories to ignore when looking for source files. 77 | exclude_patterns = ["_build"] 78 | 79 | # The reST default role (used for this markup: `text`) to use for all 80 | # documents. 81 | # default_role = None 82 | 83 | # If true, '()' will be appended to :func: etc. cross-reference text. 84 | # add_function_parentheses = True 85 | 86 | # If true, the current module name will be prepended to all description 87 | # unit titles (such as .. function::). 88 | # add_module_names = True 89 | 90 | # If true, sectionauthor and moduleauthor directives will be shown in the 91 | # output. They are ignored by default. 92 | # show_authors = False 93 | 94 | # The name of the Pygments (syntax highlighting) style to use. 95 | pygments_style = "sphinx" 96 | 97 | # A list of ignored prefixes for module index sorting. 98 | # modindex_common_prefix = [] 99 | 100 | # If true, keep warnings as "system message" paragraphs in the built documents. 101 | # keep_warnings = False 102 | 103 | # If true, `todo` and `todoList` produce output, else they produce nothing. 104 | todo_include_todos = False 105 | 106 | 107 | # -- Options for HTML output ---------------------------------------------- 108 | 109 | # The theme to use for HTML and HTML Help pages. See the documentation for 110 | # a list of builtin themes. 111 | html_theme = "alabaster" 112 | 113 | # Theme options are theme-specific and customize the look and feel of a theme 114 | # further. For a list of options available for each theme, see the 115 | # documentation. 116 | # html_theme_options = {} 117 | 118 | # Add any paths that contain custom themes here, relative to this directory. 119 | # html_theme_path = [] 120 | 121 | # The name for this set of Sphinx documents. If None, it defaults to 122 | # " v documentation". 123 | # html_title = None 124 | 125 | # A shorter title for the navigation bar. Default is the same as html_title. 126 | # html_short_title = None 127 | 128 | # The name of an image file (relative to this directory) to place at the top 129 | # of the sidebar. 130 | # html_logo = None 131 | 132 | # The name of an image file (within the static path) to use as favicon of the 133 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 134 | # pixels large. 135 | # html_favicon = None 136 | 137 | # Add any paths that contain custom static files (such as style sheets) here, 138 | # relative to this directory. They are copied after the builtin static files, 139 | # so a file named "default.css" will overwrite the builtin "default.css". 140 | html_static_path = ["_static"] 141 | 142 | # Add any extra paths that contain custom files (such as robots.txt or 143 | # .htaccess) here, relative to this directory. These files are copied 144 | # directly to the root of the documentation. 145 | # html_extra_path = [] 146 | 147 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 148 | # using the given strftime format. 149 | # html_last_updated_fmt = '%b %d, %Y' 150 | 151 | # If true, SmartyPants will be used to convert quotes and dashes to 152 | # typographically correct entities. 153 | # html_use_smartypants = True 154 | 155 | # Custom sidebar templates, maps document names to template names. 156 | # html_sidebars = {} 157 | 158 | # Additional templates that should be rendered to pages, maps page names to 159 | # template names. 160 | # html_additional_pages = {} 161 | 162 | # If false, no module index is generated. 163 | # html_domain_indices = True 164 | 165 | # If false, no index is generated. 166 | # html_use_index = True 167 | 168 | # If true, the index is split into individual pages for each letter. 169 | # html_split_index = False 170 | 171 | # If true, links to the reST sources are added to the pages. 172 | # html_show_sourcelink = True 173 | 174 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 175 | # html_show_sphinx = True 176 | 177 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 178 | # html_show_copyright = True 179 | 180 | # If true, an OpenSearch description file will be output, and all pages will 181 | # contain a tag referring to it. The value of this option must be the 182 | # base URL from which the finished HTML is served. 183 | # html_use_opensearch = '' 184 | 185 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 186 | # html_file_suffix = None 187 | 188 | # Language to be used for generating the HTML full-text search index. 189 | # Sphinx supports the following languages: 190 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 191 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 192 | # html_search_language = 'en' 193 | 194 | # A dictionary with options for the search language support, empty by default. 195 | # Now only 'ja' uses this config value 196 | # html_search_options = {'type': 'default'} 197 | 198 | # The name of a javascript file (relative to the configuration directory) that 199 | # implements a search results scorer. If empty, the default will be used. 200 | # html_search_scorer = 'scorer.js' 201 | 202 | # Output file base name for HTML help builder. 203 | htmlhelp_basename = "DjangoBreaddoc" 204 | 205 | # -- Options for LaTeX output --------------------------------------------- 206 | 207 | latex_elements = { 208 | # The paper size ('letterpaper' or 'a4paper'). 209 | # 'papersize': 'letterpaper', 210 | # The font size ('10pt', '11pt' or '12pt'). 211 | # 'pointsize': '10pt', 212 | # Additional stuff for the LaTeX preamble. 213 | # 'preamble': '', 214 | # Latex figure (float) alignment 215 | # 'figure_align': 'htbp', 216 | } 217 | 218 | # Grouping the document tree into LaTeX files. List of tuples 219 | # (source start file, target name, title, 220 | # author, documentclass [howto, manual, or own class]). 221 | latex_documents = [ 222 | ( 223 | master_doc, 224 | "DjangoBread.tex", 225 | "Django Bread Documentation", 226 | "Caktus Consulting, LLC", 227 | "manual", 228 | ), 229 | ] 230 | 231 | # The name of an image file (relative to this directory) to place at the top of 232 | # the title page. 233 | # latex_logo = None 234 | 235 | # For "manual" documents, if this is true, then toplevel headings are parts, 236 | # not chapters. 237 | # latex_use_parts = False 238 | 239 | # If true, show page references after internal links. 240 | # latex_show_pagerefs = False 241 | 242 | # If true, show URL addresses after external links. 243 | # latex_show_urls = False 244 | 245 | # Documents to append as an appendix to all manuals. 246 | # latex_appendices = [] 247 | 248 | # If false, no module index is generated. 249 | # latex_domain_indices = True 250 | 251 | 252 | # -- Options for manual page output --------------------------------------- 253 | 254 | # One entry per manual page. List of tuples 255 | # (source start file, name, description, authors, manual section). 256 | man_pages = [(master_doc, "djangobread", "Django Bread Documentation", [author], 1)] 257 | 258 | # If true, show URL addresses after external links. 259 | # man_show_urls = False 260 | 261 | 262 | # -- Options for Texinfo output ------------------------------------------- 263 | 264 | # Grouping the document tree into Texinfo files. List of tuples 265 | # (source start file, target name, title, author, 266 | # dir menu entry, description, category) 267 | texinfo_documents = [ 268 | ( 269 | master_doc, 270 | "DjangoBread", 271 | "Django Bread Documentation", 272 | author, 273 | "DjangoBread", 274 | "One line description of project.", 275 | "Miscellaneous", 276 | ), 277 | ] 278 | 279 | # Documents to append as an appendix to all manuals. 280 | # texinfo_appendices = [] 281 | 282 | # If false, no module index is generated. 283 | # texinfo_domain_indices = True 284 | 285 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 286 | # texinfo_show_urls = 'footnote' 287 | 288 | # If true, do not generate a @detailmenu in the "Top" node's menu. 289 | # texinfo_no_detailmenu = False 290 | 291 | 292 | # Example configuration for intersphinx: refer to the Python standard library. 293 | intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} 294 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | .. _configuration: 2 | 3 | Configuration 4 | ============= 5 | 6 | Bread views are configured similar to some other class-based views, by 7 | subclassing and setting attributes on the subclasses, then instantiating 8 | one and adding it to your URL config. 9 | 10 | The main class to subclass is ``Bread``:: 11 | 12 | class MyBreadView(Bread): 13 | attrib1 = something 14 | attrib2 = somethingelse 15 | 16 | then you can add it to a URL config something like this:: 17 | 18 | path('', include(MyBreadView().get_urls())), 19 | 20 | See also :ref:`urls`. 21 | 22 | Bread configuration 23 | ------------------- 24 | 25 | There are a lot of things that can be configured, so they're somewhat 26 | organized by which views they affect. Any parameter that affects 27 | multiple views (e.g. browse, read, edit) is set on what we'll call 28 | the ``main`` class, that is, the class that is a subclass of ``Bread``. 29 | 30 | Here are some of those common parameters. 31 | 32 | model (required) 33 | The model class 34 | 35 | get_additional_context_data 36 | Override this method to add data to the template context for all views. 37 | You must include the data provided by Bread too, e.g.:: 38 | 39 | def get_additional_context_data(self): 40 | context = super().get_additional_context_data() 41 | context['my_var'] = compute_my_value() 42 | return context 43 | 44 | exclude 45 | A list of names of fields to always exclude from any form classes that 46 | Bread generates itself. (Not used for any views that have a custom form 47 | class provided.) 48 | 49 | form_class 50 | A model form class that Bread should use instead of generating one 51 | itself. Can also be overridden on individual views that use forms. 52 | 53 | name 54 | A string to use in url names, permissions, etc. Defaults to the model's 55 | name, lowercased. Example: If the model class is ``MyModel``, the default 56 | name would be ``mymodel``. See also :ref:`urls` and :ref:`templates`. 57 | 58 | plural_name 59 | A string to use as the plural name where needed (see ``name``, :ref:`urls`, 60 | and :ref:`templates`). 61 | Default: the name with an ``s`` appended. 62 | 63 | namespace 64 | A string with the URL namespace to include in the generated URLS. 65 | Default is `''`. See also :ref:`urls`. 66 | 67 | views 68 | A string containing the first letters of the views to include. 69 | Default is 'BREAD'. Any omitted views will not have URLs defined and so will 70 | not be accessible. 71 | 72 | Example:: 73 | 74 | class MyBreadView(Bread): 75 | model = MyModel 76 | form_class = MyModelForm 77 | views = 'BRD' 78 | 79 | Configuring individual views 80 | ---------------------------- 81 | 82 | To set things that only affect one view, subclass the default base 83 | class for that view and set attributes on it, then configure the 84 | main bread subclass to use your view subclass by setting these 85 | attributes: 86 | 87 | browse_view 88 | Use this class for the browse view. Default: `bread.BrowseView` 89 | 90 | read_view 91 | Use this class for the read view. Default: `bread.ReadView` 92 | 93 | edit_view 94 | Use this class for the edit view. Default: `bread.EditView` 95 | 96 | add_view 97 | Use this class for the add view. Default: `bread.AddView` 98 | 99 | delete_view 100 | Use this class for the delete view. Default: `bread.DeleteView` 101 | 102 | Example:: 103 | 104 | class MyBrowseView(bread.BrowseView): 105 | param1 = value1 106 | param2 = value2 107 | 108 | class MyBreadView(Bread): 109 | attrib1 = something 110 | attrib2 = somethingelse 111 | browse_view = MyBrowseView 112 | 113 | Common view configuration parameters 114 | ------------------------------------ 115 | 116 | These can be set on any individual view class. 117 | 118 | perm_name 119 | The base permission name needed to access the view. Defaults are 120 | 'browse', 'view', 'edit', 'add', and 'delete'. Then `_` and the 121 | lowercased model name are appended to get the complete permission name 122 | that a user must have to access the view. E.g. if your model is 123 | `MyModel` and you leave the default `perm_name` on the browse view, 124 | the user must have `browse_mymodel` permission. 125 | 126 | (Note that the permission for the "read" view is "view", not "read". 127 | It's a little confusing in this context, but "view" is what Django 128 | decided on for its standard read-only permission.) 129 | 130 | template_name_suffix 131 | The default string that the template this view uses will end with. 132 | Defaults are '_browse', '_read', '_edit', '_edit' (not '_add'), and '_delete'. 133 | See also :ref:`templates`. 134 | 135 | 136 | Browse view configuration 137 | ------------------------- 138 | 139 | Subclass `bread.BrowseView` and set these parameters. 140 | 141 | BrowseView is itself a subclass of Vanilla's ListView. 142 | 143 | columns 144 | Iterable of ('Title', 'attrname') pairs to customize the columns 145 | in the browse view. 'attrname' may include '__' to drill down into fields, 146 | e.g. 'user__name' to get the user's name, or 'type__get_number_display' to 147 | call get_number_display() on the object from the type field. (Assumes 148 | the default template, obviously). 'attrname' may also be a dunder method 149 | like `__unicode__` or `__len__`. 150 | 151 | filterset 152 | filterset class to use to control filtering. Must be a subclass 153 | of django-filters' `django_filters.FilterSet` class. 154 | 155 | paginate_by 156 | Limit browsing to this many items per page, and add controls 157 | to navigate among pages. 158 | 159 | search_fields 160 | If set, enables search. Value is a list or tuple like the 161 | `same field `_ 162 | on the Django admin. 163 | 164 | This also enables display of a search input box in the default browse 165 | template. 166 | 167 | If there's a GET query parameter named ``q``, then its value will be split into 168 | words, and results will be limited to those that contain each of the words in 169 | at least one of the specified fields, not case sensitive. 170 | 171 | For example, if search_fields is set to ['first_name', 'last_name'] and a user 172 | searches for john lennon, Django will do the equivalent of this SQL WHERE clause:: 173 | 174 | WHERE (first_name ILIKE '%john%' OR last_name ILIKE '%john%') 175 | AND (first_name ILIKE '%lennon%' OR last_name ILIKE '%lennon%') 176 | 177 | To customize the search behavior, you can override the ``get_search_results`` 178 | method on the browse view, which has the same signature and behavior as 179 | the 180 | `same method `_ 181 | in the admin. 182 | 183 | search_terms 184 | If set, should be translated text listing the data fields that the search will 185 | apply to. For example, if your ``search_fields`` are ``['name', 'phone', 'manager__name']``, 186 | then you might set ``search_terms`` to ``_('name, phone number, or manager's name')``. 187 | Then ``search_terms`` will be available in the browse template context to help 188 | the user understand how their search will work. 189 | 190 | sorting 191 | The default browse template will include sort controls on the column headers 192 | for columns that are sortable. 193 | 194 | It's a good idea to define a default ordering in the model's ``Meta`` class. 195 | After applying any sort columns specified by the user, Bread will add on any 196 | default orderings not already mentioned. That will result in the overall sort 197 | being stable, which is important if you want pagination to be sensible. 198 | (Otherwise, every time we show a new page, we could be working off a different 199 | sorting of the results!) If nothing else, include a sort on the primary key. 200 | 201 | If you do not have control of the model and so cannot change its ordering there, 202 | you can add a ``default_ordering`` attribute to the browse view. Bread will use that 203 | if present, instead of the model's ordering. 204 | 205 | Configuring the browse view: 206 | 207 | If the second item in the ``columns`` entry for a column is not a valid specification 208 | for sorting on that column (e.g. it might refer to a method on the model), then 209 | you can add a third item to that column entry to provide a sort spec. E.g. 210 | ``('Office', 'name', 'name_english')``. 211 | 212 | Alternatively, if the second item in the ``columns`` entry for a column is valid for 213 | sorting, but you don't want the table to be sortable on that column, add a third 214 | item with a value of ``False``, e.g. ``('Date', 'creation_date', False)``. 215 | 216 | Query parameters: 217 | 218 | If there's a GET query parameter named ``o``, then its value will be split on 219 | commas, and each item should be a column number (0-based) optionally prefixed 220 | with '-'. Any column whose number is included with '-' will be sorted 221 | descending, while any column whose number is included without '-' will be sorted 222 | ascending. The first column mentioned will be the primary sort column and so on. 223 | 224 | (Typically links are generated for you by Bread's Javascript, so you don't 225 | have to come up with these query parameters yourself.) 226 | 227 | Template context variables: 228 | 229 | If there's an ``o`` query parameter, there will be an ``o`` variable in the 230 | template context containing the value of it. Otherwise, the ``o`` variable 231 | will exist but contain an empty string. 232 | 233 | There will be a context variable named ``valid_sorting_columns_json`` 234 | which is a JSON string containing a list of the indexes of the columns that are 235 | valid to sort on. 236 | 237 | If you're not using the default bread templates or at least 238 | ``bread/includes/browse.html``, be sure to give your ``th`` elements a 239 | class of ``col_header`` and to include this javascript snippet:: 240 | 241 | 245 | 246 | Styling: 247 | 248 | Any ``th`` element on a column that can be sorted will have the ``sortable`` 249 | CSS class added to it, in case you want to style it differently. 250 | 251 | Additionally, a ``th`` element on a column that is sorted ascending will have 252 | the ``sort_asc`` class, or if sorted descending the ``sort_desc`` class, or 253 | if sortable but not current sorted, the ``unsorted`` class. 254 | 255 | Also, the ``th`` will have an attribute added, ``sort_column``, whose value 256 | will be ``1`` on the primary sort column, ``2`` on the secondary sort column, 257 | etc. 258 | 259 | This allows styling the columns with CSS like this:: 260 | 261 | th.sortable.unsorted::after { 262 | content: "\00A0▲▼"; 263 | opacity: 0.2; 264 | } 265 | table th.sortable.sortasc::after { 266 | content: "\00A0(" attr(sort_column) "▲)"; 267 | } 268 | table th.sortable.sortdesc::after { 269 | content: "\00A0(" attr(sort_column) "▼)"; 270 | } 271 | 272 | which will put " (1▲)" after the header on the primary sorting column if it's 273 | ascending, etc. 274 | 275 | 276 | Read view configuration 277 | ----------------------- 278 | 279 | Subclass `bread.ReadView` and set these parameters. 280 | 281 | ReadView itself is a subclass of Vanilla's DetailView. 282 | 283 | exclude 284 | A list of names of fields to always exclude from any form classes that 285 | Bread generates itself. Not used in this view if a custom form class 286 | is provided. If specified, replaces `exclude` from the `BreadView` 287 | subclass. 288 | 289 | form_class 290 | specify a custom form class to use for this model in this view 291 | 292 | Alternate read view configuration 293 | --------------------------------- 294 | 295 | The default read view uses a form to describe which fields to display. If 296 | you would rather have more flexibility, subclass `bread.LabelValueReadView` 297 | and set these parameters. 298 | 299 | LabelValueReadView is a subclass of ReadView. 300 | 301 | fields 302 | A list of 2-tuples of (label, evaluator) where the evaluator is reference 303 | to an object attribute, an object method, a function, or one of a few other 304 | options. In addition, the label can be automatically generated for you in 305 | some cases. 306 | 307 | See the class docstring for full details. 308 | 309 | Edit view configuration 310 | ----------------------- 311 | 312 | Subclass `bread.EditView` and set these parameters. 313 | 314 | EditView itself is a subclass of Vanilla's UpdateView. 315 | 316 | exclude 317 | A list of names of fields to always exclude from any form classes that 318 | Bread generates itself. Not used in this view if a custom form class 319 | is provided. If specified, replaces `exclude` from the `BreadView` 320 | subclass. 321 | 322 | form_class 323 | specify a custom form class to use for this model in this view 324 | 325 | 326 | Add view configuration 327 | ---------------------- 328 | 329 | Subclass `bread.AddView` and set these parameters. 330 | 331 | AddView itself is a subclass of Vanilla's CreateView. 332 | 333 | exclude 334 | A list of names of fields to always exclude from any form classes that 335 | Bread generates itself. Not used in this view if a custom form class 336 | is provided. If specified, replaces `exclude` from the `BreadView` 337 | subclass. 338 | 339 | form_class 340 | specify a custom form class to use for this model in this view 341 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Your input is welcome. The github repo is public at 5 | https://github.com/caktus/django_bread. You can open issues, and/or 6 | fork it and submit pull requests with fixes or enhancements. 7 | 8 | Before submitting changes, please ensure the tests pass. 9 | 10 | To run the tests, install "tox" ("pip install tox") and just run it: 11 | 12 | $ tox 13 | 14 | Adding new tests for your changes is appreciated but not 15 | a prerequisite to accepting your changes (though we might add 16 | them before merging). 17 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Django Bread documentation master file, created by 2 | sphinx-quickstart on Wed Apr 22 11:14:08 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Django Bread's documentation! 7 | ======================================== 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | installation 15 | configuration 16 | urls 17 | templates 18 | contributing 19 | changes 20 | 21 | Django Bread is a Django app to help build BREAD (Browse, Read, Edit, 22 | Add, Delete) views for Django models. 23 | 24 | It helps with default templates, url generation, permissions, filters, 25 | pagination, and more. 26 | 27 | 28 | Indices and tables 29 | ================== 30 | 31 | * :ref:`genindex` 32 | * :ref:`modindex` 33 | * :ref:`search` 34 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ============ 5 | 6 | Django Bread is on `PyPI `_. To install, add this to your requirements.txt:: 7 | 8 | django-bread==0.6.0 9 | 10 | Just change ``0.6.0`` in that example to the version that you 11 | want to install. Or leave it out to get the latest release. 12 | 13 | Then run:: 14 | 15 | pip -r requirements.txt 16 | 17 | as usual. 18 | 19 | Django 20 | ------ 21 | 22 | * Add 'bread' to your ``INSTALLED_APPS`` 23 | * In any template where you're using Bread views, load bread's javascript 24 | files, using something like:: 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # Newer Jinja2 dies with: 2 | # cannot import name 'contextfunction' from 'jinja2' 3 | Jinja2<3.1.0 4 | -------------------------------------------------------------------------------- /docs/templates.rst: -------------------------------------------------------------------------------- 1 | .. _templates: 2 | 3 | Templates 4 | ========= 5 | 6 | This document describes how to use templates with Django Bread. 7 | 8 | Template Context 9 | ---------------- 10 | 11 | In addition to the context variables that Django Vanilla Views provides, Django Bread provides the 12 | following to all of your templates:: 13 | 14 | Variable Description 15 | -------- ----------- 16 | bread Bread object 17 | verbose_name Model's verbose_name field 18 | verbose_name_plural Model's verbose_name_plural field 19 | base_template The default base_template that should be extended. 20 | may_{action} (i.e. may_browse, may_read, etc.) Boolean describing whether user has specified permission 21 | 22 | Certain action views provide some additional variables. The Browse view has ``columns`` and 23 | ``filter``. The Read view has ``form``. 24 | 25 | Override ``get_context_data`` to change the variables provided to your template: 26 | 27 | .. code-block:: python 28 | 29 | class MyBrowseView(BrowseView): 30 | def get_context_data(self, **kwargs): 31 | data = super(BrowseView, self).get_context_data(**kwargs) 32 | data['my_special_var'] = 42 33 | return data 34 | 35 | 36 | Template Resolution 37 | ------------------- 38 | 39 | There are various ways to configure how and in what order Django Bread searches for the proper 40 | template for your view. 41 | 42 | By default, each Django Bread view will search first in an app-specific location (described below), 43 | and then fall back to the templates provided by the Django Bread package. 44 | 45 | App-specific location 46 | ^^^^^^^^^^^^^^^^^^^^^ 47 | 48 | The app-specific location is described by this pattern: ``{app_label}/{model}_{view}.html``. 49 | For example, if your app's name is MyApp and your model's name is MyModel, Django Bread will look 50 | for a browse template in ``myapp/mymodel_browse.html``. 51 | 52 | Customization 53 | ^^^^^^^^^^^^^ 54 | 55 | There are 3 ways that you can customize this behavior even further: 56 | 57 | 1. Specify ``template_name_pattern`` in your Bread object. All views in that Bread object will then 58 | search in the app-specific location first (described above), then search 59 | ``template_name_pattern``, and then fall back to the Django Bread provided templates. The value 60 | supplied to ``template_name_pattern`` is a string that can take zero or more placeholders with 61 | the names ``app_label``, ``model``, or ``view``. An example of a valid pattern would be 62 | ``'{app_label}/special_{model}_{view}.html'``. Note that you can use this technique to implement 63 | site-wide customization by creating a subclass of Bread with ``template_name_pattern`` set, and 64 | then use that subclass (or children of it) throughout your site. 65 | 66 | 2. Set the ``template_name`` attribute in your View to the exact template that you want for that 67 | view. Example: 68 | 69 | .. code-block:: python 70 | 71 | class MyBrowseView(BrowseView): 72 | template_name = 'my/special-location.html' 73 | 74 | 75 | 3. Override ``get_template_names`` in a specific action View. This method should return a tuple of 76 | strings representing template locations to search. In most cases, using #3 is a simpler way to 77 | achieve this. But if you have a situation where you need a list of templates to be searched then 78 | here is an example on how to do that: 79 | 80 | .. code-block:: python 81 | 82 | class MyBrowseView(BrowseView): 83 | def get_template_names(self): 84 | return ('my/special-location.html', ) 85 | 86 | In addition to these template resolution rules, it's important to remember Django's own default 87 | rules. If you have a template with the same name in 2 different applications, then whichever app is 88 | listed first in INSTALLED_APPS wins. So, another way to get site-wide customization without using #2 89 | above is to create templates named `bread/{activity}.html` in one of your local apps and make sure 90 | it's listed before ``bread``. 91 | 92 | Caveat 93 | ^^^^^^ 94 | 95 | We mentioned above that each View is matched to a template with the same name (BrowseView -> 96 | '...browse.html'). This is true for everything except the AddView. Because add and edit templates 97 | are so similar, the AddView connects to the 'edit.html' template. There is no 'add.html' template. 98 | If you need your AddView to have its own template, there are 2 ways you can accomplish this. Either 99 | use methods #3 or #4 above, or set ``template_name_suffix`` in your AddView class. It defaults to 100 | ``_edit``, but if you change it to ``_add`` then your AddView will be linked to a 101 | ``{app_label}/{model}_add.html`` template instead. 102 | -------------------------------------------------------------------------------- /docs/urls.rst: -------------------------------------------------------------------------------- 1 | .. _urls: 2 | 3 | URLs 4 | ==== 5 | 6 | Calling `.get_urls()` on a Bread instance returns a urlpatterns 7 | list intended to be included in a URLconf. 8 | 9 | Example usages:: 10 | 11 | urlpatterns += MyBread().get_urls() 12 | 13 | or:: 14 | 15 | urlpatterns = ( 16 | ..., 17 | path('', include(MyBread().get_urls()), 18 | ... 19 | ) 20 | 21 | By default, the patterns returned will be of the form:: 22 | 23 | Operation Name URL 24 | --------- -------------------- -------------------------- 25 | Browse browse_ / 26 | Read read_ // 27 | Edit edit_ //edit/ 28 | Add add_ /add/ 29 | Delete delete_ //delete/ 30 | 31 | `name` is the lowercased name of the model. 32 | 33 | `plural_name` is `name` with an `s` appended, but can be overridden by 34 | setting `plural_name` on the Bread view. 35 | 36 | If a restricted set of views is passed in the 'views' parameter, then 37 | only URLs for those views will be included. 38 | 39 | So, if your bread class looked like:: 40 | 41 | 42 | class MyBread(Bread): 43 | model = BasicThingy 44 | plural_name = 'basicthingies' 45 | 46 | Then your URLs returned by `.get_urls()` would look like:: 47 | 48 | Operation Name URL 49 | --------- -------------------- -------------------------- 50 | Browse browse_basicthingies basicthingies/ 51 | Read read_basicthingy basicthingies// 52 | Edit edit_basicthingy basicthingies//edit/ 53 | Add add_basicthingy basicthingies/add/ 54 | Delete delete_basicthingy basicthingies//delete/ 55 | 56 | If for some reason you didn't want your URLs to all start with ``/``, 57 | then you can pass ``prefix=False`` to ``.get_urls()`` and you'll get back 58 | "bare" URLS:: 59 | 60 | Operation Name URL 61 | --------- -------------------- -------------------------- 62 | Browse browse_basicthingies 63 | Read read_basicthingy / 64 | Edit edit_basicthingy /edit/ 65 | Add add_basicthingy add/ 66 | Delete delete_basicthingy /delete/ 67 | 68 | Then you'd want to include them into your URLconf with some prefix of your own 69 | choosing, e.g.:: 70 | 71 | urlpatterns = ( 72 | .... 73 | path('things/', include(MyBread().get_urls(prefix=False)), 74 | ... 75 | ) 76 | -------------------------------------------------------------------------------- /maintain.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ex 3 | 4 | pip install -Ur dev-requirements.txt 5 | pre-commit install 6 | pre-commit run -a 7 | tox 8 | coverage run runtests.py && coverage report 9 | sphinx-build docs docs/_build/html 10 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import sys 4 | 5 | from django import setup 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | if not settings.configured: 10 | settings.configure( 11 | DATABASES={ 12 | "default": { 13 | "ENGINE": "django.db.backends.sqlite3", 14 | "NAME": ":memory:", 15 | } 16 | }, 17 | MIDDLEWARE_CLASSES=(), 18 | INSTALLED_APPS=( 19 | "bread", 20 | "tests", 21 | "django.contrib.auth", 22 | "django.contrib.contenttypes", 23 | "django.contrib.sessions", 24 | ), 25 | SITE_ID=1, 26 | SECRET_KEY="super-secret", 27 | TEMPLATES=[ 28 | { 29 | "BACKEND": "django.template.backends.django.DjangoTemplates", 30 | "DIRS": ["bread/templates"], 31 | } 32 | ], 33 | DEFAULT_AUTO_FIELD="django.db.models.AutoField", 34 | ) 35 | 36 | 37 | def runtests(): 38 | logger = logging.getLogger() 39 | logger.setLevel(logging.INFO) 40 | logger.addHandler(logging.StreamHandler()) 41 | 42 | setup() 43 | TestRunner = get_runner(settings) 44 | test_runner = TestRunner(verbosity=1, interactive=True, failfast=False) 45 | args = sys.argv[1:] or [] 46 | failures = test_runner.run_tests(args) 47 | sys.exit(failures) 48 | 49 | 50 | if __name__ == "__main__": 51 | runtests() 52 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .tox 3 | max-line-length = 120 4 | 5 | [bdist_wheel] 6 | universal = 1 7 | 8 | [isort] 9 | profile = black 10 | multi_line_output = 3 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | from bread import __version__ 4 | 5 | setup( 6 | name="django_bread", 7 | version=__version__, 8 | packages=find_packages(), 9 | url="https://github.com/caktus/django_bread", 10 | license="APL2", 11 | author="Dan Poirier", 12 | author_email="dpoirier@caktusgroup.com", 13 | description="Helper for building BREAD interfaces", 14 | include_package_data=True, 15 | install_requires=[ 16 | "django-filter", 17 | "django-vanilla-views", 18 | ], 19 | long_description=open("README.rst").read(), 20 | classifiers=[ 21 | "Development Status :: 5 - Production/Stable", 22 | "Environment :: Web Environment", 23 | "Framework :: Django", 24 | "Intended Audience :: Developers", 25 | "License :: OSI Approved :: Apache Software License", 26 | "Operating System :: OS Independent", 27 | "Programming Language :: Python :: 3", 28 | "Programming Language :: Python :: 3.9", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Programming Language :: Python :: 3.12", 32 | "Topic :: Software Development :: Libraries :: Python Modules", 33 | "Framework :: Django :: 4.2", 34 | "Framework :: Django :: 5.0", 35 | "Framework :: Django :: 5.1", 36 | "Framework :: Django :: 5.2", 37 | ], 38 | zip_safe=False, # because we're including media that Django needs 39 | ) 40 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caktus/django_bread/46939a78385a3e310f9d122165b42bc097c64d97/tests/__init__.py -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.models import Permission 3 | from django.contrib.contenttypes.models import ContentType 4 | from django.test import RequestFactory, TestCase, override_settings 5 | 6 | from bread.bread import Bread, BrowseView, ReadView 7 | 8 | from .factories import BreadTestModelFactory 9 | from .models import BreadTestModel 10 | 11 | # Set urlpatterns for a test by calling .set_urls() 12 | urlpatterns = None 13 | 14 | 15 | @override_settings( 16 | ROOT_URLCONF="tests.base", 17 | BREAD={ 18 | "DEFAULT_BASE_TEMPLATE": "bread/empty.html", 19 | }, 20 | ) 21 | class BreadTestCase(TestCase): 22 | url_namespace = "" 23 | extra_bread_attributes = {} 24 | 25 | def setUp(self): 26 | self.username = "joe" 27 | self.password = "random" 28 | User = get_user_model() 29 | self.user = User.objects.create_user(username=self.username) 30 | self.user.set_password(self.password) 31 | self.user.save() 32 | assert self.client.login(username=self.username, password=self.password) 33 | self.model = BreadTestModel 34 | self.model_name = self.model._meta.model_name 35 | self.model_factory = BreadTestModelFactory 36 | self.request_factory = RequestFactory() 37 | 38 | class ReadClass(ReadView): 39 | columns = [ 40 | ("Name", "name"), 41 | ("Text", "other__text"), 42 | ( 43 | "Model1", 44 | "model1", 45 | ), 46 | ] 47 | 48 | class BrowseClass(BrowseView): 49 | columns = [ 50 | ("Name", "name"), 51 | ("Text", "other__text"), 52 | ("Model1", "model1"), 53 | ("Roundabout Name", "get_name"), 54 | ] 55 | 56 | class BreadTestClass(Bread): 57 | model = self.model 58 | base_template = "bread/empty.html" 59 | browse_view = BrowseClass 60 | namespace = self.url_namespace 61 | plural_name = "testmodels" 62 | 63 | def get_additional_context_data(self): 64 | context = super().get_additional_context_data() 65 | context["bread_test_class"] = True 66 | return context 67 | 68 | for k, v in self.extra_bread_attributes.items(): 69 | setattr(BreadTestClass, k, v) 70 | 71 | self.BreadTestClass = BreadTestClass 72 | self.bread = BreadTestClass() 73 | 74 | def tearDown(self): 75 | global urlpatterns 76 | urlpatterns = None 77 | 78 | def set_urls(self, bread): 79 | # Given a bread instance, set its URLs on the test urlconf 80 | global urlpatterns 81 | urlpatterns = bread.get_urls() 82 | 83 | def get_permission(self, short_name): 84 | """Return a Permission object for the test model. 85 | short_name should be browse, read, edit, add, or delete. 86 | """ 87 | return Permission.objects.get_or_create( 88 | content_type=ContentType.objects.get_for_model(self.model), 89 | codename=f"{short_name}_{self.model_name}", 90 | )[0] 91 | 92 | def give_permission(self, short_name): 93 | self.user.user_permissions.add(self.get_permission(short_name)) 94 | -------------------------------------------------------------------------------- /tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from .models import BreadLabelValueTestModel, BreadTestModel, BreadTestModel2 4 | 5 | 6 | class BreadTestModel2Factory(factory.django.DjangoModelFactory): 7 | class Meta: 8 | model = BreadTestModel2 9 | 10 | text = factory.Faker("text", max_nb_chars=10) 11 | 12 | 13 | class BreadTestModelFactory(factory.django.DjangoModelFactory): 14 | class Meta: 15 | model = BreadTestModel 16 | 17 | name = factory.Faker("name") 18 | age = factory.Faker("pyint", min_value=0, max_value=99) 19 | other = factory.SubFactory(BreadTestModel2Factory) 20 | 21 | 22 | class BreadLabelValueTestModelFactory(factory.django.DjangoModelFactory): 23 | class Meta: 24 | model = BreadLabelValueTestModel 25 | 26 | name = factory.Faker("name") 27 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | We really need models to test BREAD with, but Django's support for 3 | defining test-only models is (still) broken. See 4 | https://code.djangoproject.com/ticket/7835 and scroll down to the 5 | end (it's been open over 6 years). 6 | 7 | So these models are here for the tests to use, but nothing else. 8 | If Django ever gets this sorted out, we can move these models 9 | to the tests, and maybe get fancier with different test models 10 | for different tests. 11 | """ 12 | from django.db import models 13 | 14 | 15 | class BreadLabelValueTestModel(models.Model): 16 | """Model for testing LabelValueReadView, also for GetVerboseNameTest""" 17 | 18 | name = models.CharField(max_length=10) 19 | banana = models.IntegerField(verbose_name="a yellow fruit", default=0) 20 | 21 | def name_reversed(self): 22 | return self.name[::-1] 23 | 24 | 25 | class BreadTestModel2(models.Model): 26 | text = models.CharField(max_length=20) 27 | label_model = models.OneToOneField( 28 | BreadLabelValueTestModel, 29 | null=True, 30 | related_name="model2", 31 | on_delete=models.CASCADE, 32 | ) 33 | model1 = models.OneToOneField( 34 | "BreadTestModel", 35 | null=True, 36 | related_name="model1", 37 | on_delete=models.CASCADE, 38 | ) 39 | 40 | def get_text(self): 41 | return self.text 42 | 43 | 44 | class BreadTestModel(models.Model): 45 | name = models.CharField(max_length=10) 46 | age = models.IntegerField() 47 | other = models.ForeignKey( 48 | BreadTestModel2, 49 | blank=True, 50 | null=True, 51 | on_delete=models.CASCADE, 52 | ) 53 | 54 | class Meta: 55 | ordering = [ 56 | "name", 57 | "-age", # If same name, sort oldest first 58 | ] 59 | permissions = [ 60 | ("browse_breadtestmodel", "can browse BreadTestModel"), 61 | ] 62 | 63 | def __str__(self): 64 | return self.name 65 | 66 | def get_name(self): 67 | return self.name 68 | 69 | def method1(self, arg): 70 | # Method that has a required arg 71 | pass 72 | 73 | def method2(self, arg=None): 74 | # method that has an optional arg 75 | pass 76 | -------------------------------------------------------------------------------- /tests/test_add.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.urls import reverse 3 | 4 | from bread.bread import AddView, Bread 5 | 6 | from .base import BreadTestCase 7 | from .models import BreadTestModel 8 | 9 | 10 | class BreadAddTest(BreadTestCase): 11 | def setUp(self): 12 | super().setUp() 13 | self.set_urls(self.bread) 14 | 15 | def test_new_item(self): 16 | self.model.objects.all().delete() 17 | url = reverse(self.bread.get_url_name("add")) 18 | request = self.request_factory.post( 19 | url, data={"name": "Fred Jones", "age": "19"} 20 | ) 21 | request.user = self.user 22 | self.give_permission("add") 23 | view = self.bread.get_add_view() 24 | rsp = view(request) 25 | self.assertEqual(302, rsp.status_code) 26 | self.assertEqual(reverse(self.bread.get_url_name("browse")), rsp["Location"]) 27 | item = self.model.objects.get() 28 | self.assertEqual("Fred Jones", item.name) 29 | 30 | def test_fail_validation(self): 31 | self.model.objects.all().delete() 32 | url = reverse(self.bread.get_url_name("add")) 33 | request = self.request_factory.post( 34 | url, data={"name": "this name is too much long yeah", "age": "19"} 35 | ) 36 | request.user = self.user 37 | self.give_permission("add") 38 | view = self.bread.get_add_view() 39 | rsp = view(request) 40 | self.assertEqual(400, rsp.status_code) 41 | context = rsp.context_data 42 | self.assertTrue(context["bread_test_class"]) 43 | form = context["form"] 44 | errors = form.errors 45 | self.assertIn("name", errors) 46 | 47 | def test_get(self): 48 | # Get should give you a blank form 49 | url = reverse(self.bread.get_url_name("add")) 50 | request = self.request_factory.get(url) 51 | request.user = self.user 52 | self.give_permission("add") 53 | view = self.bread.get_add_view() 54 | rsp = view(request) 55 | self.assertEqual(200, rsp.status_code) 56 | form = rsp.context_data["form"] 57 | self.assertFalse(form.is_bound) 58 | rsp.render() 59 | body = rsp.content.decode("utf-8") 60 | self.assertIn('method="POST"', body) 61 | 62 | def test_setting_form_class(self): 63 | class DummyForm(forms.Form): 64 | pass 65 | 66 | glob = {} 67 | 68 | class TestAddView(AddView): 69 | form_class = DummyForm 70 | 71 | # To get hold of a reference to the actual view object created by 72 | # bread, use a fake dispatch method that saves 'self' into a 73 | # dictionary we can access in the test. 74 | def dispatch(self, *args, **kwargs): 75 | glob["view_object"] = self 76 | 77 | class BreadTest(Bread): 78 | model = BreadTestModel 79 | add_view = TestAddView 80 | 81 | bread = BreadTest() 82 | view_function = bread.get_add_view() 83 | # Call the view function to invoke dispatch so we can get to the view itself 84 | view_function(None, None, None) 85 | self.assertEqual(DummyForm, glob["view_object"].form_class) 86 | -------------------------------------------------------------------------------- /tests/test_browse.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import patch 3 | 4 | from django.db.models.functions import Upper 5 | from django.urls import reverse 6 | 7 | from bread.bread import BrowseView 8 | from tests.models import BreadTestModel 9 | 10 | from .base import BreadTestCase 11 | from .factories import BreadTestModelFactory 12 | 13 | 14 | class BreadBrowseTest(BreadTestCase): 15 | @patch("bread.templatetags.bread_tags.logger") 16 | def test_get(self, mock_logger): 17 | self.set_urls(self.bread) 18 | items = [BreadTestModelFactory() for __ in range(5)] 19 | self.give_permission("browse") 20 | url = reverse(self.bread.get_url_name("browse")) 21 | request = self.request_factory.get(url) 22 | request.user = self.user 23 | rsp = self.bread.get_browse_view()(request) 24 | self.assertEqual(200, rsp.status_code) 25 | rsp.render() 26 | self.assertTrue(rsp.context_data["bread_test_class"]) 27 | body = rsp.content.decode("utf-8") 28 | for item in items: 29 | self.assertIn(item.name, body) 30 | # No exceptions logged 31 | self.assertFalse(mock_logger.exception.called) 32 | 33 | def test_get_empty_list(self): 34 | self.set_urls(self.bread) 35 | self.model.objects.all().delete() 36 | self.give_permission("browse") 37 | url = reverse(self.bread.get_url_name("browse")) 38 | request = self.request_factory.get(url) 39 | request.user = self.user 40 | rsp = self.bread.get_browse_view()(request) 41 | self.assertEqual(200, rsp.status_code) 42 | 43 | def test_post(self): 44 | self.set_urls(self.bread) 45 | self.give_permission("browse") 46 | url = reverse(self.bread.get_url_name("browse")) 47 | request = self.request_factory.post(url) 48 | request.user = self.user 49 | rsp = self.bread.get_browse_view()(request) 50 | self.assertEqual(405, rsp.status_code) 51 | 52 | @patch("bread.templatetags.bread_tags.logger") 53 | def test_sort_all_ascending(self, mock_logger): 54 | self.set_urls(self.bread) 55 | BreadTestModelFactory(name="999", other__text="012", age=50) 56 | BreadTestModelFactory(name="555", other__text="333", age=60) 57 | BreadTestModelFactory(name="111", other__text="555", age=10) 58 | BreadTestModelFactory(name="111", other__text="555", age=20) 59 | BreadTestModelFactory(name="111", other__text="555", age=5) 60 | self.give_permission("browse") 61 | url = reverse(self.bread.get_url_name("browse")) + "?o=0,1" 62 | request = self.request_factory.get(url) 63 | request.user = self.user 64 | rsp = self.bread.get_browse_view()(request) 65 | self.assertEqual(200, rsp.status_code) 66 | rsp.render() 67 | results = rsp.context_data["object_list"] 68 | i = 0 69 | while i < len(results) - 1: 70 | sortA = (results[i].name, results[i].other.text) 71 | sortB = (results[i + 1].name, results[i + 1].other.text) 72 | self.assertLessEqual(sortA, sortB) 73 | if sortA == sortB: 74 | # default sort is '-age' 75 | self.assertGreaterEqual(results[i].age, results[i + 1].age) 76 | i += 1 77 | # No exceptions logged 78 | self.assertFalse(mock_logger.exception.called) 79 | 80 | @patch("bread.templatetags.bread_tags.logger") 81 | def test_sort_most_ascending_with_override_default_order(self, mock_logger): 82 | self.set_urls(self.bread) 83 | self.bread.browse_view.default_ordering = ["-other__text", "age"] 84 | BreadTestModelFactory(name="999", other__text="012", age=50) 85 | BreadTestModelFactory(name="555", other__text="333", age=60) 86 | BreadTestModelFactory(name="111", other__text="555", age=10) 87 | BreadTestModelFactory(name="111", other__text="555", age=20) 88 | BreadTestModelFactory(name="111", other__text="555", age=5) 89 | self.give_permission("browse") 90 | url = reverse(self.bread.get_url_name("browse")) + "?o=0,1" 91 | request = self.request_factory.get(url) 92 | request.user = self.user 93 | rsp = self.bread.get_browse_view()(request) 94 | self.assertEqual(200, rsp.status_code) 95 | rsp.render() 96 | results = rsp.context_data["object_list"] 97 | 98 | i = 0 99 | while i < len(results) - 1: 100 | sortA = (results[i].name, results[i].other.text) 101 | sortB = (results[i + 1].name, results[i + 1].other.text) 102 | self.assertLessEqual(sortA, sortB) 103 | if sortA == sortB: 104 | # default sort is 'age' 105 | self.assertLessEqual(results[i].age, results[i + 1].age) 106 | i += 1 107 | # No exceptions logged 108 | self.assertFalse(mock_logger.exception.called) 109 | 110 | @patch("bread.templatetags.bread_tags.logger") 111 | def test_sort_all_descending(self, mock_logger): 112 | self.set_urls(self.bread) 113 | BreadTestModelFactory(name="999", other__text="012", age=50) 114 | BreadTestModelFactory(name="555", other__text="333", age=60) 115 | BreadTestModelFactory(name="111", other__text="555", age=10) 116 | BreadTestModelFactory(name="111", other__text="555", age=20) 117 | BreadTestModelFactory(name="111", other__text="555", age=5) 118 | self.give_permission("browse") 119 | url = reverse(self.bread.get_url_name("browse")) + "?o=-0,-1" 120 | request = self.request_factory.get(url) 121 | request.user = self.user 122 | rsp = self.bread.get_browse_view()(request) 123 | self.assertEqual(200, rsp.status_code) 124 | rsp.render() 125 | results = rsp.context_data["object_list"] 126 | i = 0 127 | while i < len(results) - 1: 128 | sortA = (results[i].name, results[i].other.text) 129 | sortB = (results[i + 1].name, results[i + 1].other.text) 130 | self.assertGreaterEqual(sortA, sortB) 131 | if sortA == sortB: 132 | # default sort is '-age' 133 | self.assertGreaterEqual(results[i].age, results[i + 1].age) 134 | i += 1 135 | # No exceptions logged 136 | self.assertFalse(mock_logger.exception.called) 137 | 138 | def test_sort_first_ascending(self): 139 | self.set_urls(self.bread) 140 | BreadTestModelFactory(name="999", other__text="012") 141 | BreadTestModelFactory(name="555", other__text="333") 142 | BreadTestModelFactory(name="111", other__text="555") 143 | self.give_permission("browse") 144 | url = reverse(self.bread.get_url_name("browse")) + "?o=0" 145 | request = self.request_factory.get(url) 146 | request.user = self.user 147 | rsp = self.bread.get_browse_view()(request) 148 | self.assertEqual(200, rsp.status_code) 149 | rsp.render() 150 | results = rsp.context_data["object_list"] 151 | i = 0 152 | while i < len(results) - 1: 153 | sortA = (results[i].name, results[i].other.text) 154 | sortB = (results[i + 1].name, results[i + 1].other.text) 155 | self.assertLessEqual(sortA, sortB) 156 | i += 1 157 | 158 | def test_sort_first_ascending_second_descending(self): 159 | self.set_urls(self.bread) 160 | e = BreadTestModelFactory(name="999", other__text="012") 161 | d = BreadTestModelFactory(name="999", other__text="212") 162 | c = BreadTestModelFactory(name="999", other__text="312") 163 | a = BreadTestModelFactory(name="111", other__text="555") 164 | b = BreadTestModelFactory(name="555", other__text="333") 165 | self.give_permission("browse") 166 | url = reverse(self.bread.get_url_name("browse")) + "?o=0,-1" 167 | request = self.request_factory.get(url) 168 | request.user = self.user 169 | rsp = self.bread.get_browse_view()(request) 170 | self.assertEqual(200, rsp.status_code) 171 | rsp.render() 172 | results = rsp.context_data["object_list"] 173 | self.assertEqual(a, results[0]) 174 | self.assertEqual(b, results[1]) 175 | self.assertEqual(c, results[2]) 176 | self.assertEqual(d, results[3]) 177 | self.assertEqual(e, results[4]) 178 | 179 | def test_sort_first_descending_second_ascending(self): 180 | self.set_urls(self.bread) 181 | a = BreadTestModelFactory(name="999", other__text="012") 182 | b = BreadTestModelFactory(name="999", other__text="212") 183 | c = BreadTestModelFactory(name="999", other__text="312") 184 | e = BreadTestModelFactory(name="111", other__text="555") 185 | d = BreadTestModelFactory(name="555", other__text="333") 186 | self.give_permission("browse") 187 | url = reverse(self.bread.get_url_name("browse")) + "?o=-0,1" 188 | request = self.request_factory.get(url) 189 | request.user = self.user 190 | rsp = self.bread.get_browse_view()(request) 191 | self.assertEqual(200, rsp.status_code) 192 | rsp.render() 193 | results = rsp.context_data["object_list"] 194 | self.assertEqual(a, results[0]) 195 | self.assertEqual(b, results[1]) 196 | self.assertEqual(c, results[2]) 197 | self.assertEqual(d, results[3]) 198 | self.assertEqual(e, results[4]) 199 | 200 | def test_sort_second_field_ascending(self): 201 | self.set_urls(self.bread) 202 | d = BreadTestModelFactory(name="555", other__text="333") 203 | a = BreadTestModelFactory(name="999", other__text="012") 204 | c = BreadTestModelFactory(name="999", other__text="312") 205 | b = BreadTestModelFactory(name="999", other__text="212") 206 | e = BreadTestModelFactory(name="111", other__text="555") 207 | self.give_permission("browse") 208 | url = reverse(self.bread.get_url_name("browse")) + "?o=1" 209 | request = self.request_factory.get(url) 210 | request.user = self.user 211 | rsp = self.bread.get_browse_view()(request) 212 | self.assertEqual(200, rsp.status_code) 213 | rsp.render() 214 | results = rsp.context_data["object_list"] 215 | self.assertEqual(a, results[0]) 216 | self.assertEqual(b, results[1]) 217 | self.assertEqual(c, results[2]) 218 | self.assertEqual(d, results[3]) 219 | self.assertEqual(e, results[4]) 220 | 221 | @patch("bread.templatetags.bread_tags.logger") 222 | def test_sort_second_field_ascending_first_descending(self, mock_logger): 223 | self.set_urls(self.bread) 224 | d = BreadTestModelFactory(name="1", other__text="111") 225 | a = BreadTestModelFactory(name="999", other__text="000") 226 | e = BreadTestModelFactory(name="111", other__text="555") 227 | b = BreadTestModelFactory(name="3", other__text="111") 228 | c = BreadTestModelFactory(name="2", other__text="111") 229 | self.give_permission("browse") 230 | url = reverse(self.bread.get_url_name("browse")) + "?o=1,-0" 231 | request = self.request_factory.get(url) 232 | request.user = self.user 233 | rsp = self.bread.get_browse_view()(request) 234 | self.assertEqual(200, rsp.status_code) 235 | rsp.render() 236 | results = rsp.context_data["object_list"] 237 | self.assertEqual(a, results[0]) 238 | self.assertEqual(b, results[1]) 239 | self.assertEqual(c, results[2]) 240 | self.assertEqual(d, results[3]) 241 | self.assertEqual(e, results[4]) 242 | # No exceptions logged 243 | self.assertFalse(mock_logger.exception.called) 244 | 245 | 246 | class BadSortTest(BreadTestCase): 247 | class BrowseClass(BrowseView): 248 | columns = [("Name", "name"), ("Text", "other__get_text")] 249 | 250 | extra_bread_attributes = { 251 | "browse_view": BrowseClass, 252 | } 253 | 254 | def test_unorderable_column(self): 255 | self.set_urls(self.bread) 256 | BreadTestModelFactory(name="1", other__text="111") 257 | self.give_permission("browse") 258 | url = reverse(self.bread.get_url_name("browse")) + "?o=1,-0" 259 | request = self.request_factory.get(url) 260 | request.user = self.user 261 | rsp = self.bread.get_browse_view()(request) 262 | self.assertEqual(400, rsp.status_code) 263 | 264 | def test_html_injection(self): 265 | """Bad user queryparam input should be escaped in error messages.""" 266 | self.set_urls(self.bread) 267 | BreadTestModelFactory(name="1", other__text="111") 268 | self.give_permission("browse") 269 | url = reverse(self.bread.get_url_name("browse")) + "?o=

hello

" 270 | request = self.request_factory.get(url) 271 | request.user = self.user 272 | rsp = self.bread.get_browse_view()(request) 273 | self.assertNotContains(rsp, "

hello

", status_code=400, html=True) 274 | 275 | 276 | class NotDisablingSortTest(BreadTestCase): 277 | class BrowseClass(BrowseView): 278 | columns = [ 279 | ("Name", "name"), 280 | ] 281 | 282 | extra_bread_attributes = { 283 | "browse_view": BrowseClass, 284 | } 285 | 286 | def test_sorting_on_column(self): 287 | # 'name' is a valid column to sort on 288 | # (we test this because otherwise the DisableSortTest test isn't valid) 289 | self.set_urls(self.bread) 290 | self.give_permission("browse") 291 | url = reverse(self.bread.get_url_name("browse")) 292 | request = self.request_factory.get(url) 293 | request.user = self.user 294 | rsp = self.bread.get_browse_view()(request) 295 | self.assertEqual(200, rsp.status_code) 296 | rsp.render() 297 | self.assertEqual( 298 | [0], json.loads(rsp.context_data["valid_sorting_columns_json"]) 299 | ) 300 | 301 | 302 | class DisableSortTest(BreadTestCase): 303 | class BrowseClass(BrowseView): 304 | columns = [ 305 | ("Name", "name", False), 306 | ] 307 | 308 | extra_bread_attributes = { 309 | "browse_view": BrowseClass, 310 | } 311 | 312 | def test_not_sorting_on_column(self): 313 | self.set_urls(self.bread) 314 | self.give_permission("browse") 315 | url = reverse(self.bread.get_url_name("browse")) 316 | request = self.request_factory.get(url) 317 | request.user = self.user 318 | rsp = self.bread.get_browse_view()(request) 319 | self.assertEqual(200, rsp.status_code) 320 | rsp.render() 321 | self.assertEqual([], json.loads(rsp.context_data["valid_sorting_columns_json"])) 322 | 323 | 324 | class AnnotationsBrowseTest(BreadTestCase): 325 | class BrowseClass(BrowseView): 326 | queryset = BreadTestModel.objects.annotate(loud_name=Upper("name")) 327 | columns = [ 328 | ("Name", "name"), 329 | ("Loud Name", "loud_name", BrowseView.is_annotation, "loud_name"), 330 | ] 331 | 332 | extra_bread_attributes = { 333 | "browse_view": BrowseClass, 334 | } 335 | 336 | def test_rendering_annotation(self): 337 | self.set_urls(self.bread) 338 | items = [BreadTestModelFactory() for __ in range(5)] 339 | self.assertTrue( 340 | any(item for item in items if item.name != item.name.upper()), 341 | "all item names already uppercase, this test will not be meaningful", 342 | ) 343 | self.give_permission("browse") 344 | 345 | url = reverse(self.bread.get_url_name("browse")) 346 | request = self.request_factory.get(url) 347 | request.user = self.user 348 | rsp = self.bread.get_browse_view()(request) 349 | self.assertEqual(200, rsp.status_code) 350 | 351 | rsp.render() 352 | body = rsp.content.decode("utf-8") 353 | for item in items: 354 | self.assertIn(item.name, body) 355 | self.assertIn(item.name.upper(), body) 356 | 357 | def test_sort_by_annotation(self): 358 | self.set_urls(self.bread) 359 | self.give_permission("browse") 360 | d = BreadTestModelFactory(name="denise") 361 | a = BreadTestModelFactory(name="alice") 362 | e = BreadTestModelFactory(name="elise") 363 | b = BreadTestModelFactory(name="bernice") 364 | c = BreadTestModelFactory(name="clarice") 365 | 366 | url = reverse(self.bread.get_url_name("browse")) + "?o=1" 367 | request = self.request_factory.get(url) 368 | request.user = self.user 369 | rsp = self.bread.get_browse_view()(request) 370 | self.assertEqual(200, rsp.status_code) 371 | 372 | rsp.render() 373 | self.assertListEqual( 374 | [0, 1], 375 | json.loads(rsp.context_data["valid_sorting_columns_json"]), 376 | "bread did not consider our annotation column sortable", 377 | ) 378 | results = rsp.context_data["object_list"] 379 | self.assertListEqual([a, b, c, d, e], list(results), "results were not sorted") 380 | -------------------------------------------------------------------------------- /tests/test_delete.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404 2 | from django.urls import reverse 3 | 4 | from .base import BreadTestCase 5 | 6 | 7 | class BreadDeleteTest(BreadTestCase): 8 | def setUp(self): 9 | super().setUp() 10 | self.set_urls(self.bread) 11 | 12 | def test_delete_item(self): 13 | self.item = self.model_factory() 14 | url = reverse(self.bread.get_url_name("delete"), kwargs={"pk": self.item.pk}) 15 | self.give_permission("delete") 16 | 17 | # Get should work and give us a confirmation page 18 | request = self.request_factory.get(url) 19 | request.user = self.user 20 | view = self.bread.get_delete_view() 21 | rsp = view(request, pk=self.item.pk) 22 | self.assertTrue(rsp.context_data["bread_test_class"]) 23 | self.assertEqual(200, rsp.status_code) 24 | self.assertTrue(self.model.objects.filter(pk=self.item.pk).exists()) 25 | 26 | # Now post to confirm 27 | request = self.request_factory.post(url) 28 | request.user = self.user 29 | view = self.bread.get_delete_view() 30 | rsp = view(request, pk=self.item.pk) 31 | self.assertEqual(302, rsp.status_code) 32 | self.assertEqual(reverse(self.bread.get_url_name("browse")), rsp["Location"]) 33 | self.assertFalse(self.model.objects.filter(pk=self.item.pk).exists()) 34 | 35 | def test_delete_nonexistent_item(self): 36 | url = reverse(self.bread.get_url_name("delete"), kwargs={"pk": 999}) 37 | self.give_permission("delete") 38 | 39 | # Get should not work - 404 40 | request = self.request_factory.get(url) 41 | request.user = self.user 42 | view = self.bread.get_delete_view() 43 | with self.assertRaises(Http404): 44 | view(request, pk=999) 45 | 46 | # Same for post 47 | request = self.request_factory.post(url) 48 | request.user = self.user 49 | view = self.bread.get_delete_view() 50 | with self.assertRaises(Http404): 51 | view(request, pk=999) 52 | -------------------------------------------------------------------------------- /tests/test_edit.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.urls import reverse 3 | 4 | from bread.bread import Bread, EditView 5 | 6 | from .base import BreadTestCase 7 | from .models import BreadTestModel 8 | 9 | 10 | class BreadEditTest(BreadTestCase): 11 | def setUp(self): 12 | super().setUp() 13 | self.set_urls(self.bread) 14 | 15 | def test_edit_item(self): 16 | item = self.model_factory() 17 | url = reverse(self.bread.get_url_name("edit"), kwargs={"pk": item.pk}) 18 | request = self.request_factory.post( 19 | url, data={"name": "Fred Jones", "age": "19"} 20 | ) 21 | request.user = self.user 22 | self.give_permission("change") 23 | view = self.bread.get_edit_view() 24 | rsp = view(request, pk=item.pk) 25 | self.assertEqual(302, rsp.status_code) 26 | self.assertEqual(reverse(self.bread.get_url_name("browse")), rsp["Location"]) 27 | item = self.model.objects.get(pk=item.pk) 28 | self.assertEqual("Fred Jones", item.name) 29 | 30 | def test_fail_validation(self): 31 | item = self.model_factory() 32 | url = reverse(self.bread.get_url_name("edit"), kwargs={"pk": item.pk}) 33 | request = self.request_factory.post( 34 | url, data={"name": "this name is too much long yeah", "age": "19"} 35 | ) 36 | request.user = self.user 37 | self.give_permission("change") 38 | view = self.bread.get_edit_view() 39 | rsp = view(request, pk=item.pk) 40 | self.assertEqual(400, rsp.status_code) 41 | self.assertTrue(rsp.context_data["bread_test_class"]) 42 | context = rsp.context_data 43 | form = context["form"] 44 | errors = form.errors 45 | self.assertIn("name", errors) 46 | 47 | def test_get(self): 48 | # Get should give you a form with the item filled in 49 | item = self.model_factory() 50 | url = reverse(self.bread.get_url_name("edit"), kwargs={"pk": item.pk}) 51 | request = self.request_factory.get(url) 52 | request.user = self.user 53 | self.give_permission("change") 54 | view = self.bread.get_edit_view() 55 | rsp = view(request, pk=item.pk) 56 | self.assertEqual(200, rsp.status_code) 57 | form = rsp.context_data["form"] 58 | self.assertFalse(form.is_bound) 59 | self.assertEqual(item.pk, form.initial["id"]) 60 | self.assertEqual(item.name, form.initial["name"]) 61 | rsp.render() 62 | body = rsp.content.decode("utf-8") 63 | self.assertIn('method="POST"', body) 64 | 65 | def test_setting_form_class(self): 66 | class DummyForm(forms.Form): 67 | pass 68 | 69 | glob = {} 70 | 71 | class TestView(EditView): 72 | form_class = DummyForm 73 | 74 | # To get hold of a reference to the actual view object created by 75 | # bread, use a fake dispatch method that saves 'self' into a 76 | # dictionary we can access in the test. 77 | def dispatch(self, *args, **kwargs): 78 | glob["view_object"] = self 79 | 80 | class BreadTest(Bread): 81 | model = BreadTestModel 82 | edit_view = TestView 83 | 84 | bread = BreadTest() 85 | view_function = bread.get_edit_view() 86 | # Call the view function to invoke dispatch so we can get to the view itself 87 | view_function(None, None, None) 88 | self.assertEqual(DummyForm, glob["view_object"].form_class) 89 | -------------------------------------------------------------------------------- /tests/test_filters.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caktus/django_bread/46939a78385a3e310f9d122165b42bc097c64d97/tests/test_filters.py -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.core.exceptions import ValidationError 3 | from django.urls import reverse 4 | 5 | from .base import BreadTestCase 6 | from .models import BreadTestModel 7 | 8 | 9 | class TestForm(forms.ModelForm): 10 | # A form we override the bread form with 11 | # It only allows names that start with 'Dan' 12 | class Meta: 13 | model = BreadTestModel 14 | fields = ["name", "age"] 15 | 16 | def clean_name(self): 17 | name = self.cleaned_data["name"] 18 | if not name.startswith("Dan"): 19 | raise ValidationError("All good names start with Dan") 20 | return name 21 | 22 | 23 | class BreadFormAddTest(BreadTestCase): 24 | extra_bread_attributes = { 25 | "form_class": TestForm, 26 | } 27 | 28 | def setUp(self): 29 | super().setUp() 30 | self.set_urls(self.bread) 31 | 32 | def test_new_item(self): 33 | self.model.objects.all().delete() 34 | url = reverse(self.bread.get_url_name("add")) 35 | request = self.request_factory.post( 36 | url, data={"name": "Dan Jones", "age": "19"} 37 | ) 38 | request.user = self.user 39 | self.give_permission("add") 40 | view = self.bread.get_add_view() 41 | rsp = view(request) 42 | self.assertEqual(302, rsp.status_code) 43 | self.assertEqual(reverse(self.bread.get_url_name("browse")), rsp["Location"]) 44 | item = self.model.objects.get() 45 | self.assertEqual("Dan Jones", item.name) 46 | 47 | def test_fail_validation(self): 48 | self.model.objects.all().delete() 49 | url = reverse(self.bread.get_url_name("add")) 50 | request = self.request_factory.post(url, data={"name": "Fred", "age": "19"}) 51 | request.user = self.user 52 | self.give_permission("add") 53 | view = self.bread.get_add_view() 54 | rsp = view(request) 55 | self.assertEqual(400, rsp.status_code) 56 | context = rsp.context_data 57 | form = context["form"] 58 | errors = form.errors 59 | self.assertIn("name", errors) 60 | 61 | def test_get(self): 62 | # Get should give you a blank form 63 | url = reverse(self.bread.get_url_name("add")) 64 | request = self.request_factory.get(url) 65 | request.user = self.user 66 | self.give_permission("add") 67 | view = self.bread.get_add_view() 68 | rsp = view(request) 69 | self.assertEqual(200, rsp.status_code) 70 | form = rsp.context_data["form"] 71 | self.assertFalse(form.is_bound) 72 | rsp.render() 73 | body = rsp.content.decode("utf-8") 74 | self.assertIn('method="POST"', body) 75 | 76 | 77 | class BreadFormEditTest(BreadTestCase): 78 | extra_bread_attributes = { 79 | "form_class": TestForm, 80 | } 81 | 82 | def setUp(self): 83 | super().setUp() 84 | self.set_urls(self.bread) 85 | 86 | def test_edit_item(self): 87 | item = self.model_factory() 88 | url = reverse(self.bread.get_url_name("edit"), kwargs={"pk": item.pk}) 89 | request = self.request_factory.post( 90 | url, data={"name": "Dan Jones", "age": "19"} 91 | ) 92 | request.user = self.user 93 | self.give_permission("change") 94 | view = self.bread.get_edit_view() 95 | rsp = view(request, pk=item.pk) 96 | self.assertEqual(302, rsp.status_code) 97 | self.assertEqual(reverse(self.bread.get_url_name("browse")), rsp["Location"]) 98 | item = self.model.objects.get(pk=item.pk) 99 | self.assertEqual("Dan Jones", item.name) 100 | 101 | def test_fail_validation(self): 102 | item = self.model_factory() 103 | url = reverse(self.bread.get_url_name("edit"), kwargs={"pk": item.pk}) 104 | request = self.request_factory.post(url, data={"name": "Fred", "age": "19"}) 105 | request.user = self.user 106 | self.give_permission("change") 107 | view = self.bread.get_edit_view() 108 | rsp = view(request, pk=item.pk) 109 | self.assertEqual(400, rsp.status_code) 110 | context = rsp.context_data 111 | form = context["form"] 112 | errors = form.errors 113 | self.assertIn("name", errors) 114 | 115 | def test_get(self): 116 | # Get should give you a form with the item filled in 117 | item = self.model_factory() 118 | url = reverse(self.bread.get_url_name("edit"), kwargs={"pk": item.pk}) 119 | request = self.request_factory.get(url) 120 | request.user = self.user 121 | self.give_permission("change") 122 | view = self.bread.get_edit_view() 123 | rsp = view(request, pk=item.pk) 124 | self.assertEqual(200, rsp.status_code) 125 | form = rsp.context_data["form"] 126 | self.assertFalse(form.is_bound) 127 | self.assertEqual(item.name, form.initial["name"]) 128 | rsp.render() 129 | body = rsp.content.decode("utf-8") 130 | self.assertIn('method="POST"', body) 131 | 132 | 133 | class BreadExcludeTest(BreadTestCase): 134 | # We can exclude a field from the default form 135 | extra_bread_attributes = {"exclude": ["id"]} 136 | 137 | def setUp(self): 138 | super().setUp() 139 | self.set_urls(self.bread) 140 | 141 | def test_get(self): 142 | # Get should give you a form with the item filled in 143 | item = self.model_factory() 144 | url = reverse(self.bread.get_url_name("edit"), kwargs={"pk": item.pk}) 145 | request = self.request_factory.get(url) 146 | request.user = self.user 147 | self.give_permission("change") 148 | view = self.bread.get_edit_view() 149 | rsp = view(request, pk=item.pk) 150 | self.assertEqual(200, rsp.status_code) 151 | form = rsp.context_data["form"] 152 | self.assertFalse(form.is_bound) 153 | self.assertNotIn("id", form.initial) 154 | self.assertEqual(item.name, form.initial["name"]) 155 | -------------------------------------------------------------------------------- /tests/test_pagination.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404 2 | from django.urls import reverse 3 | 4 | from bread.bread import BrowseView 5 | 6 | from .base import BreadTestCase 7 | 8 | PAGE_SIZE = 5 9 | 10 | 11 | class BreadPaginationTest(BreadTestCase): 12 | class BrowseTestView(BrowseView): 13 | paginate_by = PAGE_SIZE 14 | 15 | extra_bread_attributes = {"browse_view": BrowseTestView} 16 | 17 | def setUp(self): 18 | super().setUp() 19 | [self.model_factory() for __ in range(2 * PAGE_SIZE + 1)] 20 | self.set_urls(self.bread) 21 | self.give_permission("browse") 22 | 23 | def test_get(self): 24 | url = reverse(self.bread.get_url_name("browse")) 25 | request = self.request_factory.get(url) 26 | request.user = self.user 27 | rsp = self.bread.get_browse_view()(request) 28 | self.assertEqual(200, rsp.status_code) 29 | rsp.render() 30 | context = rsp.context_data 31 | object_list = context["object_list"] 32 | self.assertEqual(PAGE_SIZE, len(object_list)) 33 | paginator = context["paginator"] 34 | self.assertEqual(3, paginator.num_pages) 35 | # Should start with first item 36 | ordered_items = self.model.objects.all() 37 | self.assertEqual(object_list[0], ordered_items[0]) 38 | 39 | def test_get_second_page(self): 40 | url = reverse(self.bread.get_url_name("browse")) + "?page=2" 41 | request = self.request_factory.get(url) 42 | request.user = self.user 43 | rsp = self.bread.get_browse_view()(request) 44 | self.assertEqual(200, rsp.status_code) 45 | rsp.render() 46 | context = rsp.context_data 47 | object_list = context["object_list"] 48 | self.assertEqual(PAGE_SIZE, len(object_list)) 49 | paginator = context["paginator"] 50 | self.assertEqual(3, paginator.num_pages) 51 | # Should start with item with index page_size 52 | ordered_items = self.model.objects.all() 53 | self.assertEqual(object_list[0], ordered_items[PAGE_SIZE]) 54 | 55 | def test_get_page_past_the_end(self): 56 | url = reverse(self.bread.get_url_name("browse")) + "?page=99" 57 | request = self.request_factory.get(url) 58 | request.user = self.user 59 | with self.assertRaises(Http404): 60 | self.bread.get_browse_view()(request) 61 | 62 | def test_get_empty_list(self): 63 | self.set_urls(self.bread) 64 | self.model.objects.all().delete() 65 | self.give_permission("browse") 66 | url = reverse(self.bread.get_url_name("browse")) 67 | request = self.request_factory.get(url) 68 | request.user = self.user 69 | rsp = self.bread.get_browse_view()(request) 70 | self.assertEqual(200, rsp.status_code) 71 | context = rsp.context_data 72 | paginator = context["paginator"] 73 | self.assertEqual(1, paginator.num_pages) 74 | self.assertEqual(0, len(context["object_list"])) 75 | 76 | def test_post(self): 77 | self.set_urls(self.bread) 78 | self.give_permission("browse") 79 | url = reverse(self.bread.get_url_name("browse")) 80 | request = self.request_factory.post(url) 81 | request.user = self.user 82 | rsp = self.bread.get_browse_view()(request) 83 | self.assertEqual(405, rsp.status_code) 84 | 85 | def test_next_url(self): 86 | # Make sure next_url includes other query params unaltered 87 | self.set_urls(self.bread) 88 | self.give_permission("browse") 89 | base_url = reverse(self.bread.get_url_name("browse")) 90 | # Add a query parm that needs to be preserved by the next page link 91 | url = base_url + "?test=1" 92 | request = self.request_factory.get(url) 93 | request.user = self.user 94 | rsp = self.bread.get_browse_view()(request) 95 | self.assertEqual(200, rsp.status_code) 96 | context = rsp.context_data 97 | next_url = context["next_url"] 98 | # We don't know what order the query parms will end up in 99 | expected_urls = [base_url + "?test=1&page=2", base_url + "?page=2&test=1"] 100 | self.assertIn(next_url, expected_urls) 101 | -------------------------------------------------------------------------------- /tests/test_permissions.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import REDIRECT_FIELD_NAME 3 | from django.contrib.auth.models import AnonymousUser 4 | from django.core.exceptions import PermissionDenied 5 | from django.test import override_settings 6 | from django.urls import reverse 7 | 8 | from .base import BreadTestCase 9 | 10 | 11 | class BreadPermissionTestMixin: 12 | # Just a mixin so these test methods don't try to run on 13 | # a parent testcase class 14 | include_post = False 15 | 16 | def setUp(self): 17 | super().setUp() 18 | 19 | self.set_urls(self.bread) 20 | 21 | self.item = self.model_factory() 22 | url_name = self.bread.get_url_name(self.view_name) 23 | if self.expects_pk: 24 | url = reverse(url_name, kwargs={"pk": self.item.pk}) 25 | else: 26 | url = reverse(url_name) 27 | self.request = self.request_factory.get(url) 28 | self.request.user = self.user 29 | 30 | self.post_request = self.request_factory.post(url, {"name": "foo"}) 31 | self.post_request.user = self.user 32 | 33 | view_getter = getattr(self.bread, "get_%s_view" % self.view_name) 34 | self.view = view_getter() 35 | 36 | @override_settings(LOGIN_URL="/logmein") 37 | def test_when_not_logged_in(self): 38 | # Can't do it if not logged in 39 | # but we get redirected rather than PermissionDenied 40 | self.request.user = AnonymousUser() 41 | rsp = self.view(self.request, pk=self.item.pk) 42 | expected_url = "{}?{}={}".format( 43 | settings.LOGIN_URL, 44 | REDIRECT_FIELD_NAME, 45 | self.request.path, 46 | ) 47 | self.assertEqual(expected_url, rsp["Location"]) 48 | 49 | if self.include_post: 50 | self.post_request.user = AnonymousUser() 51 | rsp = self.view(self.post_request, pk=self.item.pk) 52 | self.assertEqual(302, rsp.status_code) 53 | self.assertEqual(expected_url, rsp["Location"]) 54 | 55 | def test_access_without_permission(self): 56 | # Can't do it when logged in but no permission, get 403 57 | with self.assertRaises(PermissionDenied): 58 | if self.expects_pk: 59 | self.view(self.request, pk=self.item.pk) 60 | else: 61 | self.view(self.request) 62 | if self.include_post: 63 | with self.assertRaises(PermissionDenied): 64 | if self.expects_pk: 65 | self.view(self.post_request, pk=self.item.pk) 66 | else: 67 | self.view(self.post_request) 68 | 69 | 70 | class BreadBrowsePermissionTest(BreadPermissionTestMixin, BreadTestCase): 71 | view_name = "browse" 72 | expects_pk = False 73 | 74 | 75 | class BreadReadPermissionTest(BreadPermissionTestMixin, BreadTestCase): 76 | view_name = "read" 77 | expects_pk = True 78 | 79 | 80 | class BreadEditPermissionTest(BreadPermissionTestMixin, BreadTestCase): 81 | view_name = "edit" 82 | expects_pk = True 83 | include_post = True 84 | 85 | 86 | class BreadAddPermissionTest(BreadPermissionTestMixin, BreadTestCase): 87 | view_name = "add" 88 | expects_pk = False 89 | include_post = True 90 | 91 | 92 | class BreadDeletePermissionTest(BreadPermissionTestMixin, BreadTestCase): 93 | view_name = "read" 94 | expects_pk = True 95 | include_post = True 96 | -------------------------------------------------------------------------------- /tests/test_read.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.http import Http404 3 | from django.urls import reverse 4 | 5 | from bread.bread import Bread, LabelValueReadView, ReadView 6 | 7 | from .base import BreadTestCase 8 | from .factories import BreadLabelValueTestModelFactory 9 | from .models import BreadLabelValueTestModel, BreadTestModel 10 | 11 | 12 | class BreadReadTest(BreadTestCase): 13 | def setUp(self): 14 | super().setUp() 15 | self.urlconf = "bread.tests.test_read" 16 | self.give_permission("view") 17 | self.set_urls(self.bread) 18 | 19 | def test_read(self): 20 | item = self.model_factory() 21 | url = reverse("read_%s" % self.model_name, kwargs={"pk": item.pk}) 22 | request = self.request_factory.get(url) 23 | request.user = self.user 24 | 25 | view = self.bread.get_read_view() 26 | rsp = view(request, pk=item.pk) 27 | 28 | self.assertEqual(200, rsp.status_code) 29 | rsp.render() 30 | self.assertTrue(rsp.context_data["bread_test_class"]) 31 | body = rsp.content.decode("utf-8") 32 | self.assertIn(item.name, body) 33 | 34 | def test_read_no_such_item(self): 35 | self.assertFalse(self.model.objects.filter(pk=999).exists()) 36 | url = reverse("read_%s" % self.model_name, kwargs={"pk": 999}) 37 | request = self.request_factory.get(url) 38 | request.user = self.user 39 | 40 | view = self.bread.get_read_view() 41 | with self.assertRaises(Http404): 42 | view(request, pk=999) 43 | 44 | def test_post(self): 45 | self.set_urls(self.bread) 46 | self.give_permission("view") 47 | item = self.model_factory() 48 | url = reverse(self.bread.get_url_name("read"), kwargs={"pk": item.pk}) 49 | request = self.request_factory.post(url) 50 | request.user = self.user 51 | rsp = self.bread.get_read_view()(request) 52 | self.assertEqual(405, rsp.status_code) 53 | 54 | 55 | class BreadLabelValueReadTest(BreadTestCase): 56 | """Exercise LabelValueReadView, particularly the 5 modes described in get_field_label_value()""" 57 | 58 | def setUp(self): 59 | super().setUp() 60 | 61 | class ReadClass(LabelValueReadView): 62 | """See LabelValueReadView.get_field_label_value() for descriptions of the modes""" 63 | 64 | fields = [ 65 | (None, "id"), # Mode 1, also test of None for label. 66 | (None, "banana"), # Same, also test field w/explicit verbose_name 67 | ("eman", "name_reversed"), # Mode 2 68 | ("Foo", "bar"), # Mode 3 69 | # Mode 4 below 70 | ( 71 | "context first key", 72 | lambda context_data: sorted(context_data.keys())[0], 73 | ), 74 | ("Answer", 42), # Mode 5 75 | ("Model2", "model2"), # Back through related name for one2one field 76 | ] 77 | 78 | class BreadTestClass(Bread): 79 | model = BreadLabelValueTestModel 80 | base_template = "bread/empty.html" 81 | read_view = ReadClass 82 | 83 | self.bread = BreadTestClass() 84 | 85 | self.model = BreadLabelValueTestModel 86 | self.model_name = self.model._meta.model_name 87 | self.model_factory = BreadLabelValueTestModelFactory 88 | 89 | self.urlconf = "bread.tests.test_read" 90 | self.give_permission("view") 91 | self.set_urls(self.bread) 92 | 93 | def test_read(self): 94 | item = BreadLabelValueTestModel(name="abcde") 95 | item.save() 96 | url = reverse("read_%s" % self.model_name, kwargs={"pk": item.pk}) 97 | request = self.request_factory.get(url) 98 | request.user = self.user 99 | 100 | view = self.bread.get_read_view() 101 | rsp = view(request, pk=item.pk) 102 | 103 | self.assertEqual(200, rsp.status_code) 104 | rsp.render() 105 | body = rsp.content.decode("utf-8") 106 | self.assertIn("bar", body) 107 | 108 | # Test get_field_label_value() by checking the rendering of the the 5 fields of 109 | # TestLabelValueBreadReadView. 110 | key = sorted(rsp.context_data.keys())[0] 111 | for expected in ( 112 | f": {item.id}", 113 | ": 0", 114 | ": edcba", 115 | ": bar", 116 | ": {}".format( 117 | key 118 | ), 119 | ": 42", 120 | ): 121 | self.assertContains(rsp, expected) 122 | 123 | def test_setting_form_class(self): 124 | class DummyForm(forms.Form): 125 | pass 126 | 127 | glob = {} 128 | 129 | class TestView(ReadView): 130 | form_class = DummyForm 131 | 132 | # To get hold of a reference to the actual view object created by 133 | # bread, use a fake dispatch method that saves 'self' into a 134 | # dictionary we can access in the test. 135 | def dispatch(self, *args, **kwargs): 136 | glob["view_object"] = self 137 | 138 | class BreadTest(Bread): 139 | model = BreadTestModel 140 | read_view = TestView 141 | 142 | bread = BreadTest() 143 | view_function = bread.get_read_view() 144 | # Call the view function to invoke dispatch so we can get to the view itself 145 | view_function(None, None, None) 146 | self.assertEqual(DummyForm, glob["view_object"].form_class) 147 | -------------------------------------------------------------------------------- /tests/test_search.py: -------------------------------------------------------------------------------- 1 | from bread.bread import Bread, BrowseView 2 | from tests.base import BreadTestCase 3 | from tests.factories import BreadTestModelFactory 4 | from tests.models import BreadTestModel 5 | 6 | 7 | class BrowseSearchView(BrowseView): 8 | search_fields = ["name", "other__text"] 9 | 10 | 11 | class BreadSearchView(Bread): 12 | # Bread view with search fields defined 13 | base_template = "bread/empty.html" 14 | browse_view = BrowseSearchView 15 | model = BreadTestModel 16 | views = "B" 17 | 18 | 19 | class BreadNoSearchView(Bread): 20 | # Default bread view - no search fields defined 21 | base_template = "bread/empty.html" 22 | model = BreadTestModel 23 | views = "B" 24 | 25 | 26 | class BreadSearchTestCase(BreadTestCase): 27 | def setUp(self): 28 | super().setUp() 29 | self.bread = BreadSearchView() 30 | self.view = self.bread.get_browse_view() 31 | self.give_permission("browse") 32 | 33 | self.joe = BreadTestModelFactory(name="Joe", other__text="Smith") 34 | self.jim = BreadTestModelFactory(name="Jim", other__text="Brown") 35 | 36 | def get_search_results(self, q=None): 37 | data = {} 38 | if q is not None: 39 | data["q"] = q 40 | request = self.request_factory.get("", data=data) 41 | request.user = self.user 42 | rsp = self.view(request) 43 | self.assertEqual(200, rsp.status_code) 44 | return rsp.context_data["object_list"] 45 | 46 | def test_no_query_parm(self): 47 | objs = self.get_search_results() 48 | self.assertEqual(BreadTestModel.objects.count(), len(objs)) 49 | 50 | def test_simple_search_direct_field(self): 51 | objs = self.get_search_results(q="Joe") 52 | obj_ids = [obj.id for obj in objs] 53 | self.assertEqual([self.joe.id], obj_ids) 54 | 55 | def test_simple_search_any_field(self): 56 | # All records that match any field are returned 57 | objs = self.get_search_results(q="i") 58 | self.assertEqual(2, len(objs)) 59 | 60 | def test_simple_search_indirect_field(self): 61 | objs = self.get_search_results(q="Smith") 62 | obj_ids = [obj.id for obj in objs] 63 | self.assertEqual([self.joe.id], obj_ids) 64 | 65 | def test_multiple_terms_match(self): 66 | # A record that matches all terms is returned 67 | objs = self.get_search_results(q="Joe Smith") 68 | obj_ids = [obj.id for obj in objs] 69 | self.assertEqual([self.joe.id], obj_ids) 70 | 71 | def test_multiple_terms_dont_match(self): 72 | # All terms must match the record 73 | objs = self.get_search_results(q="Joe Brown") 74 | self.assertEqual(0, len(objs)) 75 | 76 | def test_case_insensitive(self): 77 | objs = self.get_search_results(q="joe") 78 | obj_ids = [obj.id for obj in objs] 79 | self.assertEqual([self.joe.id], obj_ids) 80 | 81 | def test_nonascii_search(self): 82 | # This was failing if we were also paginating 83 | BreadTestModelFactory(name="قمر") 84 | BreadTestModelFactory(name="قمر") 85 | try: 86 | self.bread.browse_view.paginate_by = 1 87 | self.get_search_results(q="قمر") 88 | finally: 89 | self.bread.browse_view.paginate_by = None 90 | -------------------------------------------------------------------------------- /tests/test_templates.py: -------------------------------------------------------------------------------- 1 | from .base import BreadTestCase 2 | 3 | 4 | class BreadTemplateResolutionTest(BreadTestCase): 5 | def setUp(self): 6 | super().setUp() 7 | # skip 'add' since it uses 'edit' template 8 | self.bread_names = ["browse", "read", "edit", "delete"] 9 | self.app_name = self.model._meta.app_label 10 | 11 | def test_default_template_resolution(self): 12 | for bread_name in self.bread_names: 13 | vanilla_template_name = "{}/{}_{}.html".format( 14 | self.app_name, 15 | self.model_name, 16 | bread_name, 17 | ) 18 | default_bread_template_name = f"bread/{bread_name}.html" 19 | expected_templates = [ 20 | vanilla_template_name, 21 | default_bread_template_name, 22 | ] 23 | get_method_name = f"{bread_name}_view" 24 | view_class = getattr(self.bread, get_method_name) 25 | view = view_class(bread=self.bread, model=self.bread.model) 26 | self.assertEqual(view.get_template_names(), expected_templates) 27 | 28 | def test_customized_template_resolution(self): 29 | self.bread.template_name_pattern = "mysite/bread/{view}.html" 30 | 31 | for bread_name in self.bread_names: 32 | vanilla_template_name = "{}/{}_{}.html".format( 33 | self.app_name, 34 | self.model_name, 35 | bread_name, 36 | ) 37 | custom_template_name = f"mysite/bread/{bread_name}.html" 38 | default_bread_template_name = f"bread/{bread_name}.html" 39 | expected_templates = [ 40 | vanilla_template_name, 41 | custom_template_name, 42 | default_bread_template_name, 43 | ] 44 | get_method_name = f"{bread_name}_view" 45 | view_class = getattr(self.bread, get_method_name) 46 | view = view_class(bread=self.bread, model=self.bread.model) 47 | self.assertEqual(view.get_template_names(), expected_templates) 48 | -------------------------------------------------------------------------------- /tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from .base import BreadTestCase 2 | 3 | 4 | class BreadURLsNamespaceTest(BreadTestCase): 5 | url_namespace = "testns" 6 | 7 | def test_all_views_urls_with_namespace(self): 8 | # get_urls() returns the expected URL patterns 9 | bread = self.bread 10 | patterns = bread.get_urls() 11 | 12 | self.assertEqual( 13 | { 14 | bread.browse_url_name(include_namespace=False), 15 | bread.read_url_name(include_namespace=False), 16 | bread.edit_url_name(include_namespace=False), 17 | bread.add_url_name(include_namespace=False), 18 | bread.delete_url_name(include_namespace=False), 19 | }, 20 | {x.name for x in patterns}, 21 | ) 22 | 23 | self.assertTrue(bread.browse_url_name().startswith(self.url_namespace + ":")) 24 | self.assertTrue(bread.read_url_name().startswith(self.url_namespace + ":")) 25 | self.assertTrue(bread.edit_url_name().startswith(self.url_namespace + ":")) 26 | self.assertTrue(bread.add_url_name().startswith(self.url_namespace + ":")) 27 | self.assertTrue(bread.delete_url_name().startswith(self.url_namespace + ":")) 28 | self.assertFalse( 29 | bread.browse_url_name(include_namespace=False).startswith( 30 | self.url_namespace + ":" 31 | ) 32 | ) 33 | self.assertFalse( 34 | bread.read_url_name(include_namespace=False).startswith( 35 | self.url_namespace + ":" 36 | ) 37 | ) 38 | self.assertFalse( 39 | bread.edit_url_name(include_namespace=False).startswith( 40 | self.url_namespace + ":" 41 | ) 42 | ) 43 | self.assertFalse( 44 | bread.add_url_name(include_namespace=False).startswith( 45 | self.url_namespace + ":" 46 | ) 47 | ) 48 | self.assertFalse( 49 | bread.delete_url_name(include_namespace=False).startswith( 50 | self.url_namespace + ":" 51 | ) 52 | ) 53 | 54 | browse_pattern = [ 55 | x 56 | for x in patterns 57 | if x.name == bread.browse_url_name(include_namespace=False) 58 | ][0].pattern 59 | self.assertEqual("%s/" % self.bread.plural_name, str(browse_pattern)) 60 | 61 | read_pattern = [ 62 | x 63 | for x in patterns 64 | if x.name == bread.read_url_name(include_namespace=False) 65 | ][0].pattern 66 | self.assertEqual("%s//" % self.bread.plural_name, str(read_pattern)) 67 | 68 | edit_pattern = [ 69 | x 70 | for x in patterns 71 | if x.name == bread.edit_url_name(include_namespace=False) 72 | ][0].pattern 73 | self.assertEqual( 74 | "%s//edit/" % self.bread.plural_name, str(edit_pattern) 75 | ) 76 | 77 | 78 | class BreadURLsTest(BreadTestCase): 79 | def test_all_views_urls_no_namespace(self): 80 | # get_urls() returns the expected URL patterns 81 | bread = self.bread 82 | patterns = bread.get_urls() 83 | 84 | self.assertEqual( 85 | { 86 | bread.browse_url_name(), 87 | bread.read_url_name(), 88 | bread.edit_url_name(), 89 | bread.add_url_name(), 90 | bread.delete_url_name(), 91 | }, 92 | {x.name for x in patterns}, 93 | ) 94 | 95 | browse_pattern = [x for x in patterns if x.name == bread.browse_url_name()][ 96 | 0 97 | ].pattern 98 | self.assertEqual("%s/" % bread.plural_name, str(browse_pattern)) 99 | 100 | read_pattern = [x for x in patterns if x.name == bread.read_url_name()][ 101 | 0 102 | ].pattern 103 | self.assertEqual("%s//" % bread.plural_name, str(read_pattern)) 104 | 105 | edit_pattern = [x for x in patterns if x.name == bread.edit_url_name()][ 106 | 0 107 | ].pattern 108 | self.assertEqual("%s//edit/" % bread.plural_name, str(edit_pattern)) 109 | 110 | def test_view_subset(self): 111 | # We can do bread with a subset of the BREAD views 112 | self.bread.views = "B" 113 | url_names = [x.name for x in self.bread.get_urls()] 114 | self.assertIn("browse_%s" % self.bread.plural_name, url_names) 115 | self.assertNotIn("read_%s" % self.model_name, url_names) 116 | self.assertNotIn("edit_%s" % self.model_name, url_names) 117 | self.assertNotIn("add_%s" % self.model_name, url_names) 118 | self.assertNotIn("delete_%s" % self.model_name, url_names) 119 | 120 | self.bread.views = "RE" 121 | url_names = [x.name for x in self.bread.get_urls()] 122 | self.assertNotIn("browse_%s" % self.bread.plural_name, url_names) 123 | self.assertIn("read_%s" % self.model_name, url_names) 124 | self.assertIn("edit_%s" % self.model_name, url_names) 125 | self.assertNotIn("add_%s" % self.model_name, url_names) 126 | self.assertNotIn("delete_%s" % self.model_name, url_names) 127 | 128 | def test_url_names(self): 129 | # The xxxx_url_name methods return what we expect 130 | bread = self.bread 131 | self.assertEqual("browse_%s" % self.bread.plural_name, bread.browse_url_name()) 132 | self.assertEqual("read_%s" % self.model_name, bread.read_url_name()) 133 | self.assertEqual("edit_%s" % self.model_name, bread.edit_url_name()) 134 | self.assertEqual("add_%s" % self.model_name, bread.add_url_name()) 135 | self.assertEqual("delete_%s" % self.model_name, bread.delete_url_name()) 136 | 137 | def test_omit_prefix(self): 138 | bread = self.bread 139 | patterns = bread.get_urls(prefix=False) 140 | 141 | self.assertEqual( 142 | { 143 | bread.browse_url_name(), 144 | bread.read_url_name(), 145 | bread.edit_url_name(), 146 | bread.add_url_name(), 147 | bread.delete_url_name(), 148 | }, 149 | {x.name for x in patterns}, 150 | ) 151 | 152 | browse_pattern = [x for x in patterns if x.name == bread.browse_url_name()][ 153 | 0 154 | ].pattern 155 | self.assertEqual("", str(browse_pattern)) 156 | 157 | read_pattern = [x for x in patterns if x.name == bread.read_url_name()][ 158 | 0 159 | ].pattern 160 | self.assertEqual("/", str(read_pattern)) 161 | 162 | edit_pattern = [x for x in patterns if x.name == bread.edit_url_name()][ 163 | 0 164 | ].pattern 165 | self.assertEqual("/edit/", str(edit_pattern)) 166 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import FieldDoesNotExist, ValidationError 2 | from django.test import TestCase 3 | 4 | from bread.utils import ( 5 | get_model_field, 6 | get_verbose_name, 7 | has_required_args, 8 | validate_fieldspec, 9 | ) 10 | from tests.models import BreadLabelValueTestModel, BreadTestModel, BreadTestModel2 11 | 12 | 13 | class HasRequiredArgsTestCase(TestCase): 14 | def test_simple_no_args_function(self): 15 | def testfunc(): 16 | pass 17 | 18 | self.assertFalse(has_required_args(testfunc)) 19 | 20 | def test_simple_1_arg_function(self): 21 | def testfunc(foo): 22 | pass 23 | 24 | self.assertTrue(has_required_args(testfunc)) 25 | 26 | def test_simple_1_arg_function_with_default(self): 27 | def testfunc(foo=2): 28 | pass 29 | 30 | self.assertFalse(has_required_args(testfunc)) 31 | 32 | def test_simple_2_arg_function(self): 33 | def testfunc(foo, bar): 34 | pass 35 | 36 | self.assertTrue(has_required_args(testfunc)) 37 | 38 | def test_simple_2_arg_function_with_one_default(self): 39 | def testfunc(foo, bar=3): 40 | pass 41 | 42 | self.assertTrue(has_required_args(testfunc)) 43 | 44 | def test_simple_2_arg_function_with_two_defaults(self): 45 | def testfunc(foo=1, bar=3): 46 | pass 47 | 48 | self.assertFalse(has_required_args(testfunc)) 49 | 50 | def test_class_function_no_args(self): 51 | class TestClass: 52 | def func(self): 53 | pass 54 | 55 | self.assertFalse(has_required_args(TestClass.func)) 56 | 57 | def test_class_function_1_arg(self): 58 | class TestClass: 59 | def func(self, foo): 60 | pass 61 | 62 | self.assertTrue(has_required_args(TestClass.func)) 63 | 64 | def test_class_function_1_arg_with_default(self): 65 | class TestClass: 66 | def func(self, foo=2): 67 | pass 68 | 69 | self.assertFalse(has_required_args(TestClass.func)) 70 | 71 | def test_class_function_2_args(self): 72 | class TestClass: 73 | def func(self, foo, bar): 74 | pass 75 | 76 | self.assertTrue(has_required_args(TestClass.func)) 77 | 78 | def test_class_function_2_args_1_default(self): 79 | class TestClass: 80 | def func(self, foo, bar=2): 81 | pass 82 | 83 | self.assertTrue(has_required_args(TestClass.func)) 84 | 85 | def test_class_function_2_args_2_defaults(self): 86 | class TestClass: 87 | def func(self, foo=1, bar=2): 88 | pass 89 | 90 | self.assertFalse(has_required_args(TestClass.func)) 91 | 92 | 93 | class GetModelFieldTestCase(TestCase): 94 | def test_it(self): 95 | obj3 = BreadLabelValueTestModel.objects.create(name="Species") 96 | obj2 = BreadTestModel2.objects.create(text="Rhinocerous", label_model=obj3) 97 | obj1 = BreadTestModel.objects.create(name="Rudy Vallee", other=obj2, age=72) 98 | self.assertEqual(obj1.name, get_model_field(obj1, "name")) 99 | self.assertEqual(obj1.name, get_model_field(obj1, "get_name")) 100 | 101 | self.assertEqual(obj2.text, get_model_field(obj1, "other__text")) 102 | self.assertEqual(obj2.text, get_model_field(obj1, "other__get_text")) 103 | 104 | # Prove that we can call a dunder method. 105 | self.assertEqual(obj1.name, get_model_field(obj1, "__str__")) 106 | 107 | # Prove that we can traverse reverse OneToOneRel fields 108 | self.assertEqual(obj2.text, get_model_field(obj3, "model2__text")) 109 | 110 | 111 | class ValidateFieldspecTestCase(TestCase): 112 | def test_simple_field(self): 113 | validate_fieldspec(BreadTestModel, "name") 114 | 115 | def test_method_name(self): 116 | validate_fieldspec(BreadTestModel, "get_name") 117 | 118 | def test_method_with_optional_arg(self): 119 | validate_fieldspec(BreadTestModel, "method2") 120 | 121 | def test_method_with_required_arg(self): 122 | with self.assertRaises(ValidationError): 123 | validate_fieldspec(BreadTestModel, "method1") 124 | 125 | def test_no_such_attribute(self): 126 | with self.assertRaises(ValidationError): 127 | validate_fieldspec(BreadTestModel, "petunias") 128 | 129 | def test_get_other(self): 130 | validate_fieldspec(BreadTestModel, "other") 131 | 132 | def test_field_on_other(self): 133 | validate_fieldspec(BreadTestModel, "other__text") 134 | 135 | def test_method_on_other(self): 136 | validate_fieldspec(BreadTestModel, "other__get_text") 137 | 138 | def test_no_such_attribute_on_other(self): 139 | with self.assertRaises(ValidationError): 140 | validate_fieldspec(BreadTestModel, "other__petunias") 141 | 142 | def test_reverse_one_to_one_rel(self): 143 | validate_fieldspec(BreadLabelValueTestModel, "model2__text") 144 | 145 | 146 | class GetVerboseNameTest(TestCase): 147 | """Exercise get_verbose_name()""" 148 | 149 | def test_with_model(self): 150 | """Ensure a model is accepted as a param""" 151 | self.assertEqual( 152 | get_verbose_name(BreadLabelValueTestModel, "banana"), "A Yellow Fruit" 153 | ) 154 | 155 | def test_with_instance(self): 156 | """Ensure a model instance is accepted as a param""" 157 | self.assertEqual( 158 | get_verbose_name(BreadLabelValueTestModel(), "banana"), "A Yellow Fruit" 159 | ) 160 | 161 | def test_no_title_cap(self): 162 | """Ensure title cap is optional""" 163 | self.assertEqual( 164 | get_verbose_name(BreadLabelValueTestModel, "banana", False), 165 | "a yellow fruit", 166 | ) 167 | 168 | def test_field_with_no_explicit_verbose_name(self): 169 | """Test behavior with a field to which we haven't given an explicit name""" 170 | self.assertEqual(get_verbose_name(BreadLabelValueTestModel, "id"), "Id") 171 | 172 | def test_failure(self): 173 | """Ensure FieldDoesNotExist is raised no matter what trash is passed as the field name""" 174 | for field_name in ( 175 | "kjasfhkjdh", 176 | "sfasfda", 177 | None, 178 | 42, 179 | False, 180 | complex(42), 181 | lambda: None, 182 | ValueError(), 183 | {}, 184 | [], 185 | tuple(), 186 | ): 187 | with self.assertRaises(FieldDoesNotExist): 188 | get_verbose_name(BreadLabelValueTestModel, field_name) 189 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py39-django42, 3 | py31{0,1,2}-django{42,50,51,52} 4 | 5 | [gh-actions] 6 | python = 7 | 3.9: py39 8 | 3.10: py310 9 | 3.11: py311 10 | 3.12: py312 11 | 12 | [testenv] 13 | deps = 14 | factory_boy==3.2.1 15 | django42: Django>=4.2,<5.0 16 | django50: Django>=5.0,<5.1 17 | django51: Django>=5.1,<5.2 18 | django52: Django>=5.2,<6.0 19 | commands = python -Wmodule runtests.py 20 | --------------------------------------------------------------------------------