├── .flake8 ├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE │ ├── feature-request.yml │ └── issue.yml ├── SECURITY.md └── workflows │ └── build.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.rst ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── decorator_include.py ├── runtests.sh ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── included.py ├── included2.py ├── settings.py ├── tests.py └── urls.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 119 3 | exclude = 4 | .git, 5 | __pycache__, 6 | .venv, 7 | build 8 | ignore = E101,E111,E114,E115,E116,E117,E12,E13,E2,E3,E401,E5,E70,W1,W2,W3,W5 9 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | This project follows [Django's Code of Conduct](https://www.djangoproject.com/conduct/). -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Request an enhancement or new feature. 3 | body: 4 | - type: input 5 | id: linked_issue 6 | attributes: 7 | label: Is your proposal related to a problem/issue? 8 | description: Please reference any issues linked to this request 9 | validations: 10 | required: false 11 | - type: textarea 12 | id: description 13 | attributes: 14 | label: Description 15 | description: Please describe your feature request with appropriate detail. 16 | validations: 17 | required: true 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.yml: -------------------------------------------------------------------------------- 1 | name: Issue 2 | description: File an issue 3 | body: 4 | - type: input 5 | id: python_version 6 | attributes: 7 | label: Python Version 8 | description: Which version of Python were you using? 9 | placeholder: 3.8.0 10 | validations: 11 | required: false 12 | - type: input 13 | id: django_version 14 | attributes: 15 | label: Django Version 16 | description: Which version of Django were you using? 17 | placeholder: 4.2.0 18 | validations: 19 | required: false 20 | - type: input 21 | id: package_version 22 | attributes: 23 | label: Package Version 24 | description: Which version of this package were you using? If not the latest version, please check this issue has not since been resolved. 25 | placeholder: 3.1.0 26 | validations: 27 | required: false 28 | - type: textarea 29 | id: description 30 | attributes: 31 | label: Description 32 | description: Please describe your issue. 33 | validations: 34 | required: true -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | Please report security issues directly over email to steve@mapestech.co.uk -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | - pull_request 5 | - push 6 | 7 | jobs: 8 | build: 9 | name: ${{ matrix.toxenv }} 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - python: 3.8 17 | toxenv: py38-django22 18 | - python: 3.9 19 | toxenv: py39-django22 20 | - python: 3.8 21 | toxenv: py38-django30 22 | - python: 3.9 23 | toxenv: py39-django30 24 | - python: 3.8 25 | toxenv: py38-django31 26 | - python: 3.9 27 | toxenv: py39-django31 28 | - python: 3.8 29 | toxenv: py38-django32 30 | - python: 3.9 31 | toxenv: py39-django32 32 | - python: "3.10" 33 | toxenv: py310-django32 34 | - python: 3.8 35 | toxenv: py38-django40 36 | - python: 3.8 37 | toxenv: py38-django40 38 | - python: "3.10" 39 | toxenv: py310-django40 40 | - python: 3.8 41 | toxenv: py38-django41 42 | - python: 3.9 43 | toxenv: py39-django41 44 | - python: "3.10" 45 | toxenv: py310-django41 46 | - python: "3.11" 47 | toxenv: py311-django41 48 | - python: "3.10" 49 | toxenv: py310-django42 50 | - python: "3.10" 51 | toxenv: py310-django50 52 | - python: "3.10" 53 | toxenv: py310-django51 54 | - python: "3.10" 55 | toxenv: py310-django52 56 | - python: "3.11" 57 | toxenv: py311-django42 58 | - python: "3.11" 59 | toxenv: py311-django50 60 | - python: "3.11" 61 | toxenv: py311-django51 62 | - python: "3.11" 63 | toxenv: py311-django52 64 | - python: "3.12" 65 | toxenv: py312-django42 66 | - python: "3.12" 67 | toxenv: py312-django50 68 | - python: "3.12" 69 | toxenv: py312-django51 70 | - python: "3.12" 71 | toxenv: py312-django52 72 | - python: "3.13" 73 | toxenv: py313-django50 74 | - python: "3.13" 75 | toxenv: py313-django51 76 | - python: "3.13" 77 | toxenv: py313-django52 78 | - python: "3.14.0-alpha.1" 79 | toxenv: py314-django51 80 | - python: "3.14.0-alpha.1" 81 | toxenv: py314-django52 82 | 83 | steps: 84 | - uses: actions/checkout@v4 85 | 86 | - name: Set up Python 87 | uses: actions/setup-python@v5 88 | with: 89 | python-version: ${{ matrix.python }} 90 | 91 | - name: Install tox 92 | run: python -m pip install tox 93 | 94 | - name: Run tox 95 | run: tox -e ${{ matrix.toxenv }} 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.py[co] 3 | *.sw[po] 4 | *~ 5 | .DS_Store 6 | .tox/ 7 | __pycache__/ 8 | build/ 9 | dist/ 10 | 11 | .idea 12 | .venv -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/PyCQA/flake8 3 | rev: 7.1.0 4 | hooks: 5 | - id: flake8 6 | - repo: https://github.com/psf/black 7 | rev: 24.10.0 8 | hooks: 9 | - id: black 10 | - repo: https://github.com/pycqa/isort 11 | rev: 5.13.2 12 | hooks: 13 | - id: isort 14 | args: ["--profile", "black", "--filter-files"] -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ``decorator_include`` was written by Jeff Kistler in 2011. 2 | 3 | Adapted for Python 3 and Django 1.6+ by Stéphane "Twidi" Angel 4 | Adapted for Python 3.10 and Django 4+ by Steven Mapes 5 | 6 | Contributors 7 | ------------ 8 | 9 | * Jeff Kistler 10 | * Stéphane "Twidi" Angel 11 | * Steve Mapes 12 | * Jon Dufresne 13 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | Release *v3.3* - ``2025-01-16`` 5 | ------------------------------- 6 | * Updated tox test runner to include Django 5.2 7 | 8 | Release *v3.2* - ``2024-10-21`` 9 | ------------------------------- 10 | * Updated tox test runner to include Python 3.14 and Django 5.1 11 | 12 | Release *v3.1* - ``2024-10-18`` 13 | ------------------------------- 14 | * Updated to include support for Python < 3.14 and Django <5.2 15 | * Updated to use Black along with Flake8 and iSort 16 | 17 | Release *v3.0* - ``2020-05-07`` 18 | ------------------------------- 19 | * Handle namespace/app_name the same way as Django `include` function 20 | * Ensure parameterized decorators are accepted 21 | * Added support for Django 2.2 and 3.0 22 | * Added support for Python 3.8 23 | 24 | Release *v2.1* - ``2018-11-23`` 25 | ------------------------------- 26 | * Added support for Django 2.1 and Python 3.7. 27 | * Fixed ImportError when installed on environments with an old version of 28 | setuptools. 29 | 30 | Release *v2.0* - ``2018-01-26`` 31 | ------------------------------- 32 | * Removed support for Python 2 33 | * Removed support for Django < 2 34 | 35 | Release *v1.4* - ``2018-01-25`` 36 | ------------------------------- 37 | * Removed support for Python 3.2 and 3.3. 38 | * Removed support for Django < 1.11. 39 | * Added support for Django 2.0. 40 | * Added ``tox`` for tests matrix 41 | * Configured setup using ``setup.cfg`` 42 | 43 | Release *v1.3* - ``2017-05-16`` 44 | ----------------------------- 45 | * Removed support for Django < 1.8 and Python 2.6. 46 | * Added support for Django 1.11 and Python 3.6. 47 | * Added support for passing a 2-tuple to ``decorator_include()`` as allowed by 48 | Django's ``include()``. 49 | 50 | Release *v1.2* - ``2016-12-28`` 51 | --------------------------------- 52 | * Official support for Django 1.10. 53 | 54 | Release *v1.1* - ``2016-12-15`` 55 | ------------------------------- 56 | * Stop importing module in ``__init__``. 57 | 58 | Release *v1.0* - ``2016-03-13`` 59 | --------------------------------- 60 | * First official release, adding support for Django 1.9. 61 | 62 | Release *v0.2* - ``2014-11-09`` 63 | --------------------------------- 64 | * Support for Python 3 and Django 1.6+. 65 | 66 | Release *v0.1* - ``2014-03-18`` 67 | --------------------------------- 68 | * Initial version by Jeff Kistler (date: 2011-06-07). 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2016, Jeff Kistler 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-decorator-include 2 | ======================== 3 | 4 | Include Django URL patterns with decorators. 5 | 6 | Maintained by Steve Mapes, Stéphane "Twidi" Angel, and Jon Dufresne on 7 | https://github.com/twidi/django-decorator-include 8 | based on the original work from Jeff Kistler on 9 | https://github.com/jeffkistler/django-decorator-include. 10 | 11 | .. image:: https://img.shields.io/pypi/v/django-decorator-include.svg 12 | :target: https://pypi.org/project/django-decorator-include/ 13 | 14 | .. image:: https://github.com/twidi/django-decorator-include/workflows/build/badge.svg 15 | :target: https://github.com/twidi/django-decorator-include/actions?query=workflow%3Abuild 16 | 17 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 18 | :target: https://github.com/twidi/django-decorator-include 19 | 20 | .. image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white 21 | :target: https://github.com/twidi/django-decorator-include 22 | 23 | Installation 24 | ------------ 25 | 26 | Assuming you have pip installed, run the following command to install from 27 | PyPI:: 28 | 29 | pip install django-decorator-include 30 | 31 | Usage 32 | ----- 33 | 34 | ``decorator_include`` is intended for use in URL confs as a replacement for the 35 | ``django.conf.urls.include`` function. It works in almost the same way as 36 | ``include`` however the first argument should be either a decorator or an 37 | iterable of decorators to apply to all included views (if an iterable, the order of the 38 | decorators is the order in which the functions will be applied on the views). 39 | Here is an example URL conf 40 | 41 | .. code-block:: python 42 | 43 | from django.contrib import admin 44 | from django.core.exceptions import PermissionDenied 45 | from django.urls import path 46 | from django.contrib.auth.decorators import login_required, user_passes_test 47 | 48 | from decorator_include import decorator_include 49 | 50 | from mysite.views import index 51 | 52 | def only_user(username): 53 | def check(user): 54 | if user.is_authenticated and user.username == username: 55 | return True 56 | raise PermissionDenied 57 | return user_passes_test(check) 58 | 59 | urlpatterns = [ 60 | path('', views.index, name='index'), 61 | # will redirect to login page if not authenticated 62 | path('secret/', decorator_include(login_required, 'mysite.secret.urls')), 63 | # will redirect to login page if not authenticated 64 | # will return a 403 http error if the user does not have the "god" username 65 | path('admin/', decorator_include([login_required, only_user('god')], admin.site.urls), 66 | ] 67 | 68 | Supported versions 69 | ------------------ 70 | 71 | =============== ======================== 72 | Django versions Python versions 73 | =============== ======================== 74 | 2.2 3.6, 3.7, 3.8, 3.9 75 | 3.0 3.6, 3.7, 3.8, 3.9 76 | 3.1 3.6, 3.7, 3.8, 3.9 77 | 3.2 3.6, 3.7, 3.8, 3.9, 3.10 78 | 4.0 3.8, 3.9, 3.10 79 | 4.1 3.8, 3.9, 3.10, 3.11 80 | 4.2 3.8, 3.9, 3.10, 3.11, 3.12 81 | 5.0 3.10, 3.11, 3.12, 3.13 82 | 5.1 3.10, 3.11, 3.12, 3.13, 3.14 83 | 5.2 3.10, 3.11, 3.12, 3.13, 3.14 84 | 85 | =============== ======================== 86 | 87 | * Python 3.11 only works with Django 4.1.3+ 88 | 89 | All library versions to use for old Django/Python support 90 | --------------------------------------------------------- 91 | 92 | =============== =============================== ================== 93 | Django versions Python versions Library versions 94 | =============== =============================== ================== 95 | 1.4, 1.5 2.6, 2.7 1.2 96 | 1.6 2.6, 2.7, 3.2, 3.3 1.2 97 | 1.7 2.7, 3.2, 3.3, 3.4 1.2 98 | 1.8 2.7, 3.2, 3.3, 3.4, 3.5 1.3 99 | 1.9, 1.10 2.7, 3.4, 3.5 1.3 100 | 1.11 2.7, 3.4, 3.5, 3.6 1.4.x (>=1.4.1,<2) 101 | 2.0 3.4, 3.5, 3.6, 3.7 3.0 102 | 2.1 3.5, 3.6, 3.7 3.0 103 | 2.2 3.5, 3.6, 3.7, 3.8, 3.9 3.0 104 | 3.0 3.6, 3.7, 3.8, 3.9 3.0 105 | 3.1 3.6, 3.7, 3.8, 3.9 3.0 106 | 3.2 3.6, 3.7, 3.8, 3.9, 3.10 3.0 107 | 4.0 3.8, 3.9, 3.10 3.1 108 | 4.1 3.8, 3.9, 3.10 3.1 109 | 4.2 3.10, 3.11, 3.12 3.1 110 | 4.2 3.10, 3.11, 3.12 3.1 111 | 5.0 3.10, 3.11, 3.12, 3.13 3.1 112 | 5.1 3.10, 3.11, 3.12, 3.13, 3.14 3.1, 3.2+ 113 | 5.2 3.10, 3.11, 3.12, 3.13, 3.14 3.3+ 114 | =============== =============================== ================== 115 | 116 | * Python 3.14 flagged as supported added in 3.2 117 | 118 | Development 119 | ----------- 120 | 121 | Make sure you are in a virtualenv on a valid python version. 122 | 123 | Grab the sources from Github:: 124 | 125 | git clone -b develop https://github.com/twidi/django-decorator-include.git 126 | 127 | 128 | Then go into the newly created ``django-decorator-include`` directory and install 129 | the package in editable mode:: 130 | 131 | pip install -e . 132 | 133 | 134 | To run the tests, this library provides a test project, so you can launch 135 | them this way:: 136 | 137 | django-admin test --settings=tests.settings tests 138 | 139 | Or simply launch the ``runtests.sh`` script (it will run this exact command):: 140 | 141 | ./runtests.sh 142 | 143 | This project uses `pre-commit`_ to automatically run `black`_ , `flake8`_ and `isort`_ on 144 | every commit. If you haven't already, first install pre-commit using the 145 | project's documentation. Then, to enable pre-commit for 146 | django-decorator-include:: 147 | 148 | pre-commit install 149 | 150 | After that, the next commit will run the tools on changed files. If you want to 151 | run the pre-commit hooks on all files, use:: 152 | 153 | pre-commit run --all-files 154 | 155 | The above command is also available as a tox environment:: 156 | 157 | tox -e lint 158 | 159 | Base your work on the ``develop`` branch. Iit should be the default branch on 160 | git assuming you used the ``-b develop`` argument on the ``git clone`` 161 | command as shown above. 162 | 163 | When creating the pull request, ensure you are using the correct base 164 | (twidi/django-decorator-include on develop). 165 | 166 | .. _pre-commit: https://pre-commit.com/ 167 | .. _flake8: https://flake8.pycqa.org/ 168 | .. _isort: https://pycqa.github.io/isort/ 169 | .. _black: https://github.com/psf/black/ 170 | 171 | -------------------------------------------------------------------------------- /decorator_include.py: -------------------------------------------------------------------------------- 1 | """ 2 | A replacement for ``django.conf.urls.include`` that takes a decorator, 3 | or an iterable of view decorators as the first argument and applies them, in 4 | reverse order, to all views in the included urlconf. 5 | """ 6 | 7 | from importlib import import_module 8 | 9 | from django.urls import URLPattern, URLResolver, include 10 | from django.utils.functional import cached_property 11 | 12 | 13 | def _extract_version(package_name): 14 | try: 15 | import importlib.metadata as importlib_metadata 16 | except ImportError: # for python < 3.8 17 | import importlib_metadata 18 | version = importlib_metadata.version(package_name) 19 | 20 | return tuple(int(part) for part in version.split(".") if part.isnumeric()) 21 | 22 | 23 | VERSION = _extract_version("django_decorator_include") 24 | 25 | 26 | class DecoratedPatterns(object): 27 | """ 28 | A wrapper for an urlconf that applies a decorator to all its views. 29 | """ 30 | 31 | def __init__(self, urlconf_module, decorators): 32 | # ``urlconf_module`` may be: 33 | # - an object with an ``urlpatterns`` attribute 34 | # - an ``urlpatterns`` itself 35 | # - the dotted Python path to a module with an ``urlpatters`` attribute 36 | self.urlconf = urlconf_module 37 | try: 38 | iter(decorators) 39 | except TypeError: 40 | decorators = [decorators] 41 | self.decorators = decorators 42 | 43 | def decorate_pattern(self, pattern): 44 | if isinstance(pattern, URLResolver): 45 | decorated = URLResolver( 46 | pattern.pattern, 47 | DecoratedPatterns(pattern.urlconf_module, self.decorators), 48 | pattern.default_kwargs, 49 | pattern.app_name, 50 | pattern.namespace, 51 | ) 52 | else: 53 | callback = pattern.callback 54 | for decorator in reversed(self.decorators): 55 | callback = decorator(callback) 56 | decorated = URLPattern( 57 | pattern.pattern, 58 | callback, 59 | pattern.default_args, 60 | pattern.name, 61 | ) 62 | return decorated 63 | 64 | @cached_property 65 | def urlpatterns(self): 66 | # urlconf_module might be a valid set of patterns, so we default to it. 67 | patterns = getattr(self.urlconf_module, "urlpatterns", self.urlconf_module) 68 | return [self.decorate_pattern(pattern) for pattern in patterns] 69 | 70 | @cached_property 71 | def urlconf_module(self): 72 | if isinstance(self.urlconf, str): 73 | return import_module(self.urlconf) 74 | else: 75 | return self.urlconf 76 | 77 | @cached_property 78 | def app_name(self): 79 | return getattr(self.urlconf_module, "app_name", None) 80 | 81 | 82 | def decorator_include(decorators, arg, namespace=None): 83 | """ 84 | Works like ``django.conf.urls.include`` but takes a view decorator 85 | or an iterable of view decorators as the first argument and applies them, 86 | in reverse order, to all views in the included urlconf. 87 | """ 88 | if isinstance(arg, tuple) and len(arg) == 3 and not isinstance(arg[0], str): 89 | # Special case where the function is used for something like `admin.site.urls`, which 90 | # returns a tuple with the object containing the urls, the app name, and the namespace 91 | # `include` does not support this pattern (you pass directly `admin.site.urls`, without 92 | # using `include`) but we have to 93 | urlconf_module, app_name, namespace = arg 94 | else: 95 | urlconf_module, app_name, namespace = include(arg, namespace=namespace) 96 | return DecoratedPatterns(urlconf_module, decorators), app_name, namespace 97 | -------------------------------------------------------------------------------- /runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | python -m django test --settings=tests.settings tests 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-decorator-include 3 | version = 3.3 4 | author = Jeff Kistler 5 | author_email = jeff@jeffkistler.com 6 | url = https://github.com/twidi/django-decorator-include 7 | description = Include Django URL patterns with decorators 8 | long_description = file: README.rst 9 | license = BSD 10 | license_file = LICENSE 11 | keywords = django, urls 12 | classifiers = 13 | Development Status :: 5 - Production/Stable 14 | Framework :: Django 15 | Framework :: Django :: 2.2 16 | Framework :: Django :: 3.0 17 | Framework :: Django :: 3.1 18 | Framework :: Django :: 3.2 19 | Framework :: Django :: 4.0 20 | Framework :: Django :: 4.1 21 | Framework :: Django :: 4.2 22 | Framework :: Django :: 5.0 23 | Framework :: Django :: 5.1 24 | Framework :: Django :: 5.2 25 | Intended Audience :: Developers 26 | License :: OSI Approved :: BSD License 27 | Operating System :: OS Independent 28 | Programming Language :: Python 29 | Programming Language :: Python :: 3 30 | Programming Language :: Python :: 3 :: Only 31 | Programming Language :: Python :: 3.6 32 | Programming Language :: Python :: 3.7 33 | Programming Language :: Python :: 3.8 34 | Programming Language :: Python :: 3.9 35 | Programming Language :: Python :: 3.10 36 | Programming Language :: Python :: 3.11 37 | Programming Language :: Python :: 3.12 38 | Programming Language :: Python :: 3.13 39 | Programming Language :: Python :: 3.14 40 | Topic :: Internet :: WWW/HTTP 41 | 42 | [options] 43 | zip_safe = True 44 | py_modules = decorator_include 45 | install_requires = 46 | Django>=2.2 47 | importlib_metadata; python_version<"3.8" 48 | python_requires = >=3.6 49 | 50 | [flake8] 51 | max-line-length = 119 52 | 53 | [isort] 54 | profile = django 55 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | setup() 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twidi/django-decorator-include/5f6a3ec699c945bb8b1b3569f4bb2b65eb8f11b9/tests/__init__.py -------------------------------------------------------------------------------- /tests/included.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.urls import include, path 3 | 4 | 5 | def testify(request): 6 | return HttpResponse("testify!") 7 | 8 | 9 | urlpatterns = [ 10 | path("included/", include("tests.included2")), 11 | path("test/", testify, name="testify"), 12 | ] 13 | -------------------------------------------------------------------------------- /tests/included2.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.urls import path 3 | 4 | 5 | def deeply_nested(request): 6 | return HttpResponse("deeply nested!") 7 | 8 | 9 | urlpatterns = [ 10 | path("deeply_nested/", deeply_nested, name="deeply_nested"), 11 | ] 12 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" 2 | 3 | INSTALLED_APPS = [ 4 | "django.contrib.admin", 5 | "django.contrib.auth", 6 | "django.contrib.contenttypes", 7 | "django.contrib.sessions", 8 | "django.contrib.messages", 9 | "django.contrib.staticfiles", 10 | "tests", 11 | ] 12 | 13 | MIDDLEWARE = [ 14 | "django.middleware.security.SecurityMiddleware", 15 | "django.contrib.sessions.middleware.SessionMiddleware", 16 | "django.middleware.common.CommonMiddleware", 17 | "django.middleware.csrf.CsrfViewMiddleware", 18 | "django.contrib.auth.middleware.AuthenticationMiddleware", 19 | "django.contrib.messages.middleware.MessageMiddleware", 20 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 21 | ] 22 | 23 | ROOT_URLCONF = "tests.urls" 24 | 25 | TEMPLATES = [ 26 | { 27 | "BACKEND": "django.template.backends.django.DjangoTemplates", 28 | "DIRS": [], 29 | "APP_DIRS": True, 30 | "OPTIONS": { 31 | "context_processors": [ 32 | "django.template.context_processors.debug", 33 | "django.template.context_processors.request", 34 | "django.contrib.auth.context_processors.auth", 35 | "django.contrib.messages.context_processors.messages", 36 | ], 37 | }, 38 | }, 39 | ] 40 | 41 | DATABASES = { 42 | "default": { 43 | "ENGINE": "django.db.backends.sqlite3", 44 | }, 45 | } 46 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.models import Permission, User 3 | from django.contrib.contenttypes.models import ContentType 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.http import HttpResponse 6 | from django.test import TestCase 7 | from django.urls import path 8 | 9 | 10 | def test_decorator(func): 11 | func.tested = True 12 | return func 13 | 14 | 15 | class IncludeDecoratedTestCase(TestCase): 16 | def get_decorator_include(self): 17 | from decorator_include import decorator_include 18 | 19 | return decorator_include 20 | 21 | def test_basic(self): 22 | decorator_include = self.get_decorator_include() 23 | 24 | urlconf, app_name, namespace = decorator_include(test_decorator, "tests.urls") 25 | self.assertEqual(urlconf.__class__.__name__, "DecoratedPatterns") 26 | # use app_name defined in tests.urls 27 | self.assertEqual(app_name, "app_name_tests") 28 | # if not defined, the namespace is the app_name 29 | self.assertEqual(namespace, "app_name_tests") 30 | 31 | def test_basic_namespace(self): 32 | decorator_include = self.get_decorator_include() 33 | 34 | urlconf, app_name, namespace = decorator_include( 35 | test_decorator, "tests.urls", "test" 36 | ) 37 | self.assertEqual(urlconf.__class__.__name__, "DecoratedPatterns") 38 | # use app_name defined in tests.urls 39 | self.assertEqual(app_name, "app_name_tests") 40 | # use passed namespace 41 | self.assertEqual(namespace, "test") 42 | 43 | def test_basic_2_tuple(self): 44 | decorator_include = self.get_decorator_include() 45 | 46 | urlconf, app_name, namespace = decorator_include( 47 | test_decorator, ("tests.urls", "testapp") 48 | ) 49 | self.assertEqual(urlconf.__class__.__name__, "DecoratedPatterns") 50 | # use app_name defined in tests.urls even if passed in the tuple 51 | self.assertEqual(app_name, "app_name_tests") 52 | # if not defined, the namespace is the app_name 53 | self.assertEqual(namespace, "app_name_tests") 54 | 55 | # temporarily remove app_name from `tests.urls` to ensure it will use the provided one 56 | from tests import urls 57 | 58 | old_app_name = urls.app_name 59 | try: 60 | del urls.app_name 61 | urlconf, app_name, namespace = decorator_include( 62 | test_decorator, ("tests.urls", "testapp") 63 | ) 64 | self.assertEqual(urlconf.__class__.__name__, "DecoratedPatterns") 65 | # no app_name in tests.urls, we use the one passed in the tuple 66 | self.assertEqual(app_name, "testapp") 67 | # if not defined, the namespace is the app_name 68 | self.assertEqual(namespace, "testapp") 69 | finally: 70 | urls.app_name = old_app_name 71 | 72 | def test_basic_2_tuple_namespace(self): 73 | decorator_include = self.get_decorator_include() 74 | 75 | urlconf, app_name, namespace = decorator_include( 76 | test_decorator, ("tests.urls", "testapp"), "testns" 77 | ) 78 | self.assertEqual(urlconf.__class__.__name__, "DecoratedPatterns") 79 | # use app_name defined in tests.urls even if passed in the tuple 80 | self.assertEqual(app_name, "app_name_tests") 81 | # use passed namespace 82 | self.assertEqual(namespace, "testns") 83 | 84 | # temporarily remove app_name from `tests.urls` to ensure it will use the provided one 85 | from tests import urls 86 | 87 | old_app_name = urls.app_name 88 | try: 89 | del urls.app_name 90 | urlconf, app_name, namespace = decorator_include( 91 | test_decorator, ("tests.urls", "testapp"), "testns" 92 | ) 93 | self.assertEqual(urlconf.__class__.__name__, "DecoratedPatterns") 94 | # no app_name in tests.urls, we use the one passed in the tuple 95 | self.assertEqual(app_name, "testapp") 96 | # use passed namespace 97 | self.assertEqual(namespace, "testns") 98 | finally: 99 | urls.app_name = old_app_name 100 | 101 | def test_basic_3_tuple(self): 102 | decorator_include = self.get_decorator_include() 103 | 104 | # passing a 3 tuple with a python path for the urls module is not allowed 105 | with self.assertRaises(ImproperlyConfigured): 106 | decorator_include(test_decorator, ("tests.urls", "testapp", "testns")) 107 | 108 | # but it is allowed when the first item can return directly urls, like the admin urls 109 | urlconf, app_name, namespace = decorator_include( 110 | test_decorator, admin.site.urls 111 | ) 112 | self.assertEqual(urlconf.__class__.__name__, "DecoratedPatterns") 113 | self.assertEqual(app_name, "admin") 114 | self.assertEqual(namespace, "admin") 115 | 116 | # or directly a list 117 | urlpatterns = [ 118 | path("myview/", lambda request: HttpResponse("view"), name="myview"), 119 | ] 120 | urlconf, app_name, namespace = decorator_include( 121 | test_decorator, (urlpatterns, "myviewsapp", "myviewsns") 122 | ) 123 | self.assertEqual(urlconf.__class__.__name__, "DecoratedPatterns") 124 | self.assertEqual(app_name, "myviewsapp") 125 | self.assertEqual(namespace, "myviewsns") 126 | 127 | def test_get_urlpatterns(self): 128 | decorator_include = self.get_decorator_include() 129 | 130 | def test_decorator(func): 131 | func.decorator_flag = "test" 132 | return func 133 | 134 | urlconf, app_name, namespace = decorator_include(test_decorator, "tests.urls") 135 | self.assertEqual(urlconf.__class__.__name__, "DecoratedPatterns") 136 | patterns = urlconf.urlpatterns 137 | # 3 URL patterns 138 | # / 139 | # /include/ 140 | # /admin/ 141 | # /only-god/ 142 | # /with-perm/ 143 | self.assertEqual(len(patterns), 5) 144 | self.assertEqual(patterns[0].callback.decorator_flag, "test") 145 | 146 | def test_multiple_decorators(self): 147 | decorator_include = self.get_decorator_include() 148 | 149 | def first_decorator(func): 150 | func.decorator_flag = "first" 151 | return func 152 | 153 | def second_decorator(func): 154 | func.decorator_flag = "second" 155 | func.decorated_by = "second" 156 | return func 157 | 158 | urlconf, app_name, namespace = decorator_include( 159 | (first_decorator, second_decorator), "tests.urls" 160 | ) 161 | self.assertEqual(urlconf.__class__.__name__, "DecoratedPatterns") 162 | patterns = urlconf.urlpatterns 163 | pattern = patterns[0] 164 | self.assertEqual(pattern.callback.decorator_flag, "first") 165 | self.assertEqual(pattern.callback.decorated_by, "second") 166 | 167 | def test_follow_include(self): 168 | decorator_include = self.get_decorator_include() 169 | 170 | def test_decorator(func): 171 | func.decorator_flag = "test" 172 | return func 173 | 174 | urlconf, app_name, namespace = decorator_include(test_decorator, "tests.urls") 175 | patterns = urlconf.urlpatterns 176 | decorated = patterns[1] 177 | self.assertEqual(decorated.url_patterns[1].callback.decorator_flag, "test") 178 | decorated = patterns[1].url_patterns[0].url_patterns[0] 179 | self.assertEqual(decorated.callback.decorator_flag, "test") 180 | 181 | def test_get_index(self): 182 | response = self.client.get("/") 183 | self.assertEqual(response.status_code, 200) 184 | 185 | def test_get_test(self): 186 | response = self.client.get("/include/test/") 187 | self.assertEqual(response.status_code, 302) 188 | 189 | def test_get_deeply_nested(self): 190 | response = self.client.get("/include/included/deeply_nested/") 191 | self.assertEqual(response.status_code, 302) 192 | 193 | def test_multiple_decorators_real_case(self): 194 | # the `/only-god/` path is decorated with two decorators: 195 | # - `login_required` that will redirect to login page if not authenticated 196 | # - `only_god` that will raise a 403 if it's not the "god" user 197 | 198 | # not authenticated will redirect to login page 199 | response = self.client.get("/only-god/test/") 200 | self.assertEqual(response.status_code, 302) 201 | 202 | # authenticated as god is ok 203 | god = User(username="god") 204 | god.set_password("foo") 205 | god.save() 206 | self.client.login(username="god", password="foo") 207 | response = self.client.get("/only-god/test/") 208 | self.assertEqual(response.status_code, 200) 209 | 210 | # authenticated as another user will raise 211 | notgod = User(username="notgod") 212 | notgod.set_password("foo") 213 | notgod.save() 214 | self.client.login(username="notgod", password="foo") 215 | response = self.client.get("/only-god/test/") 216 | self.assertEqual(response.status_code, 403) 217 | 218 | def test_django_permission_required(self): 219 | permission = Permission.objects.create( 220 | codename="is_god", 221 | name="Can do god things", 222 | content_type=ContentType.objects.get_for_model(User), 223 | ) 224 | 225 | # not authenticated will redirect to login page 226 | response = self.client.get("/with-perm/test/") 227 | self.assertEqual(response.status_code, 302) 228 | 229 | # authenticated with permission 230 | allowed_user = User(username="allowed") 231 | allowed_user.set_password("foo") 232 | allowed_user.save() 233 | allowed_user.user_permissions.add(permission) 234 | self.client.login(username="allowed", password="foo") 235 | response = self.client.get("/with-perm/test/") 236 | self.assertEqual(response.status_code, 200) 237 | 238 | # authenticated without permission will raise 239 | not_allowed = User(username="not_allowed") 240 | not_allowed.set_password("foo") 241 | not_allowed.save() 242 | self.client.login(username="not_allowed", password="foo") 243 | response = self.client.get("/with-perm/test/") 244 | self.assertEqual(response.status_code, 403) 245 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.decorators import ( 3 | login_required, 4 | permission_required, 5 | user_passes_test, 6 | ) 7 | from django.core.exceptions import PermissionDenied 8 | from django.http import HttpResponse 9 | from django.urls import path 10 | 11 | from decorator_include import decorator_include 12 | 13 | 14 | def identity(func): 15 | return func 16 | 17 | 18 | def only_user(username): 19 | def check(user): 20 | if user.is_authenticated and user.username == username: 21 | return True 22 | raise PermissionDenied 23 | 24 | return user_passes_test(check) 25 | 26 | 27 | def index(request): 28 | return HttpResponse("Index!") 29 | 30 | 31 | app_name = "app_name_tests" 32 | 33 | urlpatterns = [ 34 | path("", index, name="index"), 35 | path("include/", decorator_include(login_required, "tests.included")), 36 | path("admin/", decorator_include(identity, admin.site.urls)), 37 | path( 38 | "only-god/", 39 | decorator_include([login_required, only_user("god")], "tests.included"), 40 | ), 41 | path( 42 | "with-perm/", 43 | decorator_include( 44 | [login_required, permission_required("auth.is_god", raise_exception=True)], 45 | "tests.included", 46 | ), 47 | ), 48 | ] 49 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 1.9 3 | envlist = 4 | lint 5 | py{36,37,38,39}-django22 6 | py{36,37,38,39}-django30 7 | py{36,37,38,39}-django31 8 | py{36,37,38,39,310}-django32 9 | py{38,39,310}-django40 10 | py{38,39,310,311}-django41 11 | py{38,39,310,311,312}-django42 12 | py{310,311,312,313}-django50 13 | py{310,311,312,313,314}-django51 14 | py{310,311,312,313,314}-django52 15 | 16 | [testenv] 17 | commands = python -m django test --settings=tests.settings tests 18 | deps = 19 | django22: Django>=2.2,<3.0 20 | django30: Django>=3.0,<3.1 21 | django31: Django>=3.1,<3.2 22 | django32: Django>=3.1,<4 23 | django40: Django>=4.0,<4.1 24 | django41: Django>=4.1.3,<4.2 25 | django42: Django>=4.2,<5 26 | django50: Django>=5.0,<5.1 27 | django51: Django>=5.1,<5.2 28 | django52: Django>=5.2a1,<6 29 | 30 | [testenv:lint] 31 | basepython = python3 32 | commands = pre-commit run --all-files --show-diff-on-failure 33 | deps = pre-commit 34 | skip_install = true 35 | --------------------------------------------------------------------------------