├── .flake8 ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── cbv_inspect ├── __init__.py ├── apps.py ├── decorators.py ├── middleware.py ├── mixins.py ├── templates │ └── cbv_inspect │ │ └── toolbar.html ├── utils.py └── views.py ├── example ├── books │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── fixtures │ │ └── fake_data.json │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── templates │ │ ├── base.html │ │ └── books │ │ │ ├── author_form.html │ │ │ ├── book_confirm_delete.html │ │ │ ├── book_detail.html │ │ │ ├── book_form.html │ │ │ ├── book_list.html │ │ │ └── hello.html │ ├── urls.py │ └── views.py ├── manage.py └── project │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── pyproject.toml ├── requirements.txt └── tests ├── __init__.py ├── apps.py ├── models.py ├── settings.py ├── templates └── base.html ├── test_helpers.py ├── test_middleware.py ├── test_mixins.py ├── test_utils.py ├── urls.py └── views.py /.flake8: -------------------------------------------------------------------------------- 1 | # https://pycodestyle.pycqa.org/en/latest/intro.html#error-codes 2 | 3 | [flake8] 4 | max-line-length = 100 5 | # E203 is for black https://github.com/PyCQA/pycodestyle/issues/373 6 | ignore = E203 7 | extend-exclude = .venv, venv, .misc, migrations 8 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish: 9 | name: Publish 📦 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Python 3.10 18 | uses: actions/setup-python@v3 19 | with: 20 | python-version: "3.10" 21 | 22 | - name: Upgrade pip version 23 | run: python -m pip install -U pip 24 | 25 | - name: Install dependencies 26 | run: pip install build --user 27 | 28 | - name: Build dist packages 29 | run: python -m build --sdist --wheel --outdir dist/ . 30 | 31 | - name: Publish to Test PyPI 32 | uses: pypa/gh-action-pypi-publish@release/v1 33 | with: 34 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 35 | repository_url: https://test.pypi.org/legacy/ 36 | 37 | - name: Publish to PyPI 38 | uses: pypa/gh-action-pypi-publish@release/v1 39 | with: 40 | password: ${{ secrets.PYPI_API_TOKEN }} 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | 6 | env: 7 | DJANGO_SETTINGS_MODULE: tests.settings 8 | 9 | jobs: 10 | test: 11 | name: Tests 🧪 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 16 | django-version: ["3.2", "4.0", "4.1"] 17 | exclude: 18 | - python-version: "3.7" 19 | django-version: "4.0" 20 | - python-version: "3.7" 21 | django-version: "4.1" 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v4 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | 32 | - name: Upgrade pip version 33 | run: python -m pip install -U pip 34 | 35 | - name: Upgrade django version 36 | run: pip install Django==${{ matrix.django-version }} 37 | 38 | - name: Install dependencies 39 | run: pip install -r requirements.txt 40 | 41 | - name: Run pycln 42 | run: pycln . --config pyproject.toml -c 43 | 44 | - name: Run isort 45 | run: isort . -c 46 | 47 | - name: Run black 48 | run: black . --check 49 | 50 | - name: Run flake8 51 | run: flake8 . 52 | 53 | - name: Run tests 54 | run: coverage run -m django test && coverage report && coverage xml 55 | 56 | - name: Upload coverage to Codecov 57 | uses: codecov/codecov-action@v3 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # Misc 141 | .vscode/ 142 | .misc/ 143 | .DS_Store 144 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Sangeeta Jadoonanan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | graft cbv_inspect 4 | global-exclude __pycache__ 5 | global-exclude *.py[co] 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PY=python3 2 | VENV=venv 3 | BIN=$(VENV)/bin 4 | PYTHON=$(BIN)/python 5 | 6 | 7 | # ------------------------------------------------------------------- 8 | # Development-related commands 9 | # Run commands inside virtualenv - https://earthly.dev/blog/python-makefile/ 10 | # ------------------------------------------------------------------- 11 | 12 | 13 | $(VENV)/bin/activate: requirements.txt 14 | $(PY) -m venv $(VENV) 15 | @echo "\n\033[1;36m[2/3] Upgrading pip and installing requirements 🔧 📦 🔧 \033[0m\n" 16 | $(PYTHON) -m pip install --upgrade pip 17 | $(BIN)/pip install -r requirements.txt 18 | 19 | 20 | install-django: $(VENV)/bin/activate 21 | @echo "\033[1;37m---- Installing Django 💚 ---- \033[0m\n" 22 | $(PYTHON) -m pip install Django~=3.2.7 23 | 24 | 25 | # ------------------------------------------------------------------- 26 | # Local development-related commands 27 | # ------------------------------------------------------------------- 28 | 29 | 30 | ## @(development) - Run the example app 31 | run-example: install-django 32 | @echo "\033[1;37m---- Running migrations 🗂 ---- \033[0m\n" 33 | $(PYTHON) example/manage.py migrate --noinput --settings=example.project.settings 34 | @echo "\n\033[1;37m---- Loading fixtures 💽 ---- \033[0m\n" 35 | $(PYTHON) example/manage.py loaddata fake_data.json --settings=example.project.settings 36 | @echo "\n\033[1;37m---- Running server 🏃‍♀️ ---- \033[0m\n" 37 | $(PYTHON) example/manage.py runserver --settings=example.project.settings 38 | 39 | 40 | ## @(development) - Run tests with coverage and make reports 41 | coverage: install-django 42 | @echo "\033[1;37m---- Running unittests 🧪✨ ---- \033[0m\n" 43 | DJANGO_SETTINGS_MODULE=tests.settings \ 44 | $(BIN)/coverage run -m django test && $(BIN)/coverage report 45 | $(BIN)/coverage html 46 | $(BIN)/coverage xml 47 | 48 | 49 | ## @(development) - Run linting and formatting checks 50 | lint: $(VENV)/bin/activate 51 | @echo "\n\033[1;36m[1/4] Running pycln check 👻 🧹 👻\033[0m\n" 52 | $(BIN)/pycln . --config pyproject.toml -vc 53 | @echo "\n\033[1;36m[2/4] Running isort check 👀 👀 👀\033[0m\n" 54 | $(BIN)/isort . -vc 55 | @echo "\n\033[1;36m[3/4] Running black check 🖤 🔥 🖤\033[0m\n" 56 | $(BIN)/black . -v --check 57 | @echo "\n\033[1;36m[4/4] Running flake8 🥶 🍦 🥶\033[0m\n" 58 | $(BIN)/flake8 . 59 | 60 | 61 | ## @(development) - Run linting and formatting 62 | format: $(VENV)/bin/activate 63 | @echo "\n\033[1;36m[1/4] Running pycln 👻 🧹 👻\033[0m\n" 64 | $(BIN)/pycln . --config pyproject.toml -v 65 | @echo "\n\033[1;36m[2/4] Running isort 👀 👀 👀\033[0m\n" 66 | $(BIN)/isort . -v 67 | @echo "\n\033[1;36m[3/4] Running black 🖤 🔥 🖤\033[0m\n" 68 | $(BIN)/black . -v 69 | @echo "\n\033[1;36m[4/4] Running flake8 🥶 🍦 🥶\033[0m\n" 70 | $(BIN)/flake8 . 71 | 72 | 73 | # ------------------------------------------------------------------- 74 | # Misc. commands 75 | # ------------------------------------------------------------------- 76 | 77 | 78 | ## @(misc) - Remove cached files and dirs from workspace 79 | clean: 80 | @echo "\033[1;37m---- Cleaning workspace 🧹💨 ----\033[0m\n" 81 | find . -type f -name "*.pyc" -delete 82 | find . -type d -name "__pycache__" -delete 83 | find . -type f -name "*.DS_Store" -delete 84 | 85 | 86 | ## @(misc) - Remove virtualenv directory 87 | rm-venv: 88 | @echo "\033[1;37m---- Removing virtualenv 🗂 🧹💨 ----\033[0m\n" 89 | rm -rf $(VENV) 90 | 91 | 92 | # ------------------------------------------------------------------- 93 | # Self-documenting Makefile targets - https://git.io/Jg3bU 94 | # ------------------------------------------------------------------- 95 | 96 | .DEFAULT_GOAL := help 97 | 98 | help: 99 | @echo "Usage:" 100 | @echo " make " 101 | @echo "" 102 | @echo "Targets:" 103 | @awk '/^[a-zA-Z\-\_0-9]+:/ \ 104 | { \ 105 | helpMessage = match(lastLine, /^## (.*)/); \ 106 | if (helpMessage) { \ 107 | helpCommand = substr($$1, 0, index($$1, ":")-1); \ 108 | helpMessage = substr(lastLine, RSTART + 3, RLENGTH); \ 109 | helpGroup = match(helpMessage, /^@([^ ]*)/); \ 110 | if (helpGroup) { \ 111 | helpGroup = substr(helpMessage, RSTART + 1, index(helpMessage, " ")-2); \ 112 | helpMessage = substr(helpMessage, index(helpMessage, " ")+1); \ 113 | } \ 114 | printf "%s| %-18s %s\n", \ 115 | helpGroup, helpCommand, helpMessage; \ 116 | } \ 117 | } \ 118 | { lastLine = $$0 }' \ 119 | $(MAKEFILE_LIST) \ 120 | | sort -t'|' -sk1,1 \ 121 | | awk -F '|' ' \ 122 | { \ 123 | cat = $$1; \ 124 | if (cat != lastCat || lastCat == "") { \ 125 | if ( cat == "0" ) { \ 126 | print "\nTargets:" \ 127 | } else { \ 128 | gsub("_", " ", cat); \ 129 | printf "\n%s\n", cat; \ 130 | } \ 131 | } \ 132 | print $$2 \ 133 | } \ 134 | { lastCat = $$1 }' 135 | @echo "" 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | django-cbv-inspect 3 |

4 | 5 |
6 | 7 | PyPI 8 | 9 | 10 | Test 11 | 12 | 13 | 14 | 15 | 16 | python-versions 17 | 18 | 19 | django-versions 20 | 21 |
22 | 23 |
24 | 25 |
26 |

A tool to help inspect all class-based views within your Django project 🔎 ✨

27 | Inspired by django-debug-toolbar ❤️ 28 |
29 | 30 |

31 |
32 | django-cbv-inspect demo 33 |

