├── .github └── workflows │ └── main.yml ├── .gitignore ├── .landscape.yaml ├── CHANGES.txt ├── LICENSE ├── MANIFEST.in ├── README.rst ├── VERSION ├── django_su ├── __init__.py ├── backends.py ├── context_processors.py ├── forms.py ├── locale │ ├── pt_BR │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── ru │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── models.py ├── templates │ ├── admin │ │ ├── auth │ │ │ └── user │ │ │ │ ├── change_form.html │ │ │ │ └── change_list.html │ │ └── base_site.html │ └── su │ │ ├── is_su.html │ │ ├── login.html │ │ └── login_link.html ├── templatetags │ ├── __init__.py │ └── su_tags.py ├── tests │ ├── __init__.py │ ├── test_backends.py │ └── test_views.py ├── urls.py ├── utils.py └── views.py ├── example ├── README.rst ├── __init__.py ├── lookups.py ├── manage.py ├── requirements.txt ├── settings.py ├── templates │ ├── 404.html │ └── index.html ├── urls.py └── utils.py ├── requirements.test.txt ├── setup.cfg ├── setup.py ├── test_settings.py ├── test_templates └── 404.html └── test_urls.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [ master, develop ] 10 | pull_request: 11 | branches: [ '**' ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | env: 17 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 18 | 19 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 20 | jobs: 21 | # This workflow contains a single job called "build" 22 | tests: 23 | # The type of runner that the job will run on 24 | runs-on: ubuntu-latest 25 | 26 | strategy: 27 | matrix: 28 | DJANGO_VERSION: [ '2.2.*', '3.0.*', '3.1.*', '3.2.*', '4.0.*', '4.1.*'] 29 | python-version: ['3.7', '3.8', '3.9', '3.10'] 30 | exclude: 31 | - DJANGO_VERSION: '4.1.*' 32 | python-version: '3.7' 33 | - DJANGO_VERSION: '4.0.*' 34 | python-version: '3.7' 35 | - DJANGO_VERSION: '3.1.*' 36 | python-version: '3.10' 37 | - DJANGO_VERSION: '3.0.*' 38 | python-version: '3.10' 39 | - DJANGO_VERSION: '2.2.*' 40 | python-version: '3.10' 41 | fail-fast: false 42 | 43 | services: 44 | postgres: 45 | image: postgres 46 | env: 47 | POSTGRES_PASSWORD: postgres 48 | options: >- 49 | --health-cmd pg_isready 50 | --health-interval 10s 51 | --health-timeout 5s 52 | --health-retries 5 53 | ports: 54 | - 5432:5432 55 | 56 | steps: 57 | - uses: actions/checkout@v2 58 | 59 | - name: Set up Python ${{ matrix.python-version }} 60 | uses: actions/setup-python@v2 61 | with: 62 | python-version: ${{ matrix.python-version }} 63 | - uses: actions/cache@v2 64 | with: 65 | path: ~/.cache/pip 66 | key: ${{ hashFiles('setup.py') }}-${{ matrix.DJANGO_VERSION }} 67 | 68 | - name: Install gettext 69 | run: sudo apt-get install -y gettext 70 | - name: Install 71 | run: | 72 | python setup.py develop 73 | pip install Django==${{ matrix.DJANGO_VERSION }} 74 | pip install -e . 75 | pip install -r requirements.test.txt 76 | pip install coveralls 77 | pip install psycopg2==2.8.6 78 | pip install codecov 79 | 80 | - name: Testing 81 | run: PYTHONPATH=`pwd` python -W error::DeprecationWarning -m coverage run --source django_su ./example/manage.py test 82 | - name: upload coverage 83 | run: | 84 | coverage xml 85 | coveralls --service=github 86 | codecov 87 | env: 88 | DATABASE_URL: "postgresql://postgres:postgres@localhost/postgres" 89 | 90 | lint: 91 | runs-on: ubuntu-latest 92 | steps: 93 | - uses: actions/checkout@v2 94 | - name: Set up Python 95 | uses: actions/setup-python@v2 96 | - uses: actions/cache@v2 97 | with: 98 | path: ~/.cache/pip 99 | key: ${{ hashFiles('setup.py') }}-${{ matrix.DJANGO_VERSION }} 100 | 101 | - name: Install gettext 102 | run: sudo apt-get install -y gettext 103 | - name: Install 104 | run: | 105 | pip install flake8 isort black mypy django-stubs types-six types-requests types-mock 106 | python setup.py develop 107 | pip install -e . 108 | pip install -r requirements.test.txt 109 | - name: Running Flake8 110 | run: flake8 111 | - name: Running isort 112 | run: python -m isort django_su tests --check-only --diff 113 | - name: Running black 114 | run: black --check . 115 | - name: Running mypy 116 | run: mypy django_su --ignore-missing-imports 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | # Vim 60 | *.sw[po] 61 | -------------------------------------------------------------------------------- /.landscape.yaml: -------------------------------------------------------------------------------- 1 | ignore-paths: 2 | - south_migrations 3 | - migrations 4 | - example 5 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | Change-log for django-su. 2 | 3 | This file will be added to as part of each release 4 | ---- 5 | 6 | Version 1.0.0, Fri 1 May 2022 7 | ============================== 8 | 9 | Fix compatibility with Django 4.0 10 | Test in Django versions 2.2 - 4.0, Python versions 3.7 - 3.10 11 | 12 | Version 0.9.0, Mon 20 Jan 2020 13 | =============================== 14 | 15 | efaf6f65c0 Updating setup.py for django version support changes (Adam Charnock) 16 | 3afa8335d2 Work on migrating away from django-setuptest, as it is no longer maintained (Adam Charnock) 17 | 10da766742 Work on migrating away from django-setuptest, as it is no longer maintained (Adam Charnock) 18 | 360d621293 Work on migrating away from django-setuptest, as it is no longer maintained (Adam Charnock) 19 | 117b0a2c92 Updating CI config for django 3 and python 3.8 (Adam Charnock) 20 | b88eb7441c remove deprecated template loader 'admin_static' in favor to 'static' (Genesis Guerrero Martinez) 21 | ae406537e2 Dropped support for django < 1.11 (Basil Shubin) 22 | 662a472783 minor: call get_user_model once per method (Daniel Hahler) 23 | b7a4cbfbe5 Version bump to 0.8.0 and updating CHANGES.txt (Adam Charnock) 24 | 57c42a6cf3 Update argument of django-su.backends.authenticate function (Geoffrey H. Ferrari) 25 | 9c037ed3eb Update UserSuForm to enhance compatibility with custom user models. (Geoffrey H. Ferrari) 26 | 30502d08b9 Documenting use of AUTH_USER_MODEL with django-su (formatting fix) (Adam Charnock) 27 | 861a59776a Documenting use of AUTH_USER_MODEL with django-su (formatting fix) (Adam Charnock) 28 | e88ab1fd63 Documenting use of AUTH_USER_MODEL with django-su. Closes #62 (Adam Charnock) 29 | f4b397ccac Version bump to 0.7.0 and updating CHANGES.txt (Adam Charnock) 30 | 60cccda009 Adding missing version bump (Adam Charnock) 31 | 18df6887a6 Add request to authenticate call (Ilaissa Romero) 32 | ee2787a368 Version bump to 0.6.0 and updating CHANGES.txt (Adam Charnock) 33 | 34 | 35 | Version 0.8.0, Sat 15 Sep 2018 36 | =============================== 37 | 38 | e6b2bc9d78 Update argument of django-su.backends.authenticate function (Geoffrey H. Ferrari) 39 | a46c152adb Update UserSuForm to enhance compatibility with custom user models. (Geoffrey H. Ferrari) 40 | 2235eac863 Documenting use of AUTH_USER_MODEL with django-su (formatting fix) (Adam Charnock) 41 | 56921c216d Documenting use of AUTH_USER_MODEL with django-su (formatting fix) (Adam Charnock) 42 | 74b3ccd7ba Documenting use of AUTH_USER_MODEL with django-su. Closes #62 (Adam Charnock) 43 | 44 | 45 | Version 0.7.0, Mon 13 Aug 2018 46 | =============================== 47 | 48 | 2cec5cca12 Adding missing version bump (Adam Charnock) 49 | 89d36c414f Add request to authenticate call (Ilaissa Romero) 50 | 51 | 52 | Version 0.6.0, Mon 18 Dec 2017 53 | =============================== 54 | 55 | 9c2db58f78 Disabling CI for django 2.0 on python 2.7 (Adam Charnock) 56 | 9e89c52e82 Dropping support for django 1.4 (Adam Charnock) 57 | 4a37193a2d Reverting testing changes to setup.py (Adam Charnock) 58 | 17d9d06e86 Fixes for django 2.0. Had to change how the user's last_login was maintained due to changes in Signal.disconnect() (Adam Charnock) 59 | 8d00040c5b No need for su when adding a new user (Riccardo Magliocchetti) 60 | d9bad3c19f Update .travis.yml (Basil Shubin) 61 | 137a50ef5a ref @446b698 (Basil Shubin) 62 | 2bc30f43f0 added missing TEMPLATE_DIRS (Basil Shubin) 63 | 22cb5214ba dropped python 2.6 and 3.3 from support (Basil Shubin) 64 | 5e399e84d5 upgraded test suite (Basil Shubin) 65 | 446b6986e5 Fix for relocation of django.core.urlresolvers -> django.urls in Django 2 (Adam Charnock) 66 | d05ffa3783 No longer testing django master against python 2 (Django 2 will support Python 3 only) (Adam Charnock) 67 | d98ab119da fix broken exception handler (Basil Shubin) 68 | 4e6187b53b Update compat.py (Basil Shubin) 69 | 69c1442a13 Update MANIFEST.in (Basil Shubin) 70 | a6eaa28630 New translation (Gustavo Santana) 71 | 5d5902f1ca make sure compat template tag library is loaded (bashu) 72 | f7c7d78808 customized admin/base_site.html, fix #54 (bashu) 73 | 8731d5a3ad update example project (bashu) 74 | f75ea07657 make sure all csrf protection is enabled (bashu) 75 | 7d637e8de4 remove whitespaces (bashu) 76 | d5185c0fa1 Correct grammar used in warning (Fred Palmer) 77 | 78 | 79 | Version 0.5.2, Wed 20 Apr 2016 80 | =============================== 81 | 82 | 3d09b7bec4 re-enabling ``formadmin`` (bashu) 83 | b56f68f476 small improvement (bashu) 84 | 20f6217818 added ``UsersLookup`` as example (bashu) 85 | d58bc4ce45 make sure ``example`` project works with django 1.9+ (bashu) 86 | 7126f4a1b2 replace render_to_response to render (Konstantin Seleznev) 87 | 7a7c1c1a2c fixed a stupid mistake in template (bashu) 88 | 89 | 90 | Version 0.5.1, Wed 23 Mar 2016 91 | =============================== 92 | 93 | 6f781a02fe updated example (bashu) 94 | db634b5d6e su now works with django-suit, fix #48 (bashu) 95 | 245700d9fc using django's module loading utils, fix #45 (bashu) 96 | d1ee129ac8 Update README.rst (Basil Shubin) 97 | a659a40e8a fixed my own fixes (kudos to @PetrDlouhy) (bashu) 98 | ba5e4f8984 switched from zipball to tarball (bashu) 99 | 4c060a84c9 fix for Django 1.10 (Petr Dlouhý) 100 | 96d1178806 test (with allow failures) on dev (Petr Dlouhý) 101 | 890004401d updated example project (bashu) 102 | 24cb98f6b7 getting rid of pypy (bashu) 103 | eccb6743f9 fixed .travis.yml (bashu) 104 | 92e9c20a3e added django 1.9 support (bashu) 105 | 632db8ff8c fixed "RemovedInDjango110Warning: The context_instance argument of render_to_string is deprecated..." (bashu) 106 | 56f8acf462 fixed "RemovedInDjango110Warning: You haven't defined a TEMPLATES setting..." (bashu) 107 | 7b2d58b751 fixed "RemovedInDjango110Warning: django.conf.urls.patterns() is deprecated..." (bashu) 108 | edb9b166ef cleanup CHANGES.txt (bashu) 109 | 110 | 111 | Version 0.5.0, Fri 27 Nov 2015 112 | =============================== 113 | 114 | 9d08e6587f Preventing updating of a user's last_login field when su'ing (Adam Charnock) 115 | 37f648c05a Basic non-ajax user select now sorts users by username [#41] (Adam Charnock) 116 | 13f2374a74 Added context_processor and a template element. (Aymeric Derbois) 117 | 5c7731dbb7 added ``ru`` translation (bashu) 118 | 119 | 120 | Version 0.4.8, Fri 03 Jul 2015 121 | =============================== 122 | 123 | 2748e10e7c Allow negative user id, fix #30 (Basil Shubin) 124 | ca73033541 improve python3 compatibility (Basil Shubin) 125 | e2dbf20bfd Only import url from future when using Django < 1.5 (Basil Shubin) 126 | 3d9860d948 Allowing only POST to login views. This avoid potential XSRF issues. (Jason Lawrence) 127 | 128 | 129 | Version 0.4.7, Wed 09 Jul 2014 130 | =============================== 131 | 132 | 571f2aac86 Fix for django 1.4 (Iacopo Spalletti) 133 | 134 | 135 | Version 0.4.6, Wed 09 Jul 2014 136 | =============================== 137 | 138 | 139 | Version 0.4.5, Sat 24 May 2014 140 | =============================== 141 | 142 | 186aa20be4 missed the su_exit for the auth.login override (Clay Johns) 143 | f700cf1a19 added the ability to override the auth.login function (Clay Johns) 144 | 145 | 146 | Version 0.4.4, Sat 24 May 2014 147 | =============================== 148 | 149 | e9564130db Further cleanup of src directory usage (Adam Charnock) 150 | c0022f363b Moving django_su into top level directory (Adam Charnock) 151 | 152 | 153 | Version 0.4.3, Sat 24 May 2014 154 | =============================== 155 | 156 | 30fe67b924 Fix login_link template on django 1.4 (Iacopo Spalletti) 157 | 158 | 159 | Version 0.4.2, Fri 03 Jan 2014 160 | =============================== 161 | 162 | c9ffb78d8a Fixed #23 object tools only show up for user and not group pages 163 | Added class for django-grappelli support (won't harm vanilla admin) (David Burke) 164 | 165 | 166 | Version 0.4.1, Fri 20 Dec 2013 167 | =============================== 168 | 169 | 177d5cb7ed Fixed import error for django 1.6 (Wes Okes) 170 | 606009c1b9 Update README.rst (Adam Charnock) 171 | 43d447ee58 Re-adding incorrectly removing changelog (Adam Charnock) 172 | 1fc7cb5dda Removing old changelog (Adam Charnock) 173 | 174 | 175 | Version 0.4.0, Mon 09 Sep 2013 176 | =============================== 177 | 178 | 65a365df3c Remove deprecated adminmedia usage (Anthony Garcia) 179 | 180 | 181 | Version 0.3.2, Wed 21 Aug 2013 182 | =============================== 183 | 184 | 3021ad7e73 Minor readme updates (Adam Charnock) 185 | 186 | 187 | Version 0.3.1, Tue 20 Aug 2013 188 | =============================== 189 | 190 | 81e27c1c0f Adding long_description to setup.py (Adam Charnock) 191 | 3afc8fbcfb Updating readme (Adam Charnock) 192 | 193 | 194 | Version 0.3.0, Tue 20 Aug 2013 195 | =============================== 196 | 197 | c55b117d4d Updating setup.py (Adam Charnock) 198 | 7abefd1e0b Adding a license. Fixes #8 (Adam Charnock) 199 | 8b0704580e Update calls to url for Django 1.5 (Andrew Frankel) 200 | a4f79614f8 better handling of auth backend. (Joe Vanderstelt) 201 | a6665efd14 django 1.4 get_user_model fix (Joe Vanderstelt) 202 | f4a6b11cc0 fix for django 1.4 get_user_model (Joe Vanderstelt) 203 | 6ef3bdab54 Modify deprecated template tags for Django 1.5 compatibility (David Friedman) 204 | 9e6178ffcb SU_LOGIN can either be a string or a callable (David Wolever) 205 | ec15a6bd97 Add a logout view (Jeremy Katz) 206 | 207 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2015 Adam Charnock 4 | Copyright (c) 2015 Basil Shubin 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES.txt 2 | include LICENSE 3 | include README.rst 4 | recursive-include django_su/locale * 5 | recursive-include django_su/templates * 6 | include VERSION 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-su 2 | ========= 3 | 4 | Login as any user from the Django admin interface, then switch back when done 5 | 6 | Authored by `Adam Charnock `_ (who is available for freelance/contract work), and some great 7 | `contributors `_. 8 | 9 | .. image:: https://img.shields.io/pypi/v/django-su.svg 10 | :target: https://pypi.python.org/pypi/django-su/ 11 | 12 | .. image:: https://img.shields.io/pypi/dm/django-su.svg 13 | :target: https://pypi.python.org/pypi/django-su/ 14 | 15 | .. image:: https://img.shields.io/github/license/adamcharnock/django-su.svg 16 | :target: https://pypi.python.org/pypi/django-su/ 17 | 18 | .. image:: https://img.shields.io/travis/adamcharnock/django-su.svg 19 | :target: https://travis-ci.org/adamcharnock/django-su/ 20 | 21 | .. image:: https://coveralls.io/repos/adamcharnock/django-su/badge.svg?branch=develop 22 | :target: https://coveralls.io/r/adamcharnock/django-su?branch=develop 23 | 24 | Installation 25 | ------------ 26 | 27 | 1. Either checkout ``django_su`` from GitHub, or install using ``pip`` : 28 | 29 | .. code-block:: bash 30 | 31 | pip install django-su 32 | 33 | 2. Add ``django_su`` to your ``INSTALLED_APPS``. Make sure you put it *before* ``django.contrib.admin`` : 34 | 35 | .. code-block:: python 36 | 37 | INSTALLED_APPS = ( 38 | ... 39 | 'django_su', # must be before ``django.contrib.admin`` 40 | 'django.contrib.admin', 41 | ) 42 | 43 | 3. Add ``SuBackend`` to ``AUTHENTICATION_BACKENDS`` : 44 | 45 | .. code-block:: python 46 | 47 | AUTHENTICATION_BACKENDS = ( 48 | ... 49 | 'django_su.backends.SuBackend', 50 | ) 51 | 52 | 4. Update your ``urls.py`` file : 53 | 54 | .. code-block:: python 55 | 56 | urlpatterns = [ 57 | url(r'^su/', include('django_su.urls')), 58 | ... 59 | ] 60 | 61 | And that should be it! 62 | 63 | Please see ``example`` application. This application is used to manually test 64 | the functionalities of this package. This also serves as a good example. 65 | 66 | ``django-su`` is tested on Django 2.2 or above, lower versions may work, but are considered unsupported. 67 | 68 | External dependencies (optional, but recommended) 69 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 70 | 71 | The following apps are optional but will enhance the user experience: 72 | 73 | * The 'login su' form will render using `django-form-admin`_ 74 | * The user selection widget will render using `django-ajax-selects`_ 75 | 76 | Note that `django-ajax-selects`_ requires the following settings: 77 | 78 | .. code-block:: python 79 | 80 | AJAX_LOOKUP_CHANNELS = {'django_su': dict(model='auth.user', search_field='username')} 81 | 82 | 83 | Configuration (optional) 84 | ------------------------ 85 | 86 | There are various optional configuration options you can set in your ``settings.py`` 87 | 88 | .. code-block:: python 89 | 90 | # URL to redirect after the login. 91 | # Default: "/" 92 | SU_LOGIN_REDIRECT_URL = "/" 93 | 94 | # URL to redirect after the logout. 95 | # Default: "/" 96 | SU_LOGOUT_REDIRECT_URL = "/" 97 | 98 | # A function specifying the permissions a user requires in order 99 | # to use the django-su functionality. 100 | # Default: None 101 | SU_LOGIN_CALLBACK = "example.utils.su_login_callback" 102 | 103 | # A function to override the django.contrib.auth.login(request, user) 104 | # view, thereby allowing one to set session data, etc. 105 | # Default: None 106 | SU_CUSTOM_LOGIN_ACTION = "example.utils.custom_login" 107 | 108 | Usage 109 | ----- 110 | 111 | Go and view a user in the admin interface and look for a new "Login 112 | as" button in the top right. 113 | 114 | Once you have su'ed into a user, you can get exit back into your 115 | original user by navigating to ``/su/`` in your browser. 116 | 117 | How to 118 | ------ 119 | 120 | How to Notify superuser when connected with another user 121 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 122 | 123 | This option warns the superuser when working with another user as 124 | initially logged in. To activate this option perform: 125 | 126 | 1. Add ``django_su.context_processors.is_su`` to ``TEMPLATE_CONTEXT_PROCESSORS`` : 127 | 128 | .. code-block:: python 129 | 130 | TEMPLATE_CONTEXT_PROCESSORS = ( 131 | ... 132 | 'django_su.context_processors.is_su', 133 | ) 134 | 135 | 2. In your ``base.html`` include ``su/is_su.html`` snippet : 136 | 137 | .. code-block:: html+django 138 | 139 | {% include "su/is_su.html" %} 140 | 141 | How to use django-su with a custom user model (AUTH_USER_MODEL) 142 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 143 | 144 | Django-su should function normally with a custom user model. However, 145 | your `ModelAdmin` in your `admin.py` file will need tweaking as follows: 146 | 147 | .. code-block:: python 148 | 149 | # Within your admin.py file 150 | from django.contrib import admin 151 | from django.contrib.auth.admin import UserAdmin 152 | 153 | from . import models 154 | 155 | @admin.register(models.CustomUser) 156 | class CustomUserAdmin(UserAdmin): 157 | # The following two lines are needed: 158 | change_form_template = "admin/auth/user/change_form.html" 159 | change_list_template = "admin/auth/user/change_list.html" 160 | 161 | This ensures the Django admin will use the correct template customisations for 162 | your custom user model. 163 | 164 | 165 | Credits 166 | ------- 167 | 168 | This app was put together by Adam Charnock, but was largely based on ideas, code and comments at: 169 | 170 | * http://bitkickers.blogspot.com/2010/06/add-button-to-django-admin-to-login-as.html 171 | * http://copiousfreetime.blogspot.com/2006/12/django-su.html 172 | 173 | django-su is packaged using seed_. 174 | 175 | .. _django-form-admin: http://pypi.python.org/pypi/django-form-admin 176 | .. _django-ajax-selects: http://pypi.python.org/pypi/django-ajax-selects 177 | .. _seed: https://github.com/adamcharnock/seed/ 178 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.0.0 2 | -------------------------------------------------------------------------------- /django_su/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-su/afc8c9cdb71ea62d12ab0365721339f5a43f4512/django_su/__init__.py -------------------------------------------------------------------------------- /django_su/backends.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.contrib.auth import get_user_model 4 | 5 | 6 | User = get_user_model() 7 | 8 | 9 | class SuBackend(object): 10 | supports_inactive_user = False 11 | 12 | def authenticate(self, request=None, su=False, user_id=None, **kwargs): 13 | if not su: 14 | return None 15 | 16 | try: 17 | user = User._default_manager.get(pk=user_id) # pylint: disable=W0212 18 | except (User.DoesNotExist, ValueError): 19 | return None 20 | 21 | return user 22 | 23 | def get_user(self, user_id): 24 | try: 25 | return User._default_manager.get(pk=user_id) # pylint: disable=W0212 26 | except User.DoesNotExist: 27 | return None 28 | -------------------------------------------------------------------------------- /django_su/context_processors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | def is_su(request): 5 | return {"IS_SU": len(request.session.get("exit_users_pk", default=[]))} 6 | -------------------------------------------------------------------------------- /django_su/forms.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django import forms 4 | from django.conf import settings 5 | from django.contrib.auth import get_user_model 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | 9 | User = get_user_model() 10 | 11 | 12 | class UserSuForm(forms.Form): 13 | username_field = getattr(User, "USERNAME_FIELD", None) 14 | 15 | user = forms.ModelChoiceField( 16 | label=_("Users"), 17 | queryset=User._default_manager.order_by(username_field) 18 | if username_field 19 | else User._default_manager.all(), 20 | required=True, 21 | ) # pylint: disable=W0212 22 | 23 | use_ajax_select = False 24 | 25 | def __init__(self, *args, **kwargs): 26 | super(UserSuForm, self).__init__(*args, **kwargs) 27 | 28 | if "ajax_select" in settings.INSTALLED_APPS and getattr( 29 | settings, "AJAX_LOOKUP_CHANNELS", None 30 | ): 31 | from ajax_select.fields import AutoCompleteSelectField 32 | 33 | lookup = settings.AJAX_LOOKUP_CHANNELS.get("django_su", None) 34 | if lookup is not None: 35 | old_field = self.fields["user"] 36 | 37 | self.fields["user"] = AutoCompleteSelectField( 38 | "django_su", 39 | required=old_field.required, 40 | label=old_field.label, 41 | ) 42 | self.use_ajax_select = True 43 | 44 | def get_user(self): 45 | return self.cleaned_data.get("user", None) 46 | 47 | def __str__(self): 48 | if "formadmin" in settings.INSTALLED_APPS: 49 | try: 50 | from formadmin.forms import as_django_admin 51 | 52 | return as_django_admin(self) 53 | except ImportError: 54 | pass 55 | return super(UserSuForm, self).__str__() 56 | -------------------------------------------------------------------------------- /django_su/locale/pt_BR/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-su/afc8c9cdb71ea62d12ab0365721339f5a43f4512/django_su/locale/pt_BR/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /django_su/locale/pt_BR/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2016-04-24 21:09-0300\n" 11 | "PO-Revision-Date: 2016-04-24 21:31-0300\n" 12 | "Language: pt_BR\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 17 | "Last-Translator: \n" 18 | "Language-Team: \n" 19 | "X-Generator: Poedit 1.8.7\n" 20 | 21 | #: .\forms.py:13 22 | msgid "Users" 23 | msgstr "Usuários" 24 | 25 | #: .\templates\admin\auth\user\change_form.html:9 .\templates\su\login.html:5 26 | msgid "Login as" 27 | msgstr "Acessar como" 28 | 29 | #: .\templates\admin\base_site.html:6 .\templates\su\login.html.py:5 30 | msgid "Django site admin" 31 | msgstr "" 32 | 33 | #: .\templates\admin\base_site.html:9 34 | msgid "Django administration" 35 | msgstr "" 36 | 37 | #: .\templates\admin\base_site.html:14 38 | msgid "Exit back into your original session" 39 | msgstr "Retornar para sua sessão original" 40 | 41 | #: .\templates\admin\base_site.html:14 42 | msgid "You are" 43 | msgstr "Você" 44 | 45 | #: .\templates\admin\base_site.html:14 46 | msgid " logged in as," 47 | msgstr " acessou como," 48 | 49 | #: .\templates\admin\base_site.html:16 50 | msgid "Welcome," 51 | msgstr "Bem-vindo(a)," 52 | 53 | #: .\templates\su\is_su.html:16 54 | msgid "WARNING: You have assumed the identity of another account!" 55 | msgstr "AVISO: Você assumiu o acesso de outra conta!" 56 | 57 | #: .\templates\su\login.html:24 58 | msgid "Change Login" 59 | msgstr "Alterar Acesso" 60 | 61 | #: .\templates\su\login_link.html:3 62 | msgid "Login as other user" 63 | msgstr "Acessar como outro usuário" 64 | -------------------------------------------------------------------------------- /django_su/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-su/afc8c9cdb71ea62d12ab0365721339f5a43f4512/django_su/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /django_su/locale/ru/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-su\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2015-07-05 18:47+0600\n" 11 | "PO-Revision-Date: 2015-07-05 18:47+0500\n" 12 | "Last-Translator: Basil Shubin \n" 13 | "Language-Team: ru \n" 14 | "Language: ru\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" 19 | "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" 20 | "X-Generator: Poedit 1.5.7\n" 21 | 22 | #: forms.py:18 23 | #| msgid "users" 24 | msgid "Users" 25 | msgstr "Пользователи" 26 | 27 | #: templates/admin/auth/user/change_form.html:8 28 | msgid "History" 29 | msgstr "" 30 | 31 | #: templates/admin/auth/user/change_form.html:11 templates/su/login.html:5 32 | msgid "Login as" 33 | msgstr "Войти как" 34 | 35 | #: templates/admin/auth/user/change_form.html:16 36 | msgid "View on site" 37 | msgstr "" 38 | 39 | #: templates/su/login.html:5 40 | msgid "Django site admin" 41 | msgstr "" 42 | 43 | #: templates/su/login.html:24 44 | msgid "Change Login" 45 | msgstr "Сменить пользователя" 46 | 47 | #: templates/su/login_link.html:3 48 | msgid "Login as other user" 49 | msgstr "Войти как другой пользователь" 50 | -------------------------------------------------------------------------------- /django_su/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-su/afc8c9cdb71ea62d12ab0365721339f5a43f4512/django_su/models.py -------------------------------------------------------------------------------- /django_su/templates/admin/auth/user/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n %} 3 | 4 | {% block object-tools-items %} 5 | {{ block.super }} 6 | {% if object_id %} 7 |
  • 8 | 9 | {% trans "Login as" %} 10 | 11 |
  • 12 | {% endif %} 13 | {% endblock %} 14 | 15 | {% block content %} 16 | {{ block.super }} 17 | {% if object_id %}
    {% csrf_token %}
    {% endif %} 18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /django_su/templates/admin/auth/user/change_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | 3 | {% load i18n su_tags %} 4 | 5 | {% block object-tools-items %} 6 | {% login_su_link user %} 7 | {{ block.super }} 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /django_su/templates/admin/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} 6 | 7 | {% block branding %} 8 |

    {{ site_header|default:_('Django administration') }}

    9 | {% endblock %} 10 | 11 | {% block welcome-msg %} 12 | {% if IS_SU %} 13 | {% trans 'You are' %}{% trans ' logged in as,' %} 14 | {% else %} 15 | {% trans 'Welcome,' %} 16 | {% endif %} 17 | {% firstof user.get_short_name user.get_username %}. 18 | {% endblock %} 19 | 20 | {% block nav-global %}{% endblock %} 21 | -------------------------------------------------------------------------------- /django_su/templates/su/is_su.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% if IS_SU %} 3 | 15 |
    16 | {% trans "WARNING: You have assumed the identity of another account!" %} 17 |
    18 | {% endif %} 19 | -------------------------------------------------------------------------------- /django_su/templates/su/login.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | 3 | {% load i18n static %} 4 | 5 | {% block title %}{% trans "Login as" %} | {% trans 'Django site admin' %}{% endblock %} 6 | 7 | {% block extrahead %} 8 | {{ block.super }} 9 | {% if form.use_ajax_select %} 10 | 11 | 12 | 13 | {% endif %} 14 | {{ form.media }} 15 | 16 | {% endblock %} 17 | 18 | {% block breadcrumbs %}{% endblock %} 19 | 20 | {% block content %} 21 |
    {% csrf_token %} 22 | {{ form }} 23 |
    24 | 25 |
    26 |
    27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /django_su/templates/su/login_link.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% if can_su_login %}
  • {% trans "Login as other user" %}
  • {% endif %} 3 | -------------------------------------------------------------------------------- /django_su/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-su/afc8c9cdb71ea62d12ab0365721339f5a43f4512/django_su/templatetags/__init__.py -------------------------------------------------------------------------------- /django_su/templatetags/su_tags.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django import template 4 | 5 | from ..utils import su_login_callback 6 | 7 | 8 | register = template.Library() 9 | 10 | 11 | @register.inclusion_tag("su/login_link.html", takes_context=False) 12 | def login_su_link(user): 13 | return {"can_su_login": su_login_callback(user)} 14 | -------------------------------------------------------------------------------- /django_su/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-su/afc8c9cdb71ea62d12ab0365721339f5a43f4512/django_su/tests/__init__.py -------------------------------------------------------------------------------- /django_su/tests/test_backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase 3 | 4 | 5 | User = get_user_model() 6 | 7 | 8 | class TestSuBackend(TestCase): 9 | def setUp(self): 10 | super(TestSuBackend, self).setUp() 11 | from django_su.backends import SuBackend 12 | 13 | self.user = User.objects.create(username="testuser") 14 | self.backend = SuBackend() 15 | 16 | def test_authenticate_do_it(self): 17 | """Ensure authentication passes when su=True and user id is valid""" 18 | self.assertEqual( 19 | self.backend.authenticate(su=True, user_id=self.user.pk), self.user 20 | ) 21 | 22 | def test_authenticate_dont_do_it(self): 23 | """Ensure authentication fails when su=False and user id is valid""" 24 | self.assertEqual( 25 | self.backend.authenticate(su=False, user_id=self.user.pk), None 26 | ) 27 | 28 | def test_authenticate_id_none(self): 29 | """Ensure authentication fails when user_id is None""" 30 | self.assertEqual(self.backend.authenticate(su=True, user_id=None), None) 31 | 32 | def test_authenticate_id_non_existent(self): 33 | """Ensure authentication fails when user_id doesn't exist""" 34 | self.assertEqual(self.backend.authenticate(su=True, user_id=999), None) 35 | 36 | def test_authenticate_id_invalid(self): 37 | """Ensure authentication fails when user_id is invalid""" 38 | self.assertEqual(self.backend.authenticate(su=True, user_id="abc"), None) 39 | 40 | def test_get_user_exists(self): 41 | """Ensure get_user returns the expected user""" 42 | self.assertEqual(self.backend.get_user(user_id=self.user.pk), self.user) 43 | 44 | def test_get_user_does_not_exist(self): 45 | """Ensure get_user returns None if user is not found""" 46 | self.assertEqual(self.backend.get_user(user_id=999), None) 47 | -------------------------------------------------------------------------------- /django_su/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timezone 2 | 3 | from django.conf import settings 4 | from django.contrib import auth 5 | from django.contrib.auth import get_user_model 6 | from django.contrib.sessions.backends import cached_db 7 | from django.test import Client, TestCase 8 | from django.urls import reverse 9 | from django.utils.datetime_safe import datetime 10 | 11 | 12 | User = get_user_model() 13 | 14 | 15 | class SuViewsBaseTestCase(TestCase): 16 | def setUp(self): 17 | super(SuViewsBaseTestCase, self).setUp() 18 | from django_su.views import login_as_user 19 | 20 | self.authorized_user = self.user("authorized", is_superuser=True) 21 | self.unauthorized_user = self.user("unauthorized") 22 | self.destination_user = self.user("destination") 23 | self.view = login_as_user 24 | self.client = self.make_client() 25 | # Causes errors with validation. 26 | # TODO: Investigate 27 | if "ajax_select" in settings.INSTALLED_APPS: 28 | settings.INSTALLED_APPS.remove("ajax_select") 29 | 30 | def user(self, username, **kwargs): 31 | user = User.objects.create(username=username, **kwargs) 32 | user.set_password("pass") 33 | user.save() 34 | return user 35 | 36 | def make_client(self): 37 | client = Client() 38 | s = cached_db.SessionStore() 39 | s.save() 40 | client.cookies[settings.SESSION_COOKIE_NAME] = s.session_key 41 | return client 42 | 43 | 44 | class LoginAsUserViewTestCase(SuViewsBaseTestCase): 45 | def test_login_success(self): 46 | """Ensure login works for a valid user""" 47 | self.client.login(username="authorized", password="pass") 48 | response = self.client.post( 49 | reverse("login_as_user", args=[self.destination_user.id]) 50 | ) 51 | self.assertEqual(response.status_code, 302) 52 | # Check the user is logged in in the session 53 | self.assertIn(auth.SESSION_KEY, self.client.session) 54 | self.assertEqual( 55 | str(self.client.session[auth.SESSION_KEY]), str(self.destination_user.id) 56 | ) 57 | # Check the 'exit_users_pk' is set so we know which user to change back to 58 | self.assertIn("exit_users_pk", self.client.session) 59 | pk, backend = self.client.session["exit_users_pk"][0] 60 | self.assertEqual(str(pk), str(self.authorized_user.pk)) 61 | self.assertEqual(backend, "django.contrib.auth.backends.ModelBackend") 62 | 63 | def test_login_user_id_invalid(self): 64 | """Ensure login fails with an invalid user id""" 65 | self.client.login(username="authorized", password="pass") 66 | response = self.client.post("/su/abc/") 67 | self.assertEqual(response.status_code, 404) 68 | # User should still be logged in, but as the original user 69 | self.assertIn(auth.SESSION_KEY, self.client.session) 70 | self.assertEqual( 71 | str(self.client.session[auth.SESSION_KEY]), str(self.authorized_user.id) 72 | ) 73 | # Exit user should never get set 74 | self.assertNotIn("exit_users_pk", self.client.session) 75 | 76 | def test_login_without_permission(self): 77 | """Ensure login fails when the current user lacks permission""" 78 | self.client.login(username="unauthorized", password="pass") 79 | with self.settings(SU_LOGIN_CALLBACK=None): 80 | response = self.client.post( 81 | reverse("login_as_user", args=[self.destination_user.id]) 82 | ) 83 | self.assertEqual(response.status_code, 302) 84 | # User should still be logged in, but as the original user 85 | self.assertIn(auth.SESSION_KEY, self.client.session) 86 | self.assertEqual( 87 | str(self.client.session[auth.SESSION_KEY]), str(self.unauthorized_user.id) 88 | ) 89 | # Exit user should never get set 90 | self.assertNotIn("exit_users_pk", self.client.session) 91 | 92 | def test_custom_su_login_url(self): 93 | """Ensure user is sent to login url following successful login""" 94 | self.client.login(username="authorized", password="pass") 95 | with self.settings(SU_LOGIN_REDIRECT_URL="/foo/bar"): 96 | response = self.client.post( 97 | reverse("login_as_user", args=[self.destination_user.id]) 98 | ) 99 | self.assertEqual(response.status_code, 302) 100 | self.assertTrue("/foo/bar" in response["Location"]) 101 | 102 | def test_custom_login_action(self): 103 | """Ensure custom login action is called""" 104 | self.client.login(username="authorized", password="pass") 105 | 106 | flag = {"called": False} 107 | 108 | def custom_action(request, user): 109 | flag["called"] = True 110 | 111 | with self.settings(SU_CUSTOM_LOGIN_ACTION=custom_action): 112 | self.client.post(reverse("login_as_user", args=[self.destination_user.id])) 113 | self.assertTrue(flag["called"]) 114 | 115 | def test_last_login_not_changed(self): 116 | self.destination_user.last_login = datetime(2000, 1, 1, tzinfo=timezone.utc) 117 | self.destination_user.save() 118 | self.client.login(username="authorized", password="pass") 119 | self.client.post(reverse("login_as_user", args=[self.destination_user.id])) 120 | self.destination_user = User.objects.get(pk=self.destination_user.pk) 121 | self.assertEqual(self.destination_user.last_login.date(), date(2000, 1, 1)) 122 | # Check the update_last_login function has been reconnected to the user_logged_in signal 123 | connections = [ 124 | str(ref[1]) 125 | for ref in auth.user_logged_in.receivers 126 | if "update_last_login" in str(ref[1]) 127 | ] 128 | self.assertTrue(connections) 129 | 130 | def test_login_signal_reconnected_following_error(self): 131 | self.client.login(username="authorized", password="pass") 132 | 133 | def error_action(request, user): 134 | raise Exception() 135 | 136 | with self.settings(SU_CUSTOM_LOGIN_ACTION=error_action): 137 | try: 138 | self.client.post( 139 | reverse("login_as_user", args=[self.destination_user.id]) 140 | ) 141 | except Exception: 142 | pass 143 | # Check the update_last_login function has been reconnected to the user_logged_in signal 144 | connections = [ 145 | str(ref[1]) 146 | for ref in auth.user_logged_in.receivers 147 | if "update_last_login" in str(ref[1]) 148 | ] 149 | self.assertTrue(connections) 150 | 151 | 152 | class LoginViewTestCase(SuViewsBaseTestCase): 153 | def test_get_authorised(self): 154 | """Load the login page as an authorised user""" 155 | self.client.login(username="authorized", password="pass") 156 | response = self.client.get(reverse("su_login")) 157 | self.assertEqual(response.status_code, 200) 158 | 159 | def test_get_unauthorised(self): 160 | """Load the login page as an authorised user""" 161 | self.client.login(username="unauthorized", password="pass") 162 | response = self.client.get(reverse("su_login")) 163 | self.assertEqual(response.status_code, 302) 164 | 165 | def test_post_unauthorised(self): 166 | """Post to the login page as an authorised user""" 167 | self.client.login(username="unauthorized", password="pass") 168 | response = self.client.post(reverse("su_login")) 169 | self.assertEqual(response.status_code, 302) 170 | 171 | def test_post_valid(self): 172 | """Ensure posting valid data logs the user in""" 173 | self.client.login(username="authorized", password="pass") 174 | response = self.client.post( 175 | reverse("su_login"), data=dict(user=self.destination_user.id) 176 | ) 177 | self.assertEqual(response.status_code, 302) 178 | self.assertEqual( 179 | str(self.client.session[auth.SESSION_KEY]), str(self.destination_user.id) 180 | ) 181 | 182 | def test_post_non_existent(self): 183 | """Ensure posting a non-existent user does not log the user in""" 184 | self.client.login(username="authorized", password="pass") 185 | response = self.client.post(reverse("su_login"), data=dict(user="999")) 186 | self.assertEqual(response.status_code, 200) 187 | self.assertEqual( 188 | str(self.client.session[auth.SESSION_KEY]), str(self.authorized_user.id) 189 | ) 190 | 191 | def test_post_invalid(self): 192 | """Ensure posting invalid data redisplays the form and does not log the user in""" 193 | self.client.login(username="authorized", password="pass") 194 | response = self.client.post(reverse("su_login"), data=dict(user="abc")) 195 | self.assertEqual(response.status_code, 200) 196 | self.assertEqual( 197 | str(self.client.session[auth.SESSION_KEY]), str(self.authorized_user.id) 198 | ) 199 | 200 | 201 | class LogoutViewTestCase(SuViewsBaseTestCase): 202 | def test_valid_get(self): 203 | """Ensure user can logout via get""" 204 | s = self.client.session 205 | s["exit_users_pk"] = [ 206 | ["1", "django.contrib.auth.backends.ModelBackend"], 207 | ] 208 | s.save() 209 | response = self.client.get(reverse("su_logout")) 210 | self.assertEqual(response.status_code, 302) 211 | self.assertIn(auth.SESSION_KEY, self.client.session) 212 | self.assertEqual( 213 | str(self.client.session[auth.SESSION_KEY]), str(self.authorized_user.id) 214 | ) 215 | 216 | def test_valid_post(self): 217 | """Ensure user can logout via post""" 218 | s = self.client.session 219 | s["exit_users_pk"] = [ 220 | ["1", "django.contrib.auth.backends.ModelBackend"], 221 | ] 222 | s.save() 223 | response = self.client.post(reverse("su_logout")) 224 | self.assertEqual(response.status_code, 302) 225 | self.assertIn(auth.SESSION_KEY, self.client.session) 226 | self.assertEqual( 227 | str(self.client.session[auth.SESSION_KEY]), str(self.authorized_user.id) 228 | ) 229 | 230 | def test_no_exit_pk(self): 231 | """Ensure logout fails if no exit pk present in session""" 232 | response = self.client.get(reverse("su_logout")) 233 | self.assertEqual(response.status_code, 400) 234 | 235 | def test_non_existant_exit_pk(self): 236 | """Ensure logout fails if no exit pk present in session""" 237 | s = self.client.session 238 | s["exit_users_pk"] = [ 239 | ["999", "django.contrib.auth.backends.ModelBackend"], 240 | ] 241 | s.save() 242 | response = self.client.get(reverse("su_logout")) 243 | self.assertEqual(response.status_code, 404) 244 | 245 | def test_redirect_url(self): 246 | """Ensure logout redirect url setting respected""" 247 | s = self.client.session 248 | s["exit_users_pk"] = [ 249 | ["1", "django.contrib.auth.backends.ModelBackend"], 250 | ] 251 | s.save() 252 | with self.settings(SU_LOGOUT_REDIRECT_URL="/foo/bar"): 253 | response = self.client.get(reverse("su_logout")) 254 | self.assertEqual(response.status_code, 302) 255 | self.assertTrue("/foo/bar" in response["Location"]) 256 | -------------------------------------------------------------------------------- /django_su/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.urls import path 4 | 5 | from .views import login_as_user, su_login, su_logout 6 | 7 | 8 | urlpatterns = [ 9 | path("", su_logout, name="su_logout"), 10 | path("login/", su_login, name="su_login"), 11 | path("/", login_as_user, name="login_as_user"), 12 | ] 13 | -------------------------------------------------------------------------------- /django_su/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import warnings 4 | from collections.abc import Callable 5 | 6 | from django.conf import settings 7 | from django.utils.module_loading import import_string 8 | 9 | 10 | def su_login_callback(user): 11 | if hasattr(settings, "SU_LOGIN"): 12 | warnings.warn( 13 | "SU_LOGIN is deprecated, use SU_LOGIN_CALLBACK", 14 | DeprecationWarning, 15 | ) 16 | 17 | func = getattr(settings, "SU_LOGIN_CALLBACK", None) 18 | if func is not None: 19 | if not isinstance(func, Callable): 20 | func = import_string(func) 21 | return func(user) 22 | return user.has_perm("auth.change_user") 23 | 24 | 25 | def custom_login_action(request, user): 26 | func = getattr(settings, "SU_CUSTOM_LOGIN_ACTION", None) 27 | if func is None: 28 | return False 29 | 30 | if not isinstance(func, Callable): 31 | func = import_string(func) 32 | func(request, user) 33 | 34 | return True 35 | -------------------------------------------------------------------------------- /django_su/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import warnings 4 | 5 | from django.conf import settings 6 | from django.contrib.auth import ( 7 | BACKEND_SESSION_KEY, 8 | SESSION_KEY, 9 | authenticate, 10 | get_user_model, 11 | login, 12 | ) 13 | from django.contrib.auth.decorators import user_passes_test 14 | from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect 15 | from django.shortcuts import get_object_or_404, render 16 | from django.views.decorators.csrf import csrf_protect 17 | from django.views.decorators.http import require_http_methods 18 | 19 | from .forms import UserSuForm 20 | from .utils import custom_login_action, su_login_callback 21 | 22 | 23 | User = get_user_model() 24 | 25 | 26 | @csrf_protect 27 | @require_http_methods(["POST"]) 28 | @user_passes_test(su_login_callback) 29 | def login_as_user(request, user_id): 30 | userobj = authenticate(request=request, su=True, user_id=user_id) 31 | if not userobj: 32 | raise Http404("User not found") 33 | 34 | exit_users_pk = request.session.get("exit_users_pk", default=[]) 35 | exit_users_pk.append( 36 | (request.session[SESSION_KEY], request.session[BACKEND_SESSION_KEY]) 37 | ) 38 | 39 | maintain_last_login = hasattr(userobj, "last_login") 40 | if maintain_last_login: 41 | last_login = userobj.last_login 42 | 43 | try: 44 | if not custom_login_action(request, userobj): 45 | login(request, userobj) 46 | request.session["exit_users_pk"] = exit_users_pk 47 | finally: 48 | if maintain_last_login: 49 | userobj.last_login = last_login 50 | userobj.save(update_fields=["last_login"]) 51 | 52 | if hasattr(settings, "SU_REDIRECT_LOGIN"): 53 | warnings.warn( 54 | "SU_REDIRECT_LOGIN is deprecated, use SU_LOGIN_REDIRECT_URL", 55 | DeprecationWarning, 56 | ) 57 | 58 | return HttpResponseRedirect(getattr(settings, "SU_LOGIN_REDIRECT_URL", "/")) 59 | 60 | 61 | @csrf_protect 62 | @require_http_methods(["POST", "GET"]) 63 | @user_passes_test(su_login_callback) 64 | def su_login(request, form_class=UserSuForm, template_name="su/login.html"): 65 | form = form_class(request.POST or None) 66 | if form.is_valid(): 67 | return login_as_user(request, form.get_user().pk) 68 | 69 | return render( 70 | request, 71 | template_name, 72 | { 73 | "form": form, 74 | }, 75 | ) 76 | 77 | 78 | def su_logout(request): 79 | exit_users_pk = request.session.get("exit_users_pk", default=[]) 80 | if not exit_users_pk: 81 | return HttpResponseBadRequest(("This session was not su'ed into. Cannot exit.")) 82 | 83 | user_id, backend = exit_users_pk.pop() 84 | 85 | userobj = get_object_or_404(User, pk=user_id) 86 | userobj.backend = backend 87 | 88 | if not custom_login_action(request, userobj): 89 | login(request, userobj) 90 | request.session["exit_users_pk"] = exit_users_pk 91 | 92 | if hasattr(settings, "SU_REDIRECT_EXIT"): 93 | warnings.warn( 94 | "SU_REDIRECT_EXIT is deprecated, use SU_LOGOUT_REDIRECT_URL", 95 | DeprecationWarning, 96 | ) 97 | 98 | return HttpResponseRedirect(getattr(settings, "SU_LOGOUT_REDIRECT_URL", "/")) 99 | -------------------------------------------------------------------------------- /example/README.rst: -------------------------------------------------------------------------------- 1 | Example 2 | ======= 3 | 4 | To run the example application, make sure you have the required 5 | packages installed. You can do this using following commands : 6 | 7 | .. code-block:: bash 8 | 9 | mkvirtualenv example 10 | pip install -r example/requirements.txt 11 | 12 | This assumes you already have ``virtualenv`` and ``virtualenvwrapper`` 13 | installed and configured. 14 | 15 | Next, you can setup the django instance using : 16 | 17 | .. code-block:: bash 18 | 19 | python example/manage.py syncdb --noinput 20 | 21 | And run it : 22 | 23 | .. code-block:: bash 24 | 25 | python example/manage.py runserver 26 | 27 | Good luck! 28 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/django-su/afc8c9cdb71ea62d12ab0365721339f5a43f4512/example/__init__.py -------------------------------------------------------------------------------- /example/lookups.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from ajax_select import LookupChannel, register 4 | from django.db.models import Q 5 | from django.utils.encoding import force_text 6 | from django.utils.html import escape 7 | 8 | from django_su import get_user_model 9 | 10 | 11 | @register("django_su") 12 | class UsersLookup(LookupChannel): 13 | model = get_user_model() 14 | 15 | def get_query(self, q, request): 16 | return self.model.objects.filter( 17 | Q(username__icontains=q) | Q(pk__icontains=q) 18 | ).order_by("pk") 19 | 20 | def format_match(self, obj): 21 | return escape( 22 | force_text("%s [pk: %s]" % (obj.get_full_name() or obj.username, obj.pk)) 23 | ) 24 | 25 | def format_item_display(self, obj): 26 | return escape( 27 | force_text("%s [pk: %s]" % (obj.get_full_name() or obj.username, obj.pk)) 28 | ) 29 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | # Allow starting the app without installing the module. 12 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) 13 | 14 | execute_from_command_line(sys.argv) 15 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | django>=1.4 2 | django-form-admin 3 | django-ajax-selects 4 | -------------------------------------------------------------------------------- /example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for app project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.7/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.7/ref/settings/ 9 | """ 10 | 11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 12 | import os 13 | 14 | 15 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 16 | 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = "YOUR_SECRET_KEY" 23 | 24 | # SECURITY WARNING: don't run with debug turned on in production! 25 | DEBUG = True 26 | 27 | TEMPLATE_DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | TEMPLATE_DIRS = (os.path.join(os.path.dirname(__file__), "templates"),) 32 | 33 | TEMPLATE_CONTEXT_PROCESSORS = ( 34 | "django.core.context_processors.tz", 35 | "django.core.context_processors.i18n", 36 | "django.core.context_processors.media", 37 | "django.core.context_processors.static", 38 | "django.core.context_processors.request", 39 | "django.contrib.auth.context_processors.auth", 40 | "django.core.context_processors.debug", 41 | "django_su.context_processors.is_su", 42 | ) 43 | 44 | TEMPLATES = [ 45 | { 46 | "BACKEND": "django.template.backends.django.DjangoTemplates", 47 | "DIRS": [ 48 | os.path.join(os.path.dirname(__file__), "templates"), 49 | ], 50 | "APP_DIRS": True, 51 | "OPTIONS": { 52 | "context_processors": [ 53 | "django.template.context_processors.i18n", 54 | "django.template.context_processors.request", 55 | "django.contrib.auth.context_processors.auth", 56 | "django.template.context_processors.debug", 57 | "django.contrib.messages.context_processors.messages", 58 | "django_su.context_processors.is_su", 59 | ], 60 | }, 61 | }, 62 | ] 63 | 64 | # Application definition 65 | 66 | MIDDLEWARE = [ 67 | "django.contrib.sessions.middleware.SessionMiddleware", 68 | "django.contrib.auth.middleware.AuthenticationMiddleware", 69 | "django.contrib.messages.middleware.MessageMiddleware", 70 | ] 71 | 72 | PROJECT_APPS = [ 73 | "django_su", 74 | ] 75 | 76 | INSTALLED_APPS = [ 77 | # 'suit', # pip install django-suit 78 | "django.contrib.auth", 79 | "django.contrib.sites", 80 | "django.contrib.sessions", 81 | "django.contrib.staticfiles", 82 | "django.contrib.contenttypes", 83 | "django.contrib.admin", 84 | "django.contrib.messages", 85 | # 'guardian', 86 | "formadmin", # pip install django-form-admin 87 | "ajax_select", # pip install django-ajax-selects 88 | ] 89 | 90 | INSTALLED_APPS = PROJECT_APPS + INSTALLED_APPS 91 | 92 | ROOT_URLCONF = "example.urls" 93 | 94 | SITE_ID = 1 95 | 96 | # Database 97 | # https://docs.djangoproject.com/en/1.7/ref/settings/#databases 98 | 99 | DATABASES = { 100 | "default": { 101 | "ENGINE": "django.db.backends.sqlite3", 102 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 103 | } 104 | } 105 | 106 | # Internationalization 107 | # https://docs.djangoproject.com/en/1.7/topics/i18n/ 108 | 109 | LANGUAGE_CODE = "en-us" 110 | 111 | TIME_ZONE = "UTC" 112 | 113 | USE_I18N = True 114 | 115 | USE_L10N = True 116 | 117 | USE_TZ = True 118 | 119 | # Static files (CSS, JavaScript, Images) 120 | # https://docs.djangoproject.com/en/1.7/howto/static-files/ 121 | 122 | STATIC_URL = "/static/" 123 | 124 | AUTHENTICATION_BACKENDS = ( 125 | "django.contrib.auth.backends.ModelBackend", 126 | # "guardian.backends.ObjectPermissionBackend", 127 | "django_su.backends.SuBackend", 128 | ) 129 | 130 | # ANONYMOUS_USER_ID = -1 131 | 132 | # URL to redirect after the login. 133 | # Default: "/" 134 | SU_LOGIN_REDIRECT_URL = "/" 135 | 136 | # URL to redirect after the logout. 137 | # Default: "/" 138 | SU_LOGOUT_REDIRECT_URL = "/" 139 | 140 | # A function to specify the perms that the user must have can use django_su 141 | # Default: None 142 | SU_LOGIN_CALLBACK = "example.utils.su_login_callback" 143 | 144 | # A function to override the django.contrib.auth.login(request, user) 145 | # function so you can set session data, etc. 146 | # Default: None 147 | SU_CUSTOM_LOGIN_ACTION = "example.utils.custom_login" 148 | 149 | if "ajax_select" in INSTALLED_APPS: 150 | AJAX_LOOKUP_CHANNELS = { 151 | "django_su": ("example.lookups", "UsersLookup"), 152 | } 153 | -------------------------------------------------------------------------------- /example/templates/404.html: -------------------------------------------------------------------------------- 1 |

    Not Found

    2 | -------------------------------------------------------------------------------- /example/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% include "su/is_su.html" %} 5 | 6 | O hai, {{ user }}! 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.urls import include, path 4 | from django.views.generic import TemplateView 5 | 6 | 7 | admin.autodiscover() 8 | 9 | urlpatterns = [ 10 | path("admin/", admin.site.urls), 11 | path("su/", include("django_su.urls")), 12 | path("", TemplateView.as_view(template_name="index.html")), 13 | ] 14 | 15 | if "ajax_select" in settings.INSTALLED_APPS: 16 | from ajax_select import urls as ajax_select_urls 17 | 18 | urlpatterns += [ 19 | path("admin/lookups/", include(ajax_select_urls)), 20 | ] 21 | -------------------------------------------------------------------------------- /example/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.contrib.auth import BACKEND_SESSION_KEY, SESSION_KEY, get_user_model 4 | 5 | 6 | try: 7 | from django.contrib.auth import HASH_SESSION_KEY 8 | except ImportError: 9 | HASH_SESSION_KEY = "_auth_user_hash" 10 | 11 | User = get_user_model() 12 | 13 | 14 | def su_login_callback(user): 15 | if user.is_active and user.is_staff: 16 | return True 17 | return user.has_perm("auth.change_user") 18 | 19 | 20 | def _get_user_session_key(request): 21 | # This value in the session is always serialized to a string, so we need 22 | # to convert it back to Python whenever we access it. 23 | 24 | return User._meta.pk.to_python(request.session[SESSION_KEY]) 25 | 26 | 27 | def custom_login(request, user): 28 | session_auth_hash = "" 29 | if user is None: 30 | user = request.user 31 | if hasattr(user, "get_session_auth_hash"): 32 | session_auth_hash = user.get_session_auth_hash() 33 | 34 | if SESSION_KEY in request.session: 35 | if _get_user_session_key(request) != user.pk or ( 36 | session_auth_hash 37 | and request.session.get(HASH_SESSION_KEY) != session_auth_hash 38 | ): 39 | # To avoid reusing another user's session, create a new, empty 40 | # session if the existing session corresponds to a different 41 | # authenticated user. 42 | request.session.flush() 43 | else: 44 | request.session.cycle_key() 45 | request.session[SESSION_KEY] = user._meta.pk.value_to_string(user) 46 | request.session[BACKEND_SESSION_KEY] = user.backend 47 | request.session[HASH_SESSION_KEY] = session_auth_hash 48 | if hasattr(request, "user"): 49 | request.user = user 50 | 51 | try: 52 | from django.middleware.csrf import rotate_token 53 | 54 | rotate_token(request) 55 | except ImportError: 56 | pass 57 | -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | coveralls 2 | django-form-admin 3 | django-ajax-selects 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [zest.releaser] 2 | current_version = 0.9.0 3 | commit = True 4 | tag = True 5 | 6 | [wheel] 7 | # create "py2.py3-none-any.whl" package 8 | universal = 1 9 | 10 | [flake8] 11 | max-line-length = 110 12 | ignore = 13 | # additional newline in imports 14 | I202, 15 | # line break before binary operator 16 | W503, 17 | exclude = 18 | *migrations/*, 19 | docs/, 20 | .eggs/ 21 | application-import-names = django_su 22 | import-order-style = pep8 23 | 24 | [isort] 25 | multi_line_output = 3 26 | lines_after_imports = 2 27 | profile = black 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import codecs 4 | import os 5 | import sys 6 | 7 | from setuptools import find_packages, setup 8 | 9 | 10 | # When creating the sdist, make sure the django.mo file also exists: 11 | if "sdist" in sys.argv or "develop" in sys.argv: 12 | os.chdir("django_su") 13 | try: 14 | from django.core import management 15 | 16 | management.call_command("compilemessages", stdout=sys.stderr, verbosity=1) 17 | except ImportError: 18 | if "sdist" in sys.argv: 19 | raise 20 | finally: 21 | os.chdir("..") 22 | 23 | 24 | def read(*parts): 25 | file_path = os.path.join(os.path.dirname(__file__), *parts) 26 | return codecs.open(file_path, encoding="utf-8").read() 27 | 28 | 29 | setup( 30 | name="django-su", 31 | version=read("VERSION"), 32 | license="MIT License", 33 | install_requires=[ 34 | "django>=2.2", 35 | ], 36 | requires=[ 37 | "Django (>=2.2)", 38 | ], 39 | description="Login as any user from the Django admin interface, then switch back when done", 40 | long_description=read("README.rst"), 41 | author="Adam Charnock", 42 | author_email="adam@adamcharnock.com", 43 | maintainer="Basil Shubin", 44 | maintainer_email="basil.shubin@gmail.com", 45 | url="http://github.com/adamcharnock/django-su", 46 | download_url="https://github.com/adamcharnock/django-su/zipball/master", 47 | packages=find_packages(exclude=("example*", "*.tests*")), 48 | include_package_data=True, 49 | zip_safe=False, 50 | classifiers=[ 51 | "Development Status :: 5 - Production/Stable", 52 | "Environment :: Web Environment", 53 | "Framework :: Django", 54 | "Intended Audience :: Developers", 55 | "Intended Audience :: System Administrators", 56 | "License :: OSI Approved :: MIT License", 57 | "Operating System :: OS Independent", 58 | "Programming Language :: Python", 59 | "Programming Language :: Python :: 3", 60 | "Programming Language :: Python :: 3.6", 61 | "Programming Language :: Python :: 3.7", 62 | "Programming Language :: Python :: 3.8", 63 | "Programming Language :: Python :: 3.9", 64 | "Topic :: Internet :: WWW/HTTP", 65 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 66 | ], 67 | ) 68 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 5 | 6 | TEMPLATE_DIRS = [ 7 | os.path.join(os.path.dirname(os.path.realpath(__file__)), "test_templates"), 8 | ] 9 | 10 | TEMPLATES = [ 11 | { 12 | "BACKEND": "django.template.backends.django.DjangoTemplates", 13 | "DIRS": TEMPLATE_DIRS, 14 | "APP_DIRS": True, 15 | }, 16 | ] 17 | 18 | INSTALLED_APPS = [ 19 | "django.contrib.auth", 20 | "django.contrib.sites", 21 | "django.contrib.sessions", 22 | "django.contrib.staticfiles", 23 | "django.contrib.contenttypes", 24 | "django_su", 25 | "django.contrib.admin", 26 | ] 27 | 28 | MIDDLEWARE_CLASSES = [ 29 | "django.contrib.sessions.middleware.SessionMiddleware", 30 | "django.contrib.auth.middleware.AuthenticationMiddleware", 31 | ] 32 | 33 | MIDDLEWARE = MIDDLEWARE_CLASSES 34 | 35 | ROOT_URLCONF = "test_urls" 36 | 37 | SITE_ID = 1 38 | 39 | STATIC_URL = "/static/" 40 | 41 | AUTHENTICATION_BACKENDS = [ 42 | "django.contrib.auth.backends.ModelBackend", 43 | "django_su.backends.SuBackend", 44 | ] 45 | -------------------------------------------------------------------------------- /test_templates/404.html: -------------------------------------------------------------------------------- 1 |

    Not Found

    2 | -------------------------------------------------------------------------------- /test_urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | 5 | urlpatterns = [ 6 | path("admin/", admin.site.urls), 7 | path("su/", include("django_su.urls")), 8 | ] 9 | --------------------------------------------------------------------------------