34 | 35 |
36 | 37 | --- 38 | 39 |
40 | 41 | ## 📦 Installation 42 | 1. Install with pip 43 | ``` 44 | pip install django-cbv-inspect 45 | ``` 46 | 47 | 2. Add `cbv_inspect` to your list of `INSTALLED_APPS` in your Django settings module 48 | ```python 49 | INSTALLED_APPS = [ 50 | ... 51 | "cbv_inspect", 52 | ... 53 | ] 54 | ``` 55 | 56 | 3. Add the middleware to your list of `MIDDLEWARE` classes in your Django settings module 57 | ```python 58 | MIDDLEWARE = [ 59 | ... 60 | "cbv_inspect.middleware.DjCbvInspectMiddleware", 61 | ... 62 | ] 63 | ``` 64 | 65 | 4. Make sure your `TEMPLATES` settings uses the `DjangoTemplates` backend with `APP_DIRS` set to `True` 66 | ```python 67 | TEMPLATES = [ 68 | { 69 | "BACKEND": "django.template.backends.django.DjangoTemplates", 70 | "APP_DIRS": True, 71 | ... 72 | } 73 | ] 74 | ``` 75 | 76 |
77 | 78 | --- 79 | 80 |
81 | 82 | ## 🛞 Usage 83 | When all installation steps are done, any html response rendered by a class-based view should display the `django-cbv-inspect` toolbar on the page. 84 | 85 | By default, all class-based views will be processed by the middleware. If you wish to exclude views, there are two options: 86 | 87 | ### Exclude via mixin 88 | ```python 89 | from cbv_inspect.mixins import DjCbvExcludeMixin 90 | 91 | 92 | class MyCoolView(DjCbvExcludeMixin, View): 93 | pass 94 | ``` 95 | 96 | 97 | ### Exclude via decorator 98 | ```python 99 | from django.utils.decorators import method_decorator 100 | from cbv_inspect.decorators import djcbv_exclude 101 | 102 | 103 | @method_decorator(djcbv_exclude, name="dispatch") 104 | class MyCoolView(View): 105 | pass 106 | ``` 107 | 108 |
109 | 110 | --- 111 | 112 |
113 | 114 | ## 🧪 Run locally 115 | You can run the `example` project locally to test things out! 116 | 117 | Clone the project and from the root of the repo, run the following Make command to setup the `example` project: 118 | ``` 119 | make run-example 120 | ``` 121 | 122 | To run unittests with coverage, run 123 | ``` 124 | make coverage 125 | ``` 126 | 127 |
128 | 129 | --- 130 | 131 |
132 | 133 | ## ⚡️ Features 134 | 135 | The `django-cbv-inspect` toolbar has three main sections: 136 | 137 | 1. View information 138 | 2. CBV method call chain 139 | 3. MRO classes 140 | 141 | 142 | ### View information 143 | 144 | This section shows high level information about the class-based view, request, and url. 145 | 146 | 147 | ### CBV method call chain 148 | 149 | This is the main section that shows all methods that were excuted for the current class-based view: 150 | 151 | It shows: 152 | - method name and signature 153 | - [Classy Class-Based Views (ccbv.co.uk)](https://ccbv.co.uk/) links 154 | - method arguments and return value 155 | - all resolved `super()` calls defined in the method 156 | - module location 157 | 158 | 159 | ### MRO classes 160 | This section lists all MRO classes of the current class-based view class. 161 | 162 | This can come in handy especially with the prior section when mapping the execution of a class-based view. 163 | 164 |
165 | 166 | --- 167 | 168 |
169 | 170 | ## ❓ Why did I build this? 171 | 172 | Django class-based views are hard to grasp especially when you're new to Django. 173 | 174 | Fortunately for us, tools like [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar) and [ccbv.co.uk](https://ccbv.co.uk/) are super helpful in providing extra context for debugging. 175 | 176 | My goal for this app was to take what goes on under the hood in a class-based view and display it in an easy to use interface, just like what django-debug-toolbar does. 177 | 178 | Hopefully this can help debug your class-based views! 179 | 180 | Happy coding! ✨ -------------------------------------------------------------------------------- /cbv_inspect/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjbitcode/django-cbv-inspect/fad3cc9db45f1ccd52f349db1b7a3a8a7475e2ad/cbv_inspect/__init__.py -------------------------------------------------------------------------------- /cbv_inspect/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CBVInspectConfig(AppConfig): 5 | name = "cbv_inspect" 6 | -------------------------------------------------------------------------------- /cbv_inspect/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import partial, wraps 2 | from typing import Callable 3 | 4 | 5 | def djcbv_exclude(view_func: Callable) -> Callable: 6 | """ 7 | Attach a `djcbv_exclude` attribute to the incoming view function, 8 | so it can get excluded by DjCbvInspectMiddleware. 9 | """ 10 | 11 | # We don't need to attach our attribute to the partial function 12 | # that Django `method_decorator` creates to have a bound function. 13 | # We need to attach our attribute to the decorated wrapper function 14 | # so that it gets inspected by the middleware before the view runs, 15 | # i.e. MyViewClass.dispatch function 16 | # Django's as_view() function then updates the returned view func with 17 | # attributes set by decorators on the dispatch function...this is how 18 | # our attribute gets exposed to the middleware. 19 | if not isinstance(view_func, partial): 20 | setattr(view_func, "djcbv_exclude", True) 21 | 22 | @wraps(view_func) 23 | def _wrapped_view(*args, **kwargs): 24 | return view_func(*args, **kwargs) 25 | 26 | return _wrapped_view 27 | -------------------------------------------------------------------------------- /cbv_inspect/middleware.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Callable, Dict, Tuple 3 | 4 | from django.conf import settings 5 | from django.http import HttpRequest, HttpResponse 6 | from django.urls import ResolverMatch, resolve 7 | 8 | from cbv_inspect import utils, views 9 | from cbv_inspect.mixins import DjCbvInspectMixin 10 | 11 | 12 | class DjCbvToolbar: 13 | def __init__(self, request: HttpRequest) -> None: 14 | self.request = request 15 | self.init_logs() 16 | 17 | def init_logs(self) -> None: 18 | """ 19 | Attach metadata to request object. 20 | """ 21 | 22 | match: ResolverMatch = resolve(self.request.path) 23 | 24 | metadata = utils.DjCbvRequestMetadata( 25 | path=self.request.path, 26 | method=self.request.method, 27 | view_path=match._func_path, 28 | url_name=match.view_name, 29 | args=match.args, 30 | kwargs=match.kwargs, 31 | base_classes=utils.get_bases(match.func.view_class), 32 | mro=utils.get_mro(match.func.view_class), 33 | ) 34 | 35 | self.request._djcbv_inspect_metadata = metadata 36 | 37 | def get_content(self) -> str: 38 | """ 39 | Render the djCbv toolbar and return stringified markup. 40 | """ 41 | return views.render_djcbv_panel(self.request) 42 | 43 | 44 | class DjCbvInspectMiddleware: 45 | def __init__(self, get_response: Callable) -> None: 46 | self.get_response = get_response 47 | 48 | @staticmethod 49 | def show_toolbar() -> bool: 50 | return settings.DEBUG 51 | 52 | @staticmethod 53 | def _is_response_insertable(response: HttpResponse) -> bool: 54 | """ 55 | Determine if djCbv content can be inserted into a response. 56 | """ 57 | 58 | content_type = response.get("Content-Type", "").split(";")[0] 59 | content_encoding = response.get("Content-Encoding", "") 60 | has_content = hasattr(response, "content") 61 | is_html_content_type = content_type == "text/html" 62 | gzipped_encoded = "gzip" in content_encoding 63 | streaming_response = response.streaming 64 | 65 | return ( 66 | has_content and is_html_content_type and not gzipped_encoded and not streaming_response 67 | ) 68 | 69 | @staticmethod 70 | def _remove_djcbv_mixin(request: HttpRequest) -> None: 71 | """ 72 | Remove mixin if its present in a request's CBV view class. 73 | """ 74 | 75 | view_func = resolve(request.path).func 76 | 77 | view_func.view_class.__bases__ = tuple( 78 | x for x in view_func.view_class.__bases__ if x is not DjCbvInspectMixin 79 | ) 80 | 81 | @staticmethod 82 | def _add_djcbv_mixin(view_func: Callable) -> None: 83 | """ 84 | Insert mixin in a CBV view class. 85 | """ 86 | 87 | view_func.view_class.__bases__ = (DjCbvInspectMixin, *view_func.view_class.__bases__) 88 | 89 | @staticmethod 90 | def is_view_excluded(request: HttpRequest) -> bool: 91 | """ 92 | Check for `djcbv_exclude` attribute on view function. 93 | """ 94 | 95 | view_func = resolve(request.path).func 96 | 97 | if hasattr(view_func, "djcbv_exclude"): 98 | return True 99 | 100 | return False 101 | 102 | def should_process_request(self, request: HttpRequest) -> bool: 103 | """ 104 | Determine if the middleware should process the request. 105 | 106 | Will process requests that meet the following criteria: 107 | 1. class-based views 108 | 2. show_toolbar is True 109 | 3. view is not excluded 110 | """ 111 | 112 | if not utils.is_cbv_request(request): 113 | return False 114 | 115 | if not self.show_toolbar(): 116 | return False 117 | 118 | if self.is_view_excluded(request): 119 | return False 120 | 121 | return True 122 | 123 | def __call__(self, request: HttpRequest) -> HttpResponse: 124 | """ 125 | This is the entrypoint of django-cbv-inspect. 126 | 127 | For incoming requests: 128 | 1. check if request should be processed 129 | 2. prep the request object by attaching metadata object to it 130 | 3. attach the mixin to cbv class before view gets called 131 | 132 | For outgoing responses: 133 | 1. remove the mixin from cbv class 134 | 2. render the djCbv toolbar html and attach to response 135 | """ 136 | 137 | if not self.should_process_request(request): 138 | return self.get_response(request) 139 | 140 | toolbar = DjCbvToolbar(request) 141 | 142 | response = self.get_response(request) 143 | 144 | self._remove_djcbv_mixin(request) 145 | 146 | if self._is_response_insertable(response): 147 | content = response.content.decode(response.charset) 148 | INSERT_BEFORE = "" 149 | response_parts = re.split(INSERT_BEFORE, content, flags=re.IGNORECASE) 150 | 151 | # insert djCbv content before closing body tag 152 | if len(response_parts) > 1: 153 | djcbv_content = toolbar.get_content() 154 | response_parts[-2] += djcbv_content 155 | response.content = INSERT_BEFORE.join(response_parts) 156 | 157 | if "Content-Length" in response: 158 | response["Content-Length"] = len(response.content) 159 | 160 | return response 161 | 162 | def process_view( 163 | self, request: HttpResponse, view_func: Callable, view_args: Tuple, view_kwargs: Dict 164 | ) -> None: 165 | if self.should_process_request(request): 166 | self._add_djcbv_mixin(view_func) 167 | -------------------------------------------------------------------------------- /cbv_inspect/mixins.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import inspect 3 | import logging 4 | from typing import Any, List 5 | 6 | from django.utils.decorators import method_decorator 7 | from django.utils.functional import cached_property 8 | 9 | from cbv_inspect import decorators, utils 10 | 11 | logger = logging.getLogger("cbv_inspect.mixins") 12 | 13 | 14 | class DjCbvInspectMixin: 15 | indent = 0 16 | order = 1 17 | 18 | @cached_property 19 | def allowed_callables(self) -> List: 20 | """ 21 | Return list of all allowed methods. 22 | """ 23 | cbv_funcs = list( 24 | filter( 25 | lambda x: not x[0].startswith("__"), 26 | inspect.getmembers(self.__class__, inspect.isfunction), 27 | ) 28 | ) 29 | return [func[0] for func in cbv_funcs] 30 | 31 | def __getattribute__(self, name: str) -> Any: 32 | attr = super().__getattribute__(name) 33 | 34 | if callable(attr) and name != "__class__" and name in self.allowed_callables: 35 | tab = "\t" 36 | log = utils.DjCbvLog() 37 | 38 | @functools.wraps(attr) 39 | def wrapper(*args, **kwargs): 40 | logger.debug("%s (%s) %s", tab * self.indent, self.order, attr.__qualname__) 41 | 42 | log.order = self.order 43 | log.indent = self.indent 44 | 45 | request = utils.get_request(self, attr, *args) 46 | # if request not found, return attr lookup result 47 | if request is None: 48 | return attr(*args, **kwargs) 49 | 50 | request._djcbv_inspect_metadata.logs[log.order] = log 51 | utils.set_log_parents(self.order, request) 52 | 53 | # Prep for next call 54 | self.indent += 1 55 | self.order += 1 56 | 57 | ret = attr(*args, **kwargs) 58 | 59 | log.name = attr.__qualname__ 60 | log.args = utils.serialize_params(args) 61 | log.kwargs = utils.serialize_params(kwargs) 62 | log.return_value = utils.serialize_params(ret) 63 | log.signature = utils.get_signature(attr) 64 | log.path = utils.get_path(attr) 65 | log.super_calls = utils.get_super_calls(attr) 66 | log.ccbv_link = utils.get_ccbv_link(attr) 67 | 68 | self.indent -= 1 69 | 70 | logger.debug( 71 | "%s (%s) result: %s", 72 | tab * self.indent, 73 | log.order, 74 | log.return_value.replace("\n", ""), 75 | ) 76 | 77 | return ret 78 | 79 | return wrapper 80 | return attr 81 | 82 | 83 | class DjCbvExcludeMixin: 84 | @method_decorator(decorators.djcbv_exclude) 85 | def dispatch(self, *args, **kwargs): 86 | return super().dispatch(*args, **kwargs) 87 | -------------------------------------------------------------------------------- /cbv_inspect/templates/cbv_inspect/toolbar.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 |
6 | D 7 | J 8 | CBV 9 |
10 |
11 | 12 | 13 |
14 | 15 | 16 |
17 |

CBV inspect for {{ method }} {{ path }}

18 | 19 |
20 | 21 | 22 |
23 |

View information

24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
View classBase classesHTTP methodArgumentsKeyword argumentsPathURL name
{{ view_path }} 40 | {% if base_classes %} 41 | {% for cls_dict in base_classes %} 42 |
43 | {% if cls_dict.ccbv_link %} 44 | 45 | {{ cls_dict.name }} 46 | 47 | {% else %} 48 | {{ cls_dict.name }} 49 | {% endif %} 50 |
51 | {% endfor %} 52 | {% endif %} 53 |
{{ method|pprint }}{{ args|pprint }}{{ kwargs|pprint }}{{ path|pprint }}{{ url_name }}
62 | 63 | {% if logs %} 64 |

CBV method call chain

65 | 66 | 67 | 68 | 69 | 73 | 77 | 81 | 85 | 89 | 90 | 91 | 92 | 93 | {% for key, val in logs.items %} 94 | 95 | 109 | 110 | 113 | 114 | 117 | 118 | 121 | 122 | 151 | 152 | 155 | 156 | {% endfor %} 157 | 158 |
CBV method 70 | 71 | Arguments 72 | 74 | 75 | Keyword arguments 76 | 78 | 79 | Return value 80 | 82 | 83 | Super calls 84 | 86 | 87 | File path 88 |
96 |
97 | {% if val.is_parent %} 98 | 99 | {% endif %} 100 | {% if val.ccbv_link %} 101 | 102 | {{ val.name }}{{ val.signature }} 103 | 104 | {% else %} 105 | {{ val.name }}{{ val.signature }} 106 | {% endif %} 107 |
108 |
111 | {{ val.args }} 112 | 115 | {{ val.kwargs }} 116 | 119 | {{ val.return_value }} 120 | 123 | {% if val.super_calls %} 124 | {% if val.super_calls|length > 1 %} 125 | 138 | 139 | {% else %} 140 | {% if val.super_calls.0.ccbv_link %} 141 | 142 | {{ val.super_calls.0.name }}{{ val.super_calls.0.signature }} 143 | 144 | {% else %} 145 | {{ val.super_calls.0.name }}{{ val.super_calls.0.signature }} 146 | {% endif %} 147 | {% endif %} 148 | 149 | {% endif %} 150 | 153 | {{ val.path }} 154 |
159 | {% else %} 160 |

No CBV method call chain

161 | {% endif %} 162 | 163 | {% if mro %} 164 |

Ancestors (MRO)

165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | {% for cls_dict in mro %} 175 | 176 | 177 | 186 | 187 | {% endfor %} 188 | 189 |
OrderClass
{{ forloop.counter0 }} 178 | {% if cls_dict.ccbv_link %} 179 | 180 | {{ cls_dict.name }} 181 | 182 | {% else %} 183 | {{ cls_dict.name }} 184 | {% endif %} 185 |
190 | {% else %} 191 |

No Ancestors (MRO)

192 | {% endif %} 193 |
194 |
195 | 196 |
197 | 198 | 419 | 420 | 500 | -------------------------------------------------------------------------------- /cbv_inspect/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import inspect 5 | import logging 6 | import re 7 | from dataclasses import dataclass, field 8 | from pprint import pformat 9 | from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union 10 | 11 | from django import get_version 12 | from django.http import HttpRequest 13 | from django.urls import resolve 14 | 15 | from cbv_inspect import mixins 16 | 17 | logger = logging.getLogger("cbv_inspect.utils") 18 | 19 | 20 | class DjCbvException(Exception): 21 | pass 22 | 23 | 24 | @dataclass 25 | class DjCbvRequestMetadata: 26 | """ 27 | Dataclass to store djCbv request metadata. 28 | 29 | This is attached to the HttpRequest object like `request._djcbv_inspect_metadata`. 30 | """ 31 | 32 | path: str 33 | method: str 34 | view_path: str 35 | url_name: str 36 | args: Tuple[Any] 37 | kwargs: Dict[str, Any] 38 | logs: Dict = field(default_factory=dict) 39 | base_classes: Optional[List] = None 40 | mro: Optional[List] = None 41 | 42 | 43 | @dataclass 44 | class DjCbvLog: 45 | """ 46 | Dataclass to store logs for a class-based view execution. 47 | 48 | Log instances are stored on the `request._djcbv_inspect_metadata.logs` list. 49 | """ 50 | 51 | order: int = 0 52 | indent: int = 0 53 | 54 | is_parent: bool = False 55 | parent_list: List[str] = field(default_factory=list) 56 | 57 | name: str = None 58 | args: Tuple[str] = field(default_factory=tuple) 59 | kwargs: Dict[str, str] = field(default_factory=dict) 60 | return_value: Any = None 61 | signature: str = None 62 | path: str = None 63 | super_calls: List[str] = field(default_factory=list) 64 | ccbv_link: str = None 65 | 66 | @property 67 | def parents(self) -> str: 68 | return " ".join(self.parent_list) 69 | 70 | @property 71 | def padding(self) -> int: 72 | return self.indent * 30 73 | 74 | 75 | @dataclass 76 | class DjCbvClassOrMethodInfo: 77 | """ 78 | Dataclass to store common metadata about a method or class. 79 | """ 80 | 81 | ccbv_link: str = None 82 | name: str = None 83 | signature: str = None 84 | 85 | 86 | def is_cbv_view(func: Callable) -> bool: 87 | """ 88 | Determine if a function is a result of a CBV as_view() call. 89 | """ 90 | return hasattr(func, "view_class") 91 | 92 | 93 | def is_cbv_request(request: HttpRequest) -> bool: 94 | """ 95 | Determine if a request will map to a CBV. 96 | """ 97 | 98 | view_func = resolve(request.path).func 99 | 100 | return is_cbv_view(view_func) 101 | 102 | 103 | def collect_parent_classes(cls: Type, attr: str) -> List: 104 | """ 105 | Return metadata for all mro or base classes except for DjCBVInspectMixin. 106 | 107 | Note: attr would always be one of these strings: ["__mro__", "__bases__"]. 108 | """ 109 | 110 | classes = [] 111 | 112 | for cls in getattr(cls, attr, []): 113 | if cls is not mixins.DjCbvInspectMixin: 114 | classes.append( 115 | DjCbvClassOrMethodInfo( 116 | ccbv_link=get_ccbv_link(cls), name=f"{cls.__module__}.{cls.__name__}" 117 | ) 118 | ) 119 | 120 | return classes 121 | 122 | 123 | get_bases = functools.partial(collect_parent_classes, attr="__bases__") 124 | get_mro = functools.partial(collect_parent_classes, attr="__mro__") 125 | 126 | 127 | def get_ccbv_link(obj: Union[Callable, Type]) -> Optional[str]: 128 | """ 129 | Construct the ccbv.co.uk link for a class or method. 130 | ex: https://ccbv.co.uk/projects/Django/2.0/django.views.generic.base/View/#_allowed_methods 131 | 132 | Note: older versions of Django (1.4 - 1.7) have views from django.contrib.formtools.wizard, 133 | but we're skipping those. 134 | """ 135 | 136 | module: str = obj.__module__ 137 | from_generic: bool = module.startswith("django.views.generic") 138 | from_auth: bool = module.startswith("django.contrib.auth.views") 139 | 140 | if from_generic or from_auth: 141 | version = get_version().rsplit(".", 1)[0] 142 | 143 | if inspect.isroutine(obj): # function or bound method? 144 | class_name, method_name = obj.__qualname__.rsplit(".", 1) 145 | return ( 146 | f"https://ccbv.co.uk/projects/Django/{version}/{module}/{class_name}/#{method_name}" 147 | ) 148 | 149 | if inspect.isclass(obj): 150 | return f"https://ccbv.co.uk/projects/Django/{version}/{module}/{obj.__name__}" 151 | 152 | 153 | def get_path(obj: Callable) -> str: 154 | """ 155 | Return file path of a module. 156 | 157 | Note: site packages path start from package name. 158 | """ 159 | 160 | path: str = inspect.getfile(obj) 161 | site_pkg_dir = "/site-packages/" 162 | index: int = path.find(site_pkg_dir) 163 | 164 | # For site-packages paths, display path starting from // 165 | if index > -1: 166 | path = path[index + len(site_pkg_dir) - 1 :] 167 | 168 | return path 169 | 170 | 171 | def serialize_params(obj: Any) -> str: 172 | """ 173 | Return a stringified and masked representation of an object for 174 | function arguments, keyword arguments, and return values. 175 | """ 176 | 177 | formatted: str = pformat(obj) 178 | 179 | clean_funcs = [mask_request, mask_queryset] 180 | 181 | for clean_func in clean_funcs: 182 | formatted = clean_func(formatted) 183 | 184 | return formatted 185 | 186 | 187 | def get_signature(obj: Callable) -> str: 188 | """ 189 | Return the signature of a callable using inspect.Signature. 190 | 191 | We want the "self" argument in the signature which is only 192 | returned when we pass an unbound method to inspect.Signature. 193 | 194 | Bound methods are passed in, so we need the unbound function. 195 | """ 196 | 197 | func = obj 198 | 199 | # if obj is a bound method, get the function obj 200 | if hasattr(obj, "__func__"): 201 | func = obj.__func__ 202 | 203 | sig = inspect.signature(func) 204 | return str(sig) 205 | 206 | 207 | def mask_request(s: str) -> str: 208 | """ 209 | Subsitute an HttpRequests's string representation with a masked value. 210 | """ 211 | 212 | pattern = re.compile("") 213 | mask = "<>" 214 | return re.sub(pattern, mask, s) 215 | 216 | 217 | def mask_queryset(s: str) -> str: 218 | """ 219 | Substitute a QuerySet's string representation with a masked value. 220 | """ 221 | 222 | pattern = re.compile(r"") 223 | mask = "<>" 224 | return re.sub(pattern, mask, s) 225 | 226 | 227 | def get_callable_source(obj: Callable) -> Type: 228 | """ 229 | Return the object that defines the callable. 230 | 231 | Ex: 232 | - Given a method, this would return the class that defines it 233 | (not necessarily instance class) 234 | - Given a function, this would return the function itself 235 | """ 236 | # reference https://stackoverflow.com/a/55767059 237 | return vars(inspect.getmodule(obj))[obj.__qualname__.rsplit(".", 1)[0]] 238 | 239 | 240 | def class_has_method(cls: Type, method: str) -> bool: 241 | """ 242 | Check if a class defines a method. 243 | """ 244 | 245 | attr = getattr(cls, method, None) 246 | 247 | if attr: 248 | return callable(attr) 249 | 250 | return False 251 | 252 | 253 | def get_sourcecode(obj: Callable) -> str: 254 | """ 255 | Return an object's sourcecode without docstring and comments. 256 | """ 257 | 258 | source: str = inspect.getsource(obj) 259 | 260 | # remove docstring if it exists 261 | if obj.__doc__: 262 | source = source.replace(obj.__doc__, "", 1) 263 | 264 | return re.sub(re.compile(r"#.*?\n"), "", source) 265 | 266 | 267 | def get_super_calls(method: Callable) -> List: 268 | """ 269 | Extract, resolve, and return metadata for all super calls defined in a bound method. 270 | """ 271 | 272 | source: str = get_sourcecode(method) 273 | SUPER_PATTERN = re.compile(r"(super\(.*\)\.(?P\w+)\(.*\))") 274 | matches: List = re.findall(SUPER_PATTERN, source) 275 | 276 | if not matches: 277 | return 278 | 279 | super_metadata: List[DjCbvClassOrMethodInfo] = [] 280 | view_instance_cls = method.__self__.__class__ 281 | mro_classes: List = list( 282 | filter(lambda x: x.__name__ != "DjCBVInspectMixin", view_instance_cls.__mro__) 283 | ) 284 | # the class that defines this method containing super calls 285 | method_cls: Type = get_callable_source(method) 286 | 287 | # for each super call in method 288 | for match in matches: 289 | _, method_name = match 290 | method_info = {} 291 | 292 | # search remaining mro classes, after method_cls 293 | for mro_cls in mro_classes[mro_classes.index(method_cls) + 1 :]: 294 | if class_has_method(mro_cls, method_name): 295 | attr: Callable = getattr(mro_cls, method_name) 296 | 297 | # At this point, attr's class can be the current mro_cls or not. 298 | # ex: 299 | # mro_cls = ListView, method_name = "get_context_data" 300 | # hasattr(ListView, "get_context_data") is True, but 301 | # getattr(ListView, "get_context_data") is 302 | # 303 | # because the method is defined or overriden on MultipleObjectMixin. 304 | # We can return this metadata without iterating further through mro classes 305 | method_info = DjCbvClassOrMethodInfo( 306 | ccbv_link=get_ccbv_link(attr), 307 | name=attr.__qualname__, 308 | signature=str(inspect.signature(attr)), 309 | ) 310 | break 311 | super_metadata.append(method_info) 312 | 313 | return super_metadata 314 | 315 | 316 | def get_request(instance: object, attr: Callable, *args: Any) -> Optional[HttpRequest]: 317 | """ 318 | Attempt to get an HttpRequest object from one of two places: 319 | 1. a view class instance 320 | 2. a view class `setup` bound method 321 | 322 | View.setup is usually the first CBV method that runs and sets the 323 | request object on `self` (view instance), so we cannot access `self.request` 324 | before the setup method runs. Instead, grab it from its arguments. 325 | 326 | All following view methods will have the request available on the view class instance. 327 | 328 | Note: With custom CBVs, its possible that custom methods might run first 329 | even before `setup`. In that case, return None. 330 | """ 331 | 332 | if hasattr(instance, "request"): 333 | return instance.request 334 | 335 | if attr.__name__ == "setup": 336 | if isinstance(args[0], HttpRequest): 337 | return args[0] 338 | else: 339 | raise DjCbvException("Request object could not be setup method!") 340 | 341 | logger.debug("Request object could not be found on %s.%s", instance, attr) 342 | 343 | 344 | def set_log_parents(order: int, request: HttpRequest) -> None: 345 | """ 346 | Determine if current log has parents and if so, mark prior log as a parent. 347 | 348 | Logs are stored in a dict accessible via `request._djcbv_inspect_metadata.logs`. 349 | 350 | There are two attributes on each log to help determine parent status and parent logs: 351 | 1. `is_parent` (bool) 352 | 2. `parent_list` (list[str]) 353 | 354 | The parent log notation stored in `parent_list` is denoted by a string in the format 355 | "cbvInspect_[order]_[indent]". 356 | """ 357 | 358 | try: 359 | current_log = request._djcbv_inspect_metadata.logs[order] 360 | prior_log = request._djcbv_inspect_metadata.logs[order - 1] 361 | 362 | # is prior log a parent of current log? 363 | if prior_log.indent < current_log.indent: 364 | prior_log.is_parent = True 365 | current_log.parent_list.append(f"cbvInspect_{prior_log.order}_{prior_log.indent}") 366 | 367 | # copy the new parent's parents and assign to current log 368 | if prior_log.parent_list: 369 | current_log.parent_list = prior_log.parent_list + current_log.parent_list 370 | 371 | # if prior log is not a parent, then check previous logs to find any parents 372 | else: 373 | # get log dict keys up to the current log 374 | ancestor_log_keys = list(range(1, current_log.order)) 375 | 376 | # iterate over list backwards to access the closest log to get the correct parent 377 | for key in ancestor_log_keys[::-1]: 378 | ancestor_log = request._djcbv_inspect_metadata.logs[key] 379 | 380 | # if ancestor_log is a parent, copy its parents 381 | # plus itself as current log's parents and stop iteration 382 | if ancestor_log.is_parent and ancestor_log.indent < current_log.indent: 383 | current_log.parent_list = ancestor_log.parent_list + [ 384 | f"cbvInspect_{ancestor_log.order}_{ancestor_log.indent}" 385 | ] 386 | break 387 | except KeyError: 388 | pass 389 | -------------------------------------------------------------------------------- /cbv_inspect/views.py: -------------------------------------------------------------------------------- 1 | from dataclasses import fields 2 | 3 | from django.template.loader import render_to_string 4 | from django.utils.safestring import SafeString 5 | 6 | from cbv_inspect.utils import DjCbvRequestMetadata 7 | 8 | 9 | def render_djcbv_panel(request) -> SafeString: 10 | metadata: DjCbvRequestMetadata = getattr(request, "_djcbv_inspect_metadata") 11 | 12 | # creates a shallow copy of the metadata object 13 | # because we want to keep each log as a dataclass object 14 | ctx_data = dict((field.name, getattr(metadata, field.name)) for field in fields(metadata)) 15 | 16 | return render_to_string("cbv_inspect/toolbar.html", ctx_data) 17 | -------------------------------------------------------------------------------- /example/books/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjbitcode/django-cbv-inspect/fad3cc9db45f1ccd52f349db1b7a3a8a7475e2ad/example/books/__init__.py -------------------------------------------------------------------------------- /example/books/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Author, Book 4 | 5 | 6 | class BookInline(admin.TabularInline): 7 | model = Book 8 | 9 | 10 | class AuthorAdmin(admin.ModelAdmin): 11 | inlines = [ 12 | BookInline, 13 | ] 14 | 15 | 16 | admin.site.register(Author, AuthorAdmin) 17 | admin.site.register(Book) 18 | -------------------------------------------------------------------------------- /example/books/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BooksConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "books" 7 | -------------------------------------------------------------------------------- /example/books/fixtures/fake_data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "books.author", 4 | "pk": 1, 5 | "fields": { 6 | "first_name": "J.K.", 7 | "last_name": "Rowling" 8 | } 9 | }, 10 | { 11 | "model": "books.author", 12 | "pk": 2, 13 | "fields": { 14 | "first_name": "George R.R.", 15 | "last_name": "Martin" 16 | } 17 | }, 18 | { 19 | "model": "books.author", 20 | "pk": 3, 21 | "fields": { 22 | "first_name": "Roald", 23 | "last_name": "Dahl" 24 | } 25 | }, 26 | { 27 | "model": "books.book", 28 | "pk": 1, 29 | "fields": { 30 | "name": "Harry Potter and the Sorcerer's Stone", 31 | "author": 1, 32 | "published": "1997-06-27" 33 | } 34 | }, 35 | { 36 | "model": "books.book", 37 | "pk": 2, 38 | "fields": { 39 | "name": "Harry Potter and the Order of the Phoenix", 40 | "author": 1, 41 | "published": "2003-06-21" 42 | } 43 | }, 44 | { 45 | "model": "books.book", 46 | "pk": 3, 47 | "fields": { 48 | "name": "The Witches", 49 | "author": 3, 50 | "published": "1983-01-01" 51 | } 52 | }, 53 | { 54 | "model": "books.book", 55 | "pk": 4, 56 | "fields": { 57 | "name": "A Game of Thrones", 58 | "author": 2, 59 | "published": "1996-08-01" 60 | } 61 | }, 62 | { 63 | "model": "books.book", 64 | "pk": 2, 65 | "fields": { 66 | "name": "Harry Potter and the Chamber of Secrets", 67 | "author": 1, 68 | "published": "1998-07-02" 69 | } 70 | } 71 | ] 72 | -------------------------------------------------------------------------------- /example/books/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import ModelForm 2 | 3 | from .models import Author, Book 4 | 5 | 6 | class BookForm(ModelForm): 7 | class Meta: 8 | model = Book 9 | fields = ["name", "author", "published"] 10 | 11 | 12 | class AuthorForm(ModelForm): 13 | class Meta: 14 | model = Author 15 | fields = "__all__" 16 | -------------------------------------------------------------------------------- /example/books/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-10-30 21:21 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Author', 17 | fields=[ 18 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('first_name', models.CharField(max_length=50)), 20 | ('last_name', models.CharField(max_length=50)), 21 | ], 22 | ), 23 | migrations.CreateModel( 24 | name='Book', 25 | fields=[ 26 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 27 | ('name', models.CharField(max_length=100)), 28 | ('published', models.DateField()), 29 | ('isbn', models.CharField(blank=True, max_length=17)), 30 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='books.author')), 31 | ], 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /example/books/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjbitcode/django-cbv-inspect/fad3cc9db45f1ccd52f349db1b7a3a8a7475e2ad/example/books/migrations/__init__.py -------------------------------------------------------------------------------- /example/books/models.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | from django.db import models 5 | from django.db.models.signals import pre_save 6 | from django.urls import reverse 7 | 8 | 9 | class Book(models.Model): 10 | name = models.CharField(max_length=100) 11 | author = models.ForeignKey("Author", on_delete=models.CASCADE) 12 | published = models.DateField() 13 | isbn = models.CharField(max_length=17, blank=True) 14 | 15 | def __str__(self) -> str: 16 | return self.name 17 | 18 | def get_absolute_url(self): 19 | return reverse("books:book_detail", kwargs={"pk": self.pk}) 20 | 21 | 22 | class Author(models.Model): 23 | first_name = models.CharField(max_length=50) 24 | last_name = models.CharField(max_length=50) 25 | 26 | @property 27 | def name(self): 28 | return f"{self.first_name} {self.last_name}" 29 | 30 | def __str__(self) -> str: 31 | return self.name 32 | 33 | 34 | # Create a digit with of length x 35 | def d(x: int) -> str: 36 | return "".join(random.choice(string.digits) for _ in range(x)) 37 | 38 | 39 | def generate_isbn(sender, instance, *args, **kwargs): 40 | print(sender, instance) 41 | if not instance.isbn: 42 | instance.isbn = f"{d(3)}-{d(1)}-{d(2)}-{d(6)}-{d(1)}" 43 | 44 | 45 | pre_save.connect(generate_isbn, sender=Book) 46 | -------------------------------------------------------------------------------- /example/books/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | {% block title %}{% endblock %} 14 | {% block styles %} 15 | {% endblock %} 16 | 17 | 18 | 19 | 20 |
21 |
22 |
23 | 24 |

The Django Book Store ✨📗

25 |
26 |

A project to inspect CBV's

27 |
28 |
29 |
30 | 31 |
32 |
33 |
34 | {% block content %} 35 | {% endblock %} 36 |
37 |
38 |
39 | 40 | 41 | 42 | {% block scripts %} 43 | {% endblock %} 44 | 45 | -------------------------------------------------------------------------------- /example/books/templates/books/author_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}Author Create{% endblock %} 4 | 5 | {% block content %} 6 | 7 |

Create Author

8 | 9 |
10 | {% csrf_token %} 11 | 12 |
13 |
14 |
15 | 16 |
17 | 18 |
19 |
20 | 21 |
22 | 23 |
24 | 25 |
26 |
27 | 28 |
29 |
30 | 31 |
32 |
33 |
34 |
35 |
36 | 37 | 38 | {% endblock %} -------------------------------------------------------------------------------- /example/books/templates/books/book_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}Book List{% endblock %} 4 | 5 | {% block content %} 6 | 7 |
8 | {% csrf_token %} 9 |

Are you sure you want to delete "{{ object }}"?

10 | 11 |
12 |

13 | 14 |

15 |
16 | 17 |
18 | 19 | {% endblock %} 20 | 21 | 22 | -------------------------------------------------------------------------------- /example/books/templates/books/book_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}Book List{% endblock %} 4 | 5 | {% block content %} 6 | 7 |
8 |

{{ object.name }}

9 |

{{ object.author.name }}

10 |

{{ object.published }}

11 |

{{ object.isbn }}

12 |
13 | 14 |
15 |

16 | 17 | 18 | 19 |

20 |

21 | 22 | 23 | 24 |

25 |
26 | {% endblock %} -------------------------------------------------------------------------------- /example/books/templates/books/book_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}Book Create{% endblock %} 4 | 5 | {% block content %} 6 | 7 |

{% if object %}Update{% else %}Create{% endif %} Book

8 | 9 |
10 | {% csrf_token %} 11 |
12 |
13 |
14 | 15 |
16 | 18 |
19 |
20 | 21 |
22 | 23 |
24 | 26 |
27 |
28 | 29 |
30 | 31 |
32 |
33 | 43 |
44 |
45 | 46 |

or create new author

47 |
48 |
49 | 50 |
51 |
52 | {% if object %} 53 | 54 | {% else %} 55 | 56 | {% endif %} 57 |
58 |
59 |
60 |
61 |
62 | 63 | {% endblock %} -------------------------------------------------------------------------------- /example/books/templates/books/book_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}Book List{% endblock %} 4 | 5 | {% block content %} 6 | 7 |

Book List

8 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {% for book in object_list %} 26 | 27 | 32 | 33 | 34 | 35 | 40 | 45 | 46 | {% endfor %} 47 | 48 |
TitleAuthorDate PublishedISBN
28 | 29 | {{ book.name }} 30 | 31 | {{ book.author.name }}{{ book.published }}{{ book.isbn }} 36 | 37 | 38 | 39 | 41 | 42 | 43 | 44 |
49 | 50 | {% endblock %} -------------------------------------------------------------------------------- /example/books/templates/books/hello.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}Hello{% endblock %} 4 | 5 | {% block content %} 6 | 7 |

Hello 👋

8 | 9 | {% endblock %} -------------------------------------------------------------------------------- /example/books/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = "books" 6 | 7 | urlpatterns = [ 8 | path("", views.BookListView.as_view(), name="books"), 9 | path("/", views.BookDetailView.as_view(), name="book_detail"), 10 | path("edit//", views.BookUpdateView.as_view(), name="book_edit"), 11 | path("delete//", views.BookDeleteView.as_view(), name="book_delete"), 12 | path("new/", views.BookCreateView.as_view(), name="book_create"), 13 | path("authors/new/", views.AuthorCreateView.as_view(), name="author_create"), 14 | # random views 15 | path("hello_fbv/", views.hello_fbv, name="hello_fbv"), 16 | path("hello_cbv/", views.HelloCBV.as_view(), name="hello_cbv"), 17 | path("jsontest/", views.jsontest, name="jsontest"), 18 | path("gotobooks/", views.BookRedirect.as_view(), name="gotobooks"), 19 | ] 20 | -------------------------------------------------------------------------------- /example/books/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.http import HttpResponse, JsonResponse 4 | from django.shortcuts import render 5 | from django.urls import reverse_lazy 6 | from django.views.generic import ( 7 | CreateView, 8 | DeleteView, 9 | DetailView, 10 | ListView, 11 | RedirectView, 12 | UpdateView, 13 | View, 14 | ) 15 | 16 | from .forms import AuthorForm, BookForm 17 | from .models import Author, Book 18 | 19 | # from cbv_inspect.decorators import djcbv_exclude 20 | 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | class FooMixin: 26 | def test(self): 27 | return "Test from FooMixin" 28 | 29 | 30 | class CoffeeMixin: 31 | def greet(self): 32 | return "Hello from CoffeeMixin" 33 | 34 | 35 | # @method_decorator(djcbv_exclude, name='dispatch') # test excluding this view 36 | class BookListView(CoffeeMixin, FooMixin, ListView): 37 | model = Book 38 | 39 | def get_favorite_book(self): 40 | return "Harry Potter" 41 | 42 | def get_context_data(self, **kwargs): 43 | """a doctstring with super().omg()""" 44 | context = super().get_context_data(**kwargs) 45 | context["fav_book"] = self.get_favorite_book() 46 | 47 | super(CoffeeMixin, self).test() 48 | 49 | return context 50 | 51 | 52 | class BookRedirect(RedirectView): 53 | url = reverse_lazy("books:books") 54 | 55 | 56 | class BookDetailView(DetailView): 57 | model = Book 58 | 59 | 60 | class BookCreateView(CreateView): 61 | model = Book 62 | form_class = BookForm 63 | 64 | 65 | class BookUpdateView(UpdateView): 66 | model = Book 67 | form_class = BookForm 68 | 69 | def get_success_url(self) -> str: 70 | return reverse_lazy("books:book_detail", kwargs={"pk": self.object.pk}) 71 | 72 | 73 | class BookDeleteView(DeleteView): 74 | model = Book 75 | success_url = reverse_lazy("books:books") 76 | 77 | 78 | class AuthorCreateView(CreateView): 79 | model = Author 80 | form_class = AuthorForm 81 | success_url = reverse_lazy("books:books") 82 | 83 | 84 | class HelloCBV(View): 85 | def get(self, request, *args, **kwargs): 86 | return HttpResponse("hello from a CBV View!") 87 | 88 | 89 | def hello_fbv(request): 90 | return render(request, "books/hello.html", {}) 91 | 92 | 93 | def jsontest(request): 94 | return JsonResponse({"foo": "bar"}) 95 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | # allow app import outside of parent directory 7 | sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) 8 | 9 | 10 | def main(): 11 | """Run administrative tasks.""" 12 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 13 | try: 14 | from django.core.management import execute_from_command_line 15 | except ImportError as exc: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) from exc 21 | execute_from_command_line(sys.argv) 22 | 23 | 24 | if __name__ == "__main__": 25 | main() 26 | -------------------------------------------------------------------------------- /example/project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjbitcode/django-cbv-inspect/fad3cc9db45f1ccd52f349db1b7a3a8a7475e2ad/example/project/__init__.py -------------------------------------------------------------------------------- /example/project/asgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.asgi import get_asgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 6 | 7 | application = get_asgi_application() 8 | -------------------------------------------------------------------------------- /example/project/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | BASE_DIR = Path(__file__).resolve().parent.parent.parent 4 | 5 | SECRET_KEY = "django-insecure-w@4#vq$o=9)p-%55c5vgth5$x7nju4zvy88ub5uflbbn=3yppq" 6 | 7 | DEBUG = True 8 | 9 | ALLOWED_HOSTS = ["*"] 10 | 11 | INSTALLED_APPS = [ 12 | "django.contrib.admin", 13 | "django.contrib.auth", 14 | "django.contrib.contenttypes", 15 | "django.contrib.sessions", 16 | "django.contrib.messages", 17 | "django.contrib.staticfiles", 18 | # external apps 19 | "cbv_inspect", 20 | # internal apps 21 | "books", 22 | ] 23 | 24 | MIDDLEWARE = [ 25 | "django.middleware.security.SecurityMiddleware", 26 | "django.contrib.sessions.middleware.SessionMiddleware", 27 | "django.middleware.common.CommonMiddleware", 28 | "django.middleware.csrf.CsrfViewMiddleware", 29 | "django.contrib.auth.middleware.AuthenticationMiddleware", 30 | "django.contrib.messages.middleware.MessageMiddleware", 31 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 32 | "cbv_inspect.middleware.DjCbvInspectMiddleware", 33 | ] 34 | 35 | ROOT_URLCONF = "project.urls" 36 | 37 | TEMPLATES = [ 38 | { 39 | "BACKEND": "django.template.backends.django.DjangoTemplates", 40 | "DIRS": [BASE_DIR / "example" / "project" / "templates"], 41 | "APP_DIRS": True, 42 | "OPTIONS": { 43 | "context_processors": [ 44 | "django.template.context_processors.debug", 45 | "django.template.context_processors.request", 46 | "django.contrib.auth.context_processors.auth", 47 | "django.contrib.messages.context_processors.messages", 48 | ], 49 | }, 50 | }, 51 | ] 52 | 53 | WSGI_APPLICATION = "project.wsgi.application" 54 | 55 | DATABASES = { 56 | "default": { 57 | "ENGINE": "django.db.backends.sqlite3", 58 | "NAME": BASE_DIR / "example" / "db.sqlite3", 59 | } 60 | } 61 | 62 | AUTH_PASSWORD_VALIDATORS = [ 63 | { 64 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 65 | }, 66 | { 67 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 68 | }, 69 | { 70 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 71 | }, 72 | { 73 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 74 | }, 75 | ] 76 | 77 | LANGUAGE_CODE = "en-us" 78 | 79 | TIME_ZONE = "UTC" 80 | 81 | USE_I18N = True 82 | 83 | USE_L10N = True 84 | 85 | USE_TZ = True 86 | 87 | STATIC_URL = "/static/" 88 | 89 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 90 | 91 | 92 | LOGGING = { 93 | "version": 1, 94 | "disable_existing_loggers": False, 95 | "handlers": { 96 | "console": { 97 | "class": "logging.StreamHandler", 98 | }, 99 | }, 100 | "root": { 101 | "handlers": ["console"], 102 | "level": "DEBUG", 103 | }, 104 | } 105 | -------------------------------------------------------------------------------- /example/project/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | urlpatterns = [ 5 | path("admin/", admin.site.urls), 6 | path("", include("books.urls")), 7 | ] 8 | -------------------------------------------------------------------------------- /example/project/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | 6 | [project] 7 | name = "django-cbv-inspect" 8 | version = "0.2.1" 9 | description = "A tool to inspect Django class-based views." 10 | authors = [ 11 | { name="Sangeeta Jadoonanan", email="sjbitcode@gmail.com" } 12 | ] 13 | readme = "README.md" 14 | requires-python = ">=3.7" 15 | license = {text = "MIT"} 16 | keywords = [ 17 | "python", 18 | "django" 19 | ] 20 | classifiers = [ 21 | "Environment :: Web Environment", 22 | "Framework :: Django", 23 | "Framework :: Django :: 3.2", 24 | "Framework :: Django :: 4.0", 25 | "Framework :: Django :: 4.1", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Programming Language :: Python :: 3.7", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Intended Audience :: Developers", 34 | "Programming Language :: Python :: 3", 35 | "License :: OSI Approved :: MIT License", 36 | "Topic :: Software Development :: Libraries :: Python Modules", 37 | ] 38 | dependencies = [ 39 | "Django>=3.2" 40 | ] 41 | 42 | 43 | [project.urls] 44 | Homepage = "https://github.com/sjbitcode/django-cbv-inspect" 45 | Download = "https://pypi.org/project/django-cbv-inspect" 46 | 47 | 48 | [tool.setuptools] 49 | include-package-data = true 50 | packages = ["cbv_inspect", "cbv_inspect.templates.cbv_inspect"] 51 | 52 | 53 | [tool.coverage.run] 54 | branch = true 55 | source = ["cbv_inspect"] 56 | omit = [ 57 | "*/__init__.py", 58 | "*/apps.py", 59 | "*/settings.py" 60 | ] 61 | 62 | [tool.coverage.report] 63 | show_missing = true 64 | precision = 2 65 | fail_under = 100 66 | 67 | 68 | [tool.black] 69 | line-length = 100 70 | extend-exclude = '''.*/migrations/.*\.py''' 71 | 72 | 73 | [tool.isort] 74 | profile = "black" 75 | known_django = "django" 76 | known_first_party = ["cbv_inspect", "example", "tests"] 77 | sections = ["FUTURE", "STDLIB", "DJANGO", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] 78 | skip_glob = ["*/migrations/*"] 79 | 80 | 81 | [tool.pycln] 82 | all = true 83 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | black 2 | coverage 3 | flake8 4 | isort 5 | pycln 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjbitcode/django-cbv-inspect/fad3cc9db45f1ccd52f349db1b7a3a8a7475e2ad/tests/__init__.py -------------------------------------------------------------------------------- /tests/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BooksConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "tests" 7 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Book(models.Model): 5 | name = models.CharField(max_length=200) 6 | 7 | def __str__(self): 8 | return f"Book <{self.name}>" 9 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | BASE_DIR = Path(__file__).resolve().parent.parent.parent 4 | 5 | SECRET_KEY = "super-secret-key" 6 | 7 | DEBUG = True 8 | 9 | ALLOWED_HOSTS = [] 10 | 11 | INSTALLED_APPS = [ 12 | "django.contrib.admin", 13 | "django.contrib.auth", 14 | "django.contrib.contenttypes", 15 | "django.contrib.sessions", 16 | "django.contrib.messages", 17 | "django.contrib.staticfiles", 18 | # external apps 19 | "cbv_inspect", 20 | # internal apps 21 | "tests.apps.BooksConfig", 22 | ] 23 | 24 | MIDDLEWARE = [ 25 | "django.middleware.security.SecurityMiddleware", 26 | "django.contrib.sessions.middleware.SessionMiddleware", 27 | "django.middleware.common.CommonMiddleware", 28 | "django.middleware.csrf.CsrfViewMiddleware", 29 | "django.contrib.auth.middleware.AuthenticationMiddleware", 30 | "django.contrib.messages.middleware.MessageMiddleware", 31 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 32 | "cbv_inspect.middleware.DjCbvInspectMiddleware", 33 | ] 34 | 35 | ROOT_URLCONF = "tests.urls" 36 | 37 | TEMPLATES = [ 38 | { 39 | "BACKEND": "django.template.backends.django.DjangoTemplates", 40 | "DIRS": [BASE_DIR / "tests" / "templates"], 41 | "APP_DIRS": True, 42 | "OPTIONS": { 43 | "context_processors": [ 44 | "django.template.context_processors.debug", 45 | "django.template.context_processors.request", 46 | "django.contrib.auth.context_processors.auth", 47 | "django.contrib.messages.context_processors.messages", 48 | ], 49 | }, 50 | }, 51 | ] 52 | 53 | WSGI_APPLICATION = "project.wsgi.application" 54 | 55 | DATABASES = { 56 | "default": { 57 | "ENGINE": "django.db.backends.sqlite3", 58 | "NAME": BASE_DIR / "tests" / "db.sqlite3", 59 | } 60 | } 61 | 62 | AUTH_PASSWORD_VALIDATORS = [ 63 | { 64 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 65 | }, 66 | { 67 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 68 | }, 69 | { 70 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 71 | }, 72 | { 73 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 74 | }, 75 | ] 76 | 77 | LANGUAGE_CODE = "en-us" 78 | 79 | TIME_ZONE = "UTC" 80 | 81 | USE_I18N = True 82 | 83 | USE_L10N = True 84 | 85 | USE_TZ = True 86 | 87 | STATIC_URL = "/static/" 88 | 89 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 90 | -------------------------------------------------------------------------------- /tests/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} 5 | 6 | 7 | 8 | {% block content %}{% endblock %} 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Some random classes and functions used for unittests! 3 | """ 4 | 5 | from cbv_inspect import mixins 6 | 7 | 8 | class AncientFoo: 9 | def greet(self): 10 | return "Hello, from ancient foo!" 11 | 12 | def customize_greet(self, name): 13 | return f"Hello, {name}" 14 | 15 | def greet_in_spanish(self): 16 | return "Hola!" 17 | 18 | 19 | class Foo(AncientFoo): 20 | color = "red" 21 | 22 | def greet(self): 23 | super().greet() 24 | return "Hello, from foo!" 25 | 26 | def goodbye(self): 27 | return "Goodbye, from foo!" 28 | 29 | @classmethod 30 | def get_cls_color(cls): 31 | return cls.color 32 | 33 | @staticmethod 34 | def get_number(): 35 | return 4 36 | 37 | @property 38 | def uppercase_color(self): 39 | return self.color.upper() 40 | 41 | 42 | class MixinFoo: 43 | def some_random_method(self): 44 | return 1 45 | 46 | 47 | class FuturisticFoo(MixinFoo, Foo): 48 | def customize_greet(self, name): 49 | greeting = super().customize_greet(name) 50 | return f"{greeting}!!!" 51 | 52 | def excited_spanish_greet(self): 53 | greeting = super().greet_in_spanish() 54 | num_greetings = super().get_number() 55 | 56 | return (greeting * num_greetings).upper() 57 | 58 | def test(self): 59 | """this method will error if called!""" 60 | super().some_nonexistent_method() 61 | 62 | 63 | class DjFoo(mixins.DjCbvInspectMixin, FuturisticFoo): 64 | pass 65 | 66 | 67 | def sample_func(): 68 | """ 69 | Sample docstring! 70 | """ 71 | x = 1 # comment 1 72 | 73 | # comment 2 74 | 75 | return x 76 | 77 | 78 | def sample_func2(): 79 | return 1 80 | 81 | 82 | class AwesomeMixin: 83 | def do_some_django_thing(self, *args, **kwargs): 84 | pass 85 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, create_autospec, patch 2 | 3 | from django.http import HttpResponse 4 | from django.test import Client, RequestFactory, TestCase 5 | from django.test.utils import override_settings 6 | from django.urls import resolve 7 | 8 | from cbv_inspect.middleware import DjCbvInspectMiddleware 9 | from cbv_inspect.mixins import DjCbvInspectMixin 10 | 11 | 12 | class TestDjCBVInspectMiddleware(TestCase): 13 | """ 14 | Tests for the `DjCbvInspectMiddleware` middleware class. 15 | """ 16 | 17 | def setUp(self): 18 | self.mock_get_response = MagicMock() 19 | self.middleware = DjCbvInspectMiddleware(self.mock_get_response) 20 | self.request = RequestFactory().get("/simple_cbv_render") 21 | 22 | self.addCleanup(patch.stopall) 23 | 24 | @override_settings(DEBUG=False) 25 | def test_show_toolbar_reads_from_settings(self): 26 | """ 27 | Test that the `show_toolbar` method determines value based on settings.DEBUG. 28 | """ 29 | 30 | # Act 31 | should_show_toolbar = self.middleware.show_toolbar() 32 | 33 | # Assert 34 | self.assertFalse(should_show_toolbar) 35 | 36 | @patch("cbv_inspect.middleware.resolve") 37 | def test_view_excluded_check_is_true_when_attr_exists(self, mock_resolve): 38 | """ 39 | Test that the `is_view_excluded` method determines a view 40 | is excluded if the `djcbv_exclude` attr exists on the view function. 41 | """ 42 | 43 | # Arrange 44 | mock_resolve.return_value.func = MagicMock(djcbv_exclude=True) 45 | 46 | # Act 47 | is_excluded = self.middleware.is_view_excluded(self.request) 48 | 49 | # Assert 50 | self.assertTrue(is_excluded) 51 | 52 | @patch("cbv_inspect.middleware.resolve") 53 | def test_view_excluded_check_is_false_when_attr_does_not_exist(self, mock_resolve): 54 | """ 55 | Test that the `is_view_excluded` method determines a view is not excluded 56 | if the `djcbv_exclude` attr is not present on the view function. 57 | """ 58 | 59 | # Arrange 60 | mock_view_func = MagicMock() 61 | del mock_view_func.djcbv_exclude 62 | mock_resolve.return_value.func = mock_view_func 63 | 64 | # Act 65 | is_excluded = self.middleware.is_view_excluded(self.request) 66 | 67 | # Assert 68 | self.assertFalse(is_excluded) 69 | 70 | @patch("cbv_inspect.utils.is_cbv_request", new=MagicMock(return_value=False)) 71 | @patch("cbv_inspect.middleware.DjCbvToolbar.__init__", return_value=None) 72 | def test_middleware_does_not_process_request_for_fbv_request(self, mock_toolbar_init): 73 | """ 74 | Test that the `should_process_request` makes the middleware exit early 75 | for a function-based view. 76 | 77 | As a result, the DjCbvToolbar class should not be instantiated. 78 | """ 79 | 80 | # Arrange 81 | response = create_autospec(HttpResponse) 82 | self.mock_get_response.return_value = response 83 | 84 | # Act 85 | self.middleware(self.request) 86 | 87 | # Assert 88 | mock_toolbar_init.assert_not_called() 89 | 90 | @patch.object(DjCbvInspectMiddleware, "show_toolbar", new=MagicMock(return_value=False)) 91 | @patch("cbv_inspect.utils.is_cbv_request", new=MagicMock(return_value=True)) 92 | @patch("cbv_inspect.middleware.DjCbvToolbar.__init__", return_value=None) 93 | def test_middleware_does_not_process_request_when_show_toolbar_is_false( 94 | self, mock_toolbar_init 95 | ): 96 | """ 97 | Test that the `should_process_request` makes the middleware exit early 98 | when `show_toolbar` is False. 99 | 100 | As a result, the DjCbvToolbar class should not be instantiated. 101 | """ 102 | 103 | # Arrange 104 | response = create_autospec(HttpResponse) 105 | self.mock_get_response.return_value = response 106 | 107 | # Act 108 | self.middleware(self.request) 109 | 110 | # Assert 111 | mock_toolbar_init.assert_not_called() 112 | 113 | @patch.object(DjCbvInspectMiddleware, "show_toolbar", new=MagicMock(return_value=True)) 114 | @patch.object(DjCbvInspectMiddleware, "is_view_excluded", new=MagicMock(return_value=True)) 115 | @patch("cbv_inspect.utils.is_cbv_request", new=MagicMock(return_value=True)) 116 | @patch("cbv_inspect.middleware.DjCbvToolbar.__init__", return_value=None) 117 | def test_middleware_does_not_process_request_when_view_is_excluded(self, mock_toolbar_init): 118 | """ 119 | Test that the `should_process_request` makes the middleware exit early 120 | when a view is excluded. 121 | """ 122 | 123 | # Arrange 124 | response = create_autospec(HttpResponse) 125 | self.mock_get_response.return_value = response 126 | 127 | # Act 128 | self.middleware(self.request) 129 | 130 | # Assert 131 | mock_toolbar_init.assert_not_called() 132 | 133 | @patch.object(DjCbvInspectMiddleware, "show_toolbar", new=MagicMock(return_value=True)) 134 | @patch.object(DjCbvInspectMiddleware, "is_view_excluded", new=MagicMock(return_value=False)) 135 | @patch("cbv_inspect.utils.is_cbv_request", new=MagicMock(return_value=True)) 136 | @patch("cbv_inspect.middleware.DjCbvToolbar.__init__", return_value=None) 137 | def test_middleware_should_process_request_allows_cbv_view(self, mock_toolbar_init): 138 | """ 139 | Test that the `should_process_request` allows middleware to run fully. 140 | """ 141 | 142 | # Arrange 143 | response = create_autospec(HttpResponse) 144 | self.mock_get_response.return_value = response 145 | 146 | # Act 147 | self.middleware(self.request) 148 | 149 | # Assert 150 | mock_toolbar_init.assert_called_once() 151 | 152 | @patch.object( 153 | DjCbvInspectMiddleware, "should_process_request", new=MagicMock(return_value=True) 154 | ) 155 | @patch.object(DjCbvInspectMiddleware, "_remove_djcbv_mixin", new=MagicMock()) 156 | @patch.object( 157 | DjCbvInspectMiddleware, "_is_response_insertable", new=MagicMock(return_value=False) 158 | ) 159 | def test_middleware_exits_if_response_not_insertable(self): 160 | """ 161 | Test that the middleware does not append djCbv markup to 162 | a non-html response. 163 | """ 164 | 165 | # Arrange 166 | response = create_autospec(HttpResponse) 167 | response.content = b"foo" 168 | self.mock_get_response.return_value = response 169 | 170 | # Act 171 | res = self.middleware(self.request) 172 | 173 | # Assert 174 | self.assertEqual(res.content.decode(), "foo") 175 | 176 | @patch.object( 177 | DjCbvInspectMiddleware, "should_process_request", new=MagicMock(return_value=True) 178 | ) 179 | @patch.object(DjCbvInspectMiddleware, "_remove_djcbv_mixin", new=MagicMock()) 180 | @patch.object( 181 | DjCbvInspectMiddleware, "_is_response_insertable", new=MagicMock(return_value=True) 182 | ) 183 | def test_middleware_exits_for_malformed_html_response(self): 184 | """ 185 | Test that the middleware does not append djCbv markup to 186 | a malformed html response. 187 | """ 188 | 189 | # Arrange 190 | response = create_autospec(HttpResponse) 191 | response.charset = "utf-8" 192 | response.content = bytes("", response.charset) 193 | response.__contains__.return_value = True # "Content-Length" in response 194 | self.mock_get_response.return_value = response 195 | 196 | # Act 197 | res = self.middleware(self.request) 198 | 199 | # Assert 200 | self.assertEqual(res.content.decode(res.charset), "") 201 | 202 | @patch.object( 203 | DjCbvInspectMiddleware, "should_process_request", new=MagicMock(return_value=True) 204 | ) 205 | @patch.object(DjCbvInspectMiddleware, "_remove_djcbv_mixin", new=MagicMock()) 206 | @patch.object( 207 | DjCbvInspectMiddleware, "_is_response_insertable", new=MagicMock(return_value=True) 208 | ) 209 | def test_middleware_updates_content_length_header_if_exists(self): 210 | """ 211 | Test that the middleware appends djCbv markup and updates the 212 | Content-Length header if it exists. 213 | """ 214 | 215 | # Arrange 216 | html_content = "

test

" 217 | response = create_autospec(HttpResponse) 218 | response.charset = "utf-8" 219 | response.headers = {"Content-Length": len(html_content)} 220 | response.__contains__.side_effect = ( 221 | response.headers.__contains__ 222 | ) # "Content-Length" in response 223 | response.__setitem__.side_effect = ( 224 | response.headers.__setitem__ 225 | ) # response["Content-Length"] = 100 226 | response.__getitem__.side_effect = ( 227 | response.headers.__getitem__ 228 | ) # response["Content-Length"] 229 | response.content = bytes(html_content, response.charset) 230 | self.mock_get_response.return_value = response 231 | 232 | # Act 233 | res = self.middleware(self.request) 234 | 235 | # Assert 236 | self.assertTrue('id="djCbv"' in res.content) 237 | self.assertTrue(res["Content-Length"] > len(html_content)) 238 | 239 | @patch.object( 240 | DjCbvInspectMiddleware, "should_process_request", new=MagicMock(return_value=True) 241 | ) 242 | @patch.object(DjCbvInspectMiddleware, "_remove_djcbv_mixin", new=MagicMock()) 243 | @patch.object( 244 | DjCbvInspectMiddleware, "_is_response_insertable", new=MagicMock(return_value=True) 245 | ) 246 | def test_middleware_ignores_missing_content_length_header(self): 247 | """ 248 | Test that the middleware appends djCbv markup even if no 249 | Content-Length header exists. 250 | """ 251 | 252 | # Arrange 253 | html_content = "

test

" 254 | response = create_autospec(HttpResponse) 255 | response.charset = "utf-8" 256 | response.content = bytes(html_content, response.charset) 257 | response.__contains__.return_value = False # "Content-Length" in response 258 | self.mock_get_response.return_value = response 259 | 260 | # Act 261 | res = self.middleware(self.request) 262 | 263 | # Assert 264 | self.assertTrue('id="djCbv"' in res.content) 265 | 266 | @patch.object( 267 | DjCbvInspectMiddleware, "should_process_request", new=MagicMock(return_value=True) 268 | ) 269 | @patch.object(DjCbvInspectMiddleware, "_add_djcbv_mixin") 270 | def test_middleware_process_view_hook_appends_mixin(self, mock_add_mixin): 271 | """ 272 | Test that the `process_view` hook runs when `should_process_request` 273 | is True. 274 | 275 | Even if the middleware exits early in `__call__`, the `get_response()` call 276 | still triggers the `process_view` hook, hence the secondary check here. 277 | """ 278 | 279 | # Act 280 | self.middleware.process_view(self.request, MagicMock(), (), {}) 281 | 282 | # Assert 283 | mock_add_mixin.assert_called_once() 284 | 285 | @patch.object( 286 | DjCbvInspectMiddleware, "should_process_request", new=MagicMock(return_value=False) 287 | ) 288 | @patch.object(DjCbvInspectMiddleware, "_add_djcbv_mixin") 289 | def test_middleware_process_view_hook_does_not_append_mixin(self, mock_add_mixin): 290 | """ 291 | Test that the middleware `process_view` hook exits early when `should_process_request` 292 | is False. 293 | 294 | Even if the middleware exits early in `__call__`, the `get_response` calls which 295 | still triggers the `process_view` hook, hence the secondary check here. 296 | """ 297 | 298 | # Act 299 | self.middleware.process_view(self.request, MagicMock(), (), {}) 300 | 301 | # Assert 302 | mock_add_mixin.assert_not_called() 303 | 304 | 305 | @override_settings(DEBUG=True) 306 | class TestMiddlewareWithClient(TestCase): 307 | """ 308 | Client end-to-end request/response tests for the `DjCbvInspectMiddleware` middleware class. 309 | """ 310 | 311 | def test_client_request_for_fbv_returns_early(self): 312 | """ 313 | Test a function-based view to make sure the toolbar is not shown. 314 | """ 315 | 316 | # Arrange 317 | client = Client() 318 | 319 | # Act 320 | response = client.get("/simple_fbv_render") 321 | 322 | # Assert 323 | self.assertFalse('id="djCbv"' in response.content.decode(response.charset)) 324 | 325 | def test_client_request_for_cbv_returns_early_when_excluded_with_mixin(self): 326 | """ 327 | Test excluding a class-based view using the `DjCbvExcludeMixin` mixin class 328 | and asserting that the toolbar is not shown. 329 | """ 330 | 331 | # Arrange 332 | client = Client() 333 | 334 | # Act 335 | response = client.get("/djcbv_exclude_mixin") 336 | 337 | # Assert 338 | self.assertFalse('id="djCbv"' in response.content.decode(response.charset)) 339 | 340 | def test_client_request_for_cbv_returns_early_when_excluded_with_decorator(self): 341 | """ 342 | Test an excluded class-based view using the `djcbv_exclude` decorator function 343 | and asserting that the toolbar is not shown. 344 | """ 345 | 346 | # Arrange 347 | client = Client() 348 | 349 | # Act 350 | response = client.get("/djcbv_exclude_dec") 351 | 352 | # Assert 353 | self.assertFalse('id="djCbv"' in response.content.decode(response.charset)) 354 | 355 | def test_client_request_for_cbv_shows_toolbar(self): 356 | """ 357 | Test a class-based view request and assert that: 358 | - the djCbv markup is in the response content 359 | - the `DjCbvInspectMixin` class has been removed view class 360 | """ 361 | 362 | # Arrange 363 | client = Client() 364 | 365 | # Act 366 | response = client.get("/simple_cbv_render") 367 | 368 | # Assert 369 | self.assertTrue('id="djCbv"' in response.content.decode(response.charset)) 370 | self.assertFalse("get_greeting" in response.content.decode(response.charset)) 371 | bases = resolve(response._request.path).func.view_class.__bases__ 372 | self.assertTrue(DjCbvInspectMixin not in bases) 373 | -------------------------------------------------------------------------------- /tests/test_mixins.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | from django.test import RequestFactory, TestCase 4 | 5 | from cbv_inspect.mixins import DjCbvInspectMixin 6 | 7 | from . import views 8 | 9 | 10 | @patch("cbv_inspect.utils.serialize_params") 11 | @patch("cbv_inspect.utils.get_path") 12 | @patch("cbv_inspect.utils.get_super_calls") 13 | @patch("cbv_inspect.utils.get_ccbv_link") 14 | class TestDjCBVInspectMixin(TestCase): 15 | """ 16 | Tests for the `DjCbvInspectMixin` mixin class. 17 | """ 18 | 19 | def setUp(self): 20 | self.request = RequestFactory().get("/simple_cbv_render") 21 | 22 | # DjCBVInspectMixin only cares about the logs attr 23 | self.request._djcbv_inspect_metadata = Mock(logs={}) 24 | self.view_func = views.RenderHtmlView.as_view() 25 | self.view_func.view_class.__bases__ = ( 26 | DjCbvInspectMixin, 27 | *self.view_func.view_class.__bases__, 28 | ) 29 | 30 | @patch("cbv_inspect.utils.get_request") 31 | def test_mixin_runs_on_cbv_view( 32 | self, 33 | mock_utils_get_request, 34 | mock_utils_serialize_params, 35 | mock_utils_get_path, 36 | mock_utils_get_super_calls, 37 | mock_utils_get_ccbv_link, 38 | ): 39 | """ 40 | Test that the mixin runs and calls some util functions 41 | to capture log metadata. 42 | """ 43 | 44 | # Arrange 45 | mock_utils_get_request.return_value = self.request 46 | 47 | # Act 48 | response = self.view_func(self.request) 49 | original_request = response._request 50 | request_logs = original_request._djcbv_inspect_metadata.logs 51 | 52 | # Assert 53 | self.assertTrue(len(request_logs) > 0) 54 | mock_utils_get_request.assert_called() 55 | mock_utils_serialize_params.assert_called() 56 | mock_utils_get_path.assert_called() 57 | mock_utils_get_super_calls.assert_called() 58 | mock_utils_get_ccbv_link.assert_called() 59 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import unittest 3 | from collections import namedtuple 4 | from unittest.mock import MagicMock, Mock, create_autospec, patch 5 | 6 | from django import get_version 7 | from django.test import RequestFactory, TestCase 8 | from django.views.generic import TemplateView 9 | 10 | from cbv_inspect.mixins import DjCbvInspectMixin 11 | from cbv_inspect.utils import ( 12 | DjCbvClassOrMethodInfo, 13 | DjCbvException, 14 | DjCbvLog, 15 | class_has_method, 16 | collect_parent_classes, 17 | get_bases, 18 | get_callable_source, 19 | get_ccbv_link, 20 | get_mro, 21 | get_path, 22 | get_request, 23 | get_signature, 24 | get_sourcecode, 25 | get_super_calls, 26 | is_cbv_view, 27 | mask_queryset, 28 | mask_request, 29 | serialize_params, 30 | set_log_parents, 31 | ) 32 | 33 | from . import models, test_helpers, views 34 | 35 | SubTestArgs = namedtuple("SubTestArgs", "passed expected") 36 | 37 | 38 | class TestIsViewCbv(unittest.TestCase): 39 | """ 40 | Tests for the `is_cbv_view` util function. 41 | """ 42 | 43 | def test_is_view_cbv_returns_true_for_cbv_func(self): 44 | """ 45 | Test that the view function for a class-based view. 46 | """ 47 | 48 | # Arrange 49 | view_func = views.RenderHtmlView.as_view() 50 | 51 | # Act 52 | from_cbv = is_cbv_view(view_func) 53 | 54 | # Assert 55 | self.assertTrue(from_cbv) 56 | 57 | def test_is_view_cbv_returns_false_for_fbv(self): 58 | """ 59 | Test a function-based view function. 60 | """ 61 | 62 | # Arrange 63 | view_func = views.fbv_render 64 | 65 | # Act 66 | from_cbv = is_cbv_view(view_func) 67 | 68 | # Assert 69 | self.assertFalse(from_cbv) 70 | 71 | 72 | class TestCollectParentClasses(unittest.TestCase): 73 | """ 74 | Tests for the `collect_parent_classes` util function. 75 | """ 76 | 77 | def test_collect_parent_classes_returns_metadata_for_mro(self): 78 | """ 79 | Test that an mro lookup returns a list of `DjCbvClassOrMethodInfo` objects. 80 | """ 81 | 82 | # Arrange 83 | mro = test_helpers.FuturisticFoo.__mro__ 84 | mro_cls_metadata = [] 85 | 86 | for mro_cls in mro: 87 | mro_cls_metadata.append( 88 | DjCbvClassOrMethodInfo( 89 | ccbv_link=None, name=f"{mro_cls.__module__}.{mro_cls.__name__}", signature=None 90 | ) 91 | ) 92 | 93 | # Act 94 | cls_metadata = collect_parent_classes(test_helpers.FuturisticFoo, "__mro__") 95 | cls_metadata_from_mro = get_mro(test_helpers.FuturisticFoo) 96 | 97 | # Assert 98 | self.assertEqual(mro_cls_metadata, cls_metadata) 99 | self.assertEqual(mro_cls_metadata, cls_metadata_from_mro) 100 | 101 | def test_collect_parent_classes_returns_metadata_for_bases(self): 102 | """ 103 | Test that a bases class lookup returns a list of `DjCbvClassOrMethodInfo` objects. 104 | """ 105 | 106 | # Arrange 107 | bases = test_helpers.FuturisticFoo.__bases__ 108 | base_cls_metadata = [] 109 | 110 | for base_cls in bases: 111 | base_cls_metadata.append( 112 | DjCbvClassOrMethodInfo( 113 | ccbv_link=None, 114 | name=f"{base_cls.__module__}.{base_cls.__name__}", 115 | signature=None, 116 | ) 117 | ) 118 | 119 | # Act 120 | cls_metadata = collect_parent_classes(test_helpers.FuturisticFoo, "__bases__") 121 | cls_metadata_from_get_bases = get_bases(test_helpers.FuturisticFoo) 122 | 123 | # Assert 124 | self.assertEqual(base_cls_metadata, cls_metadata) 125 | self.assertEqual(base_cls_metadata, cls_metadata_from_get_bases) 126 | 127 | def test_collect_parent_classes_skips_djcbvinspectmixin(self): 128 | """ 129 | Test that the `DjCbvInspectMixin` class gets filtered out from result list. 130 | """ 131 | 132 | # Arrange 133 | bases = test_helpers.DjFoo.__bases__ 134 | base_cls_metadata = [] 135 | 136 | for base_cls in bases: 137 | if base_cls is not DjCbvInspectMixin: 138 | base_cls_metadata.append( 139 | DjCbvClassOrMethodInfo( 140 | ccbv_link=None, 141 | name=f"{base_cls.__module__}.{base_cls.__name__}", 142 | signature=None, 143 | ) 144 | ) 145 | 146 | # Act 147 | cls_metadata = collect_parent_classes(test_helpers.DjFoo, "__bases__") 148 | cls_metadata_from_get_bases = get_bases(test_helpers.DjFoo) 149 | 150 | # Assert 151 | self.assertEqual(base_cls_metadata, cls_metadata) 152 | self.assertEqual(base_cls_metadata, cls_metadata_from_get_bases) 153 | 154 | 155 | class TestGetCCBVLink(unittest.TestCase): 156 | """ 157 | Tests for the `get_ccbv_link` util function. 158 | """ 159 | 160 | def test_ccbv_link_returns_link_for_cbv_cls(self): 161 | """ 162 | Test that a ccbv link is generated for a Django generic view. 163 | """ 164 | 165 | # Arrange 166 | version: str = get_version().rsplit(".", 1)[0] 167 | module: str = TemplateView.__module__ 168 | expected_ccbv_url = ( 169 | f"https://ccbv.co.uk/projects/Django/{version}/{module}/{TemplateView.__name__}" 170 | ) 171 | 172 | # Act 173 | ccbv_url = get_ccbv_link(TemplateView) 174 | 175 | # Assert 176 | self.assertEqual(expected_ccbv_url, ccbv_url) 177 | 178 | def test_ccbv_link_returns_link_for_cbv_method(self): 179 | """ 180 | Test that a ccbv link is generated for a Django generic view method. 181 | """ 182 | 183 | # Arrange 184 | cbv_method = TemplateView.get_template_names 185 | 186 | version: str = get_version().rsplit(".", 1)[0] 187 | module: str = TemplateView.__module__ 188 | class_name, method_name = cbv_method.__qualname__.rsplit(".", 1) 189 | expected_ccbv_url = ( 190 | f"https://ccbv.co.uk/projects/Django/{version}/{module}/{class_name}/#{method_name}" 191 | ) 192 | 193 | # Act 194 | ccbv_url = get_ccbv_link(cbv_method) 195 | 196 | # Assert 197 | self.assertEqual(expected_ccbv_url, ccbv_url) 198 | 199 | @patch.object(inspect, "isclass", return_value=False) 200 | def test_ccbv_link_does_not_return_link_for_cbv_nonclass(self, _): 201 | """ 202 | Test that no ccbv link is generated for a nonclass or nonmethod object. 203 | """ 204 | 205 | # Arrange 206 | mock_obj = Mock() 207 | mock_obj.__module__ = TemplateView.__module__ 208 | 209 | # Act 210 | ccbv_url = get_ccbv_link(mock_obj) 211 | 212 | # Assert 213 | self.assertEqual(None, ccbv_url) 214 | 215 | def test_ccbv_link_does_not_return_link_for_local_cbv_cls(self): 216 | """ 217 | Test that no ccbv link is generated for a user-defined cbv class. 218 | """ 219 | 220 | # Act/Assert 221 | self.assertIsNone(get_ccbv_link(views.RenderHtmlView)) 222 | 223 | def test_ccbv_link_does_not_return_link_for_local_cbv_method(self): 224 | """ 225 | Test that no ccbv link is generated for an overriden cbv method. 226 | """ 227 | 228 | # Act/Assert 229 | self.assertIsNone(get_ccbv_link(views.RenderHtmlView.get_context_data)) 230 | 231 | 232 | @patch.object(inspect, "getfile") 233 | class TestGetPath(unittest.TestCase): 234 | """ 235 | Tests for the `get_path` util function. 236 | """ 237 | 238 | def test_get_path_for_dependency_returns_truncated_path(self, mock_getfile): 239 | """ 240 | Test path for an installed package. 241 | """ 242 | 243 | # Arrange 244 | mock_getfile.return_value = "some/path/site-packages/cool/cool_class.py" 245 | expected_return_path = "/cool/cool_class.py" 246 | 247 | # Act 248 | path = get_path(Mock()) 249 | 250 | # Assert 251 | self.assertEqual(expected_return_path, path) 252 | 253 | def test_get_path_for_internal_dependency_returns_full_path(self, mock_getfile): 254 | """ 255 | Test path for an internal module. 256 | """ 257 | 258 | # Arrange 259 | mock_getfile.return_value = "some/path/cool/cool_class.py" 260 | 261 | # Act 262 | path = get_path(Mock()) 263 | 264 | # Assert 265 | self.assertEqual(mock_getfile.return_value, path) 266 | 267 | 268 | class TestSerializeParams(unittest.TestCase): 269 | """ 270 | Tests for the `serialize_params` util function. 271 | """ 272 | 273 | @patch("cbv_inspect.utils.mask_request") 274 | @patch("cbv_inspect.utils.mask_queryset") 275 | @patch("cbv_inspect.utils.pformat") 276 | def test_serialize_params_calls_pformat_and_clean_functions( 277 | self, mock_pformat, mock_mask_queryset, mock_mask_req 278 | ): 279 | """ 280 | Test that formatting and clean functions run. 281 | """ 282 | 283 | # Act 284 | serialize_params({"name": "Foo"}) 285 | 286 | # Assert 287 | mock_pformat.assert_called_once() 288 | mock_mask_req.assert_called_once() 289 | mock_mask_queryset.assert_called_once() 290 | 291 | 292 | class TestMaskRequest(unittest.TestCase): 293 | """ 294 | Tests for the `mask_request` util function. 295 | """ 296 | 297 | def setUp(self): 298 | self.factory = RequestFactory() 299 | 300 | def test_mask_request_replaces_string_correctly(self): 301 | """ 302 | Test that only HttpRequest objects are masked. 303 | """ 304 | 305 | # Arrange 306 | expected = "<>" 307 | 308 | args = [ 309 | SubTestArgs(passed=str(self.factory.get("")), expected=expected), 310 | SubTestArgs(passed=str(self.factory.get("/foo")), expected=expected), 311 | SubTestArgs(passed=str((1, 2)), expected="(1, 2)"), 312 | SubTestArgs(passed=" ", expected=" "), 313 | SubTestArgs(passed="", expected=""), 314 | ] 315 | 316 | # Act/Assert 317 | for arg in args: 318 | with self.subTest(arg): 319 | masked = mask_request(arg.passed) 320 | self.assertEqual(arg.expected, masked) 321 | 322 | 323 | class TestMaskQueryset(TestCase): 324 | """ 325 | Tests for the `mask_queryset` util function. 326 | """ 327 | 328 | def setUp(self): 329 | self.model = models.Book 330 | self.model.objects.create(name="The Witches") 331 | self.model.objects.create(name="Harry Potter and the Chamber of Apps") 332 | 333 | def test_mask_queryset_replaces_string_correctly(self): 334 | """ 335 | Test that only QuerySet objects are masked. 336 | """ 337 | 338 | # Arrange 339 | expected = "<>" 340 | 341 | non_empty_qs = SubTestArgs(passed=str(self.model.objects.all()), expected=expected) 342 | self.model.objects.all().delete() 343 | empty_qs = SubTestArgs(passed=str(self.model.objects.all()), expected=expected) 344 | 345 | args = [ 346 | non_empty_qs, 347 | empty_qs, 348 | SubTestArgs(passed=str((1, 2)), expected="(1, 2)"), 349 | SubTestArgs(passed=" ", expected=" "), 350 | SubTestArgs(passed="", expected=""), 351 | ] 352 | 353 | # Act/Assert 354 | for arg in args: 355 | with self.subTest(arg): 356 | masked = mask_queryset(arg.passed) 357 | self.assertEqual(arg.expected, masked) 358 | 359 | 360 | class TestGetSignature(unittest.TestCase): 361 | """ 362 | Tests for the `get_signature` util function. 363 | """ 364 | 365 | def test_signature_for_bound_method(self): 366 | """ 367 | Test that the signature returned contains "self" for bound methods. 368 | """ 369 | # Arrange 370 | m = test_helpers.FuturisticFoo().customize_greet 371 | 372 | # Act 373 | sig = get_signature(m) 374 | 375 | # Assert 376 | self.assertEqual(str(inspect.signature(m)), "(name)") 377 | self.assertEqual(sig, "(self, name)") 378 | 379 | def test_signature_for_unbound_method(self): 380 | """ 381 | Test that the signature returned is expected for unbound methods. 382 | """ 383 | # Arrange 384 | f = test_helpers.FuturisticFoo.customize_greet 385 | 386 | # Act 387 | sig = get_signature(f) 388 | 389 | # Assert 390 | self.assertEqual(str(inspect.signature(f)), "(self, name)") 391 | self.assertEqual(sig, "(self, name)") 392 | 393 | 394 | class TestGetCallableSource(unittest.TestCase): 395 | """ 396 | Tests for the `get_callable_source` util function. 397 | """ 398 | 399 | def test_get_callable_source_for_method_returns_class(self): 400 | """ 401 | Test that the source of a method defined on a class returns that class. 402 | """ 403 | 404 | # Arrange 405 | foo_cls = test_helpers.Foo 406 | 407 | # Act 408 | source = get_callable_source(foo_cls.goodbye) 409 | 410 | # Assert 411 | self.assertEqual(source, foo_cls) 412 | 413 | def test_get_callable_source_for_inherited_method_returns_parent_class(self): 414 | """ 415 | Test that the source of an inherited method returns the base class that defines it. 416 | """ 417 | 418 | # Arrange 419 | foo_cls = test_helpers.Foo 420 | 421 | # Act 422 | source = get_callable_source(foo_cls.greet_in_spanish) 423 | 424 | # Assert 425 | self.assertNotEqual(source, foo_cls) 426 | 427 | def test_get_callable_source_for_overriden_method_returns_overriding_class(self): 428 | """ 429 | Test that the source of an overridden method returns the class that overrides it. 430 | """ 431 | 432 | # Arrange 433 | foo_cls = test_helpers.Foo 434 | 435 | # Act 436 | source = get_callable_source(foo_cls.greet) # Foo's superclass also defines greet 437 | 438 | # Assert 439 | self.assertEqual(source, foo_cls) 440 | 441 | def test_get_callable_source_for_function_returns_the_function(self): 442 | """ 443 | Test that the source of a function is the function itself. 444 | """ 445 | 446 | # Arrange 447 | func = test_helpers.sample_func2 448 | 449 | # Act 450 | source = get_callable_source(func) # Foo's superclass also defines greet 451 | 452 | # Assert 453 | self.assertEqual(source, func) 454 | 455 | 456 | class TestClassHasMethod(unittest.TestCase): 457 | """ 458 | Tests for the `class_has_method` util function. 459 | """ 460 | 461 | def test_class_has_method_returns_true_for_method(self): 462 | """ 463 | Test a class method. 464 | """ 465 | 466 | # Act/Assert 467 | self.assertTrue(class_has_method(test_helpers.Foo, "greet")) 468 | 469 | def test_class_has_method_returns_false_for_attribue(self): 470 | """ 471 | Test a class attribute. 472 | """ 473 | 474 | # Act/Assert 475 | self.assertFalse(class_has_method(test_helpers.Foo, "color")) 476 | 477 | def test_class_has_method_returns_false_for_nonexistent_method(self): 478 | """ 479 | Test an undefined method. 480 | """ 481 | 482 | # Act/Assert 483 | self.assertFalse(class_has_method(test_helpers.Foo, "some_method")) 484 | 485 | def test_class_has_method_returns_true_for_inherited_method(self): 486 | """ 487 | Test an inherited method. 488 | """ 489 | 490 | # Act/Assert 491 | self.assertTrue(class_has_method(test_helpers.FuturisticFoo, "greet")) 492 | 493 | def test_class_has_method_returns_true_for_classmethod(self): 494 | """ 495 | Test a classmethod. 496 | """ 497 | 498 | # Act/Assert 499 | self.assertTrue(class_has_method(test_helpers.FuturisticFoo, "get_cls_color")) 500 | 501 | def test_class_has_method_returns_true_for_staticmethod(self): 502 | """ 503 | Test a staticmethod. 504 | """ 505 | 506 | # Act/Assert 507 | self.assertTrue(class_has_method(test_helpers.FuturisticFoo, "get_number")) 508 | 509 | def test_class_has_method_returns_false_for_property(self): 510 | """ 511 | Test a property. 512 | """ 513 | 514 | # Act/Assert 515 | self.assertFalse(class_has_method(test_helpers.Foo, "uppercase_color")) 516 | 517 | 518 | class TestGetSourcecode(unittest.TestCase): 519 | """ 520 | Tests for the `get_sourcecode` util function. 521 | """ 522 | 523 | def test_get_sourcecode_strips_docstring(self): 524 | """ 525 | Test that docstring and comments are removed. 526 | """ 527 | 528 | # Act 529 | sourcecode = get_sourcecode(test_helpers.sample_func) 530 | 531 | # Assert 532 | self.assertFalse("Sample docstring" in sourcecode) 533 | self.assertFalse("comment" in sourcecode) 534 | self.assertFalse("#" in sourcecode) 535 | 536 | def test_get_sourcecode_for_func_witout_docstring_or_comments(self): 537 | """ 538 | Test with function that has no docstring or comments. 539 | """ 540 | 541 | # Act 542 | sourcecode = get_sourcecode(test_helpers.sample_func2) 543 | 544 | # Assert 545 | self.assertFalse("#" in sourcecode) 546 | self.assertEqual(sourcecode, inspect.getsource(test_helpers.sample_func2)) 547 | 548 | 549 | class TestGetSuperCalls(unittest.TestCase): 550 | """ 551 | Test the `get_super_calls` util function. 552 | """ 553 | 554 | def test_method_with_no_super_returns_none(self): 555 | """ 556 | Test a class method that has no super calls. 557 | """ 558 | 559 | # Arrange 560 | instance = test_helpers.Foo() # we need to pass in the bound method 561 | 562 | # Act 563 | super_calls = get_super_calls(instance.goodbye) 564 | 565 | # Assert 566 | self.assertIsNone(super_calls) 567 | 568 | def test_super_call_that_resolves_to_direct_parent(self): 569 | """ 570 | Test a class method with a super call that resolves to a base class method. 571 | """ 572 | 573 | # Arrange 574 | instance = test_helpers.Foo() 575 | expected_super_call = DjCbvClassOrMethodInfo( 576 | ccbv_link=None, 577 | name=test_helpers.AncientFoo.greet.__qualname__, 578 | signature=str(inspect.signature(test_helpers.AncientFoo.greet)), 579 | ) 580 | 581 | # Act 582 | super_calls = get_super_calls(instance.greet) 583 | 584 | # Assert 585 | self.assertEqual(1, len(super_calls)) 586 | self.assertEqual(expected_super_call, super_calls[0]) 587 | 588 | def test_super_call_that_resolves_to_ancestor(self): 589 | """ 590 | Test a class method with a super call that resolves to one of its mro classes 591 | (not a direct base class). 592 | """ 593 | 594 | # Arrange 595 | instance = test_helpers.FuturisticFoo() 596 | expected_super_call = DjCbvClassOrMethodInfo( 597 | ccbv_link=None, 598 | name=test_helpers.AncientFoo.customize_greet.__qualname__, 599 | signature=str(inspect.signature(test_helpers.AncientFoo.customize_greet)), 600 | ) 601 | 602 | # Act 603 | super_calls = get_super_calls(instance.customize_greet) 604 | 605 | # Assert 606 | self.assertEqual(1, len(super_calls)) 607 | self.assertEqual(expected_super_call, super_calls[0]) 608 | 609 | def test_super_call_that_does_not_resolve(self): 610 | """ 611 | Test a class method with a super call to a nonexistent method. 612 | """ 613 | 614 | # Arrange 615 | instance = test_helpers.FuturisticFoo() 616 | expected_super_call = {} 617 | 618 | # Act 619 | super_calls = get_super_calls(instance.test) 620 | 621 | # Assert 622 | self.assertEqual(1, len(super_calls)) 623 | self.assertEqual(expected_super_call, super_calls[0]) 624 | 625 | def test_super_call_for_different_method_than_calling_method(self): 626 | """ 627 | Test a class method with a multiple super calls. 628 | """ 629 | 630 | # Arrange 631 | instance = test_helpers.FuturisticFoo() 632 | expected_super_calls = [ 633 | DjCbvClassOrMethodInfo( 634 | ccbv_link=None, 635 | name=test_helpers.AncientFoo.greet_in_spanish.__qualname__, 636 | signature=str(inspect.signature(test_helpers.AncientFoo.greet_in_spanish)), 637 | ), 638 | DjCbvClassOrMethodInfo( 639 | ccbv_link=None, 640 | name=test_helpers.Foo.get_number.__qualname__, 641 | signature=str(inspect.signature(test_helpers.Foo.get_number)), 642 | ), 643 | ] 644 | 645 | # Act 646 | super_calls = get_super_calls(instance.excited_spanish_greet) 647 | 648 | # Assert 649 | self.assertEqual(2, len(super_calls)) 650 | self.assertEqual(expected_super_calls[0], super_calls[0]) 651 | self.assertEqual(expected_super_calls[1], super_calls[1]) 652 | 653 | 654 | class TestGetRequest(unittest.TestCase): 655 | """ 656 | Tests for the `get_request` util function. 657 | """ 658 | 659 | def test_get_request_from_setup(self): 660 | """ 661 | Test retrieving request from View.setup method. 662 | """ 663 | 664 | # Arrange 665 | request = RequestFactory().get("/") 666 | cbv_instance = create_autospec(views.HelloTest) 667 | cbv_method = MagicMock() 668 | cbv_method.__name__ = "setup" 669 | 670 | # Act 671 | returned_request = get_request(cbv_instance, cbv_method, request) 672 | 673 | # Assert 674 | self.assertEqual(request, returned_request) 675 | 676 | def test_get_request_from_setup_where_request_not_found(self): 677 | """ 678 | (edge case) This will probably never happen, but if the 679 | first argument of View.setup is not an HttpRequest object, 680 | then raise an exception. 681 | """ 682 | 683 | # Arrange 684 | cbv_instance = create_autospec(views.HelloTest) 685 | cbv_method = MagicMock() 686 | cbv_method.__name__ = "setup" 687 | 688 | # Act/Assert 689 | with self.assertRaises(DjCbvException): 690 | get_request(cbv_instance, cbv_method, Mock()) 691 | 692 | def test_get_request_from_instance(self): 693 | """ 694 | Test retrieving request from view instance, i.e. `self.request`. 695 | """ 696 | 697 | # Arrange 698 | request = RequestFactory().get("/") 699 | cbv_instance = create_autospec(views.HelloTest) 700 | cbv_instance.request = request 701 | cbv_method = MagicMock() 702 | cbv_method.__name__ = "dispatch" 703 | 704 | # Act 705 | returned_request = get_request(cbv_instance, cbv_method) 706 | 707 | # Assert 708 | self.assertEqual(request, returned_request) 709 | 710 | def test_get_request_returns_none_if_request_not_found(self): 711 | """ 712 | Test that None is returned if the request cannot be found 713 | on the view instance or view method. 714 | """ 715 | 716 | # Arrange 717 | cbv_instance = create_autospec(views.HelloTest) 718 | cbv_method = MagicMock() 719 | cbv_method.__name__ = "some_method" 720 | 721 | # Act 722 | returned_request = get_request(cbv_instance, cbv_method) 723 | 724 | # Assert 725 | self.assertIsNone(returned_request) 726 | 727 | 728 | class TestSetLogParents(unittest.TestCase): 729 | """ 730 | Tests for the `set_log_parents` util function. 731 | """ 732 | 733 | def setUp(self): 734 | self.request = MagicMock() 735 | self.request._djcbv_inspect_metadata.logs = {} 736 | 737 | def test_initial_log(self): 738 | """ 739 | Test that the first log has no parents and is not a parent. 740 | 741 | log (1) <--- current log 742 | """ 743 | 744 | # Arrange 745 | first_log = DjCbvLog(order=1, indent=0) 746 | self.request._djcbv_inspect_metadata.logs[1] = first_log 747 | 748 | # Act 749 | set_log_parents(1, self.request) 750 | 751 | # Assert 752 | self.assertFalse(first_log.is_parent) 753 | self.assertTrue(len(first_log.parent_list) == 0) 754 | 755 | def test_log_with_no_parent(self): 756 | """ 757 | Test log that has no parents (the prior log has same indent log). 758 | 759 | log (1) 760 | log (2) <--- current log 761 | """ 762 | 763 | # Arrange 764 | log_1 = DjCbvLog(order=1, indent=0) 765 | current_log_order = 2 766 | current_log = DjCbvLog(order=current_log_order, indent=0) 767 | self.request._djcbv_inspect_metadata.logs[1] = log_1 768 | self.request._djcbv_inspect_metadata.logs[2] = current_log 769 | 770 | # Act 771 | set_log_parents(current_log_order, self.request) 772 | 773 | # Assert 774 | self.assertFalse(current_log.is_parent) 775 | self.assertTrue(len(current_log.parent_list) == 0) 776 | 777 | def test_log_with_nested_parents(self): 778 | """ 779 | Test log that has multiple parents. 780 | 781 | log (1) 782 | ....log (2) 783 | ........log (3) <--- current log 784 | """ 785 | 786 | # Arrange 787 | log_1 = DjCbvLog(order=1, indent=0, is_parent=True) 788 | log_2 = DjCbvLog(order=2, indent=1, parent_list=["cbvInspect_1_0"]) 789 | current_log_order = 3 790 | current_log = DjCbvLog(order=current_log_order, indent=2) 791 | self.request._djcbv_inspect_metadata.logs[1] = log_1 792 | self.request._djcbv_inspect_metadata.logs[2] = log_2 793 | self.request._djcbv_inspect_metadata.logs[3] = current_log 794 | 795 | # Act 796 | set_log_parents(current_log_order, self.request) 797 | 798 | # Assert 799 | self.assertFalse(current_log.is_parent) 800 | self.assertTrue(log_2.is_parent) 801 | self.assertEqual(current_log.parent_list, ["cbvInspect_1_0", "cbvInspect_2_1"]) 802 | 803 | def test_log_with_ancestor_parent(self): 804 | """ 805 | Test log that doesn't have a direct parent. 806 | 807 | log (1) 808 | ....log (2) 809 | ........log (3) 810 | ....log (4) <--- current log 811 | """ 812 | 813 | # Arrange 814 | log_1 = DjCbvLog(order=1, indent=0, is_parent=True) 815 | log_2 = DjCbvLog(order=2, indent=1, is_parent=True, parent_list=["cbvInspect_1_0"]) 816 | log_3 = DjCbvLog(order=3, indent=2, parent_list=["cbvInspect_1_0", "cbvInspect_2_1"]) 817 | current_log_order = 4 818 | current_log = DjCbvLog(order=current_log_order, indent=1) 819 | self.request._djcbv_inspect_metadata.logs[1] = log_1 820 | self.request._djcbv_inspect_metadata.logs[2] = log_2 821 | self.request._djcbv_inspect_metadata.logs[3] = log_3 822 | self.request._djcbv_inspect_metadata.logs[4] = current_log 823 | 824 | # Act 825 | set_log_parents(current_log_order, self.request) 826 | 827 | # Assert 828 | self.assertFalse(current_log.is_parent) 829 | self.assertFalse(log_3.is_parent) 830 | self.assertEqual(current_log.parent_list, ["cbvInspect_1_0"]) 831 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls), 8 | path("simple_cbv_render", views.RenderHtmlView.as_view()), 9 | path("djcbv_exclude_mixin", views.ExcludedByMixin.as_view()), 10 | path("djcbv_exclude_dec", views.ExcludedByDecorator.as_view()), 11 | path("simple_fbv_render", views.fbv_render), 12 | path("hello_cbv", views.HelloTest.as_view()), 13 | ] 14 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.shortcuts import render 3 | from django.utils.decorators import method_decorator 4 | from django.views.generic import TemplateView, View 5 | 6 | from cbv_inspect.decorators import djcbv_exclude 7 | from cbv_inspect.mixins import DjCbvExcludeMixin 8 | 9 | 10 | class RenderHtmlView(TemplateView): 11 | template_name = "base.html" 12 | 13 | def __init__(self, *args, **kwargs): 14 | self.greeting = self.get_greeting() 15 | 16 | @staticmethod 17 | def get_greeting(): 18 | return "hello!" 19 | 20 | def get_context_data(self, **kwargs): 21 | context = super().get_context_data(**kwargs) 22 | context["title"] = "Render Html View" 23 | context["content"] = "Hello CBV!" 24 | return context 25 | 26 | 27 | class ExcludedByMixin(DjCbvExcludeMixin, TemplateView): 28 | template_name = "base.html" 29 | 30 | def get_context_data(self, **kwargs): 31 | context = super().get_context_data(**kwargs) 32 | context["title"] = "Render Html View" 33 | context["content"] = "Hello CBV!" 34 | return context 35 | 36 | 37 | @method_decorator(djcbv_exclude, name="dispatch") 38 | class ExcludedByDecorator(TemplateView): 39 | template_name = "base.html" 40 | 41 | def get_context_data(self, **kwargs): 42 | context = super().get_context_data(**kwargs) 43 | context["title"] = "Render Html View" 44 | context["content"] = "Hello CBV!" 45 | return context 46 | 47 | 48 | def fbv_render(request): 49 | template_name = "base.html" 50 | context = {"title": "FBV Render", "content": "Hello FBV!"} 51 | 52 | return render(request, template_name, context) 53 | 54 | 55 | class HelloTest(View): 56 | def get(self, request, *args, **kwargs): 57 | return HttpResponse("hello from a CBV View!") 58 | --------------------------------------------------------------------------------