├── .github └── workflows │ └── tox.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── django_currentuser ├── __init__.py ├── db │ ├── __init__.py │ └── models │ │ ├── __init__.py │ │ └── fields.py └── middleware.py ├── manage.py ├── pyproject.toml ├── settings.py ├── setup.cfg ├── setup.py ├── tests ├── setup.py └── testapp │ ├── __init__.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ ├── sixmock.py │ ├── tests.py │ └── urls.py └── tox.ini /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: Tox 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | max-parallel: 4 15 | matrix: 16 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v3 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install Dependencies 25 | run: | 26 | set -xu 27 | python -m pip install --upgrade pip 28 | pip install tox==4.6.3 29 | - name: Run Tests 30 | run: | 31 | tox --skip-missing-interpreters 32 | 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | 37 | # Complexity 38 | output/*.html 39 | output/*/index.html 40 | 41 | # Sphinx 42 | docs/_build 43 | 44 | # rst2html 45 | readme-errors 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023-, zsoldosp 2 | Copyright (c) 2017-2023, Paessler AG 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | 9 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 10 | 11 | * Neither the name of django-currentuser nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | recursive-include django_currentuser *.html *.png *.gif *js *.css *jpg *jpeg *svg *py 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-python clean-build docs clean-tox 2 | #PYPI_SERVER?=pypi 3 | PYPI_SERVER?=testpypi 4 | ifeq ($(PYPI_SERVER),testpypi) 5 | TWINE_PASSWORD=${TEST_TWINE_PASSWORD} 6 | else 7 | TWINE_PASSWORD=${CURRENTUSER_TWINE_PASSWORD} 8 | endif 9 | RELEASE_PYTHON=python3.13 10 | RELEASE_VENV=release-venv-${RELEASE_PYTHON} 11 | RELEASE_PYTHON_ACTIVATE=${RELEASE_VENV}/bin/activate 12 | GIT_REMOTE_NAME?=origin 13 | SHELL=/bin/bash 14 | VERSION=$(shell ${RELEASE_PYTHON} -c"import django_currentuser as m; print(m.__version__)") 15 | PACKAGE_FILE_TGZ=dist/django_currentuser-${VERSION}.tar.gz 16 | PACKAGE_FILE_WHL=dist/django_currentuser-${VERSION}-py3-none-any.whl 17 | 18 | help: 19 | @echo "clean-build - remove build artifacts" 20 | @echo "clean-python - remove Python file artifacts" 21 | @echo "clean-tox - remove test artifacts" 22 | @echo "lint - check style with flake8" 23 | @echo "test - run tests quickly with the default Python" 24 | @echo "test-all - run tests on every Python version with tox" 25 | @echo "coverage - check code coverage quickly with the default Python" 26 | @echo "docs - generate Sphinx HTML documentation, including API docs" 27 | @echo "tag - git tag the current version which creates a new pypi package with travis-ci's help" 28 | @echo "package- build the sdist/wheel" 29 | @echo "release- package, tag, and publush" 30 | 31 | clean: clean-build clean-python clean-tox 32 | 33 | clean-build: 34 | rm -fr build/ 35 | rm -fr dist/ 36 | find -name *.egg-info -type d | xargs rm -rf 37 | 38 | clean-python: 39 | find . -name '*.pyc' -exec rm -f {} + 40 | find . -name '*.pyo' -exec rm -f {} + 41 | find . -name '*~' -exec rm -f {} + 42 | find . -name '__pycache__' -type d -exec rm -rf {} + 43 | 44 | clean-tox: 45 | if [[ -d .tox ]]; then rm -r .tox; fi 46 | 47 | lint: 48 | flake8 django_currentuser tests --max-complexity=10 49 | 50 | test: 51 | python manage.py test testapp --traceback 52 | 53 | test-all: clean-tox 54 | tox 55 | 56 | coverage: 57 | coverage run --source django_currentuser setup.py test 58 | coverage report -m 59 | coverage html 60 | open htmlcov/index.html 61 | 62 | docs: outfile=/tmp/readme-errors 63 | docs: 64 | rst2html.py README.rst > /dev/null 2> ${outfile} 65 | cat ${outfile} 66 | test 0 -eq `cat ${outfile} | wc -l` 67 | 68 | tag: TAG:=v${VERSION} 69 | tag: exit_code=$(shell git ls-remote ${GIT_REMOTE_NAME} | grep -q tags/${TAG}; echo $$?) 70 | tag: 71 | ifeq ($(exit_code),0) 72 | @echo "Tag ${TAG} already present" 73 | else 74 | @echo "git tag -a ${TAG} -m"${TAG}"; git push --tags ${GIT_REMOTE_NAME}" 75 | endif 76 | 77 | ${RELEASE_VENV}: 78 | virtualenv --python ${RELEASE_PYTHON} ${RELEASE_VENV} 79 | 80 | build-deps: ${RELEASE_VENV} 81 | source ${RELEASE_PYTHON_ACTIVATE} && python -m pip install --upgrade build 82 | source ${RELEASE_PYTHON_ACTIVATE} && python -m pip install --upgrade twine 83 | 84 | 85 | ${PACKAGE_FILE_TGZ}: django_currentuser/ pyproject.toml Makefile setup.py setup.cfg 86 | ${PACKAGE_FILE_WHL}: django_currentuser/ pyproject.toml Makefile setup.py setup.cfg 87 | source ${RELEASE_PYTHON_ACTIVATE} && python -m build 88 | 89 | package: build-deps clean-build clean-python ${PACKAGE_FILE} ${PACKAGE_FILE_WHL} 90 | 91 | 92 | release: package 93 | ifeq ($(TWINE_PASSWORD),) 94 | echo TWINE_PASSWORD empty 95 | echo "USE env vars TEST_TWINE_PASSWORD/CURRENTUSER_TWINE_PASSWORD env vars before invoking make" 96 | false 97 | endif 98 | twine check dist/* 99 | echo "if the release fails, setup a ~/pypirc file as per https://packaging.python.org/en/latest/tutorials/packaging-projects/" 100 | # env | grep TWINE 101 | source ${RELEASE_PYTHON_ACTIVATE} && TWINE_PASSWORD=${TWINE_PASSWORD} python -m twine upload --repository ${PYPI_SERVER} dist/* --verbose 102 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | django-currentuser 3 | ============================= 4 | 5 | .. contents:: Conveniently store reference to request user on thread/db level. 6 | 7 | Quickstart 8 | ---------- 9 | 10 | Install django-currentuser:: 11 | 12 | pip install django-currentuser 13 | 14 | Note: if there is a new Django version released that the library hasn't been 15 | upgraded to support yet, e.g.: 16 | 17 | The conflict is caused by: 18 | The user requested django==5.1 19 | django-currentuser 0.8.0 depends on Django<5.1 and >=4.2 20 | 21 | you can try to install it with the unsupported/untested Django version by 22 | using the `DJANGO_CURRENTUSER_USE_UNSUPPORTED_DJANGO` environment variable 23 | 24 | DJANGO_CURRENTUSER_USE_UNSUPPORTED_DJANGO=1 pip install django-currentuser 25 | 26 | Ade it to the middleware classes in your settings.py:: 27 | 28 | MIDDLEWARE = ( 29 | ..., 30 | 'django_currentuser.middleware.ThreadLocalUserMiddleware', 31 | ) 32 | 33 | Then use it in a project:: 34 | 35 | from django_currentuser.middleware import ( 36 | get_current_user, get_current_authenticated_user) 37 | 38 | # As model field: 39 | from django_currentuser.db.models import CurrentUserField 40 | class Foo(models.Model): 41 | created_by = CurrentUserField() 42 | updated_by = CurrentUserField(on_update=True) 43 | 44 | 45 | Differences to django-cuser 46 | --------------------------- 47 | 48 | Both libraries serve the same purpose, but be aware of these 49 | differences (as of django-cuser v.2017.3.16): 50 | 51 | - django-currentuser's CurrentUserField stores the reference to the request user 52 | at initialization of the model instance and still allows you to overwrite the 53 | value before saving. django-cuser sets the value in the pre_save handler 54 | of the field just before writing it to the database. Intermediate changes 55 | will be ignored. 56 | 57 | - django-cuser deletes the user reference from the thread after finishing a 58 | response and it will therefore no longer be available for testing purposes. 59 | 60 | Supported Versions 61 | ------------------ 62 | * for django-currentuser`, fixes are always made against the latest version 63 | * for Python, support is guided by https://devguide.python.org/versions/#supported-versions 64 | * for Django, support is guided by 65 | https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django 66 | and https://www.djangoproject.com/download/#supported-versions (assuming the Python version 67 | listed there is supported) 68 | 69 | Note on [semver](https://semver.org/): While the fact that Django support stopped for version 70 | X, doesn't have to mean it's a breaking change for this lib, as it would be just a 71 | backward-compatible metadata patch, no code change. However, at some point support needs to be 72 | dropped - so for simplicity, the project follows what Django supports. If someone needs to use 73 | different, unsupported version, `DJANGO_CURRENTUSER_USE_UNSUPPORTED_DJANGO` allows for it. 74 | 75 | Release Notes 76 | ------------- 77 | 78 | * 0.9.0 79 | 80 | * by @bartvanandel 81 | * add support for Python 3.13 82 | * add support for Django 5.2 83 | * drop support for Python 3.8 84 | * drop support for Django 5.0 85 | 86 | * 0.8.0 87 | 88 | * add support for Django 5.1 89 | * drop support for Django 3.2 90 | * introduce `DJANGO_CURRENTUSER_USE_UNSUPPORTED_DJANGO` environment variable 91 | to make upgrades easier 92 | 93 | * 0.7.0 94 | 95 | * add support for Django 5.0 96 | * add support for Python 3.12 97 | * drop support for Django 4.0 and 4.1 98 | 99 | * 0.6.1 100 | 101 | * remove project transfer warning from README in order not to scare people away from the project 102 | 103 | * 0.6.0 104 | 105 | * add support for Django 4.0, 4.1, and 4.2 106 | * add support for Python 3.11 107 | * drop support for Python 3.6 and 3.7 108 | 109 | * 0.5.3 - add support for Django 3.2 and Python 3.9 110 | 111 | * 0.5.2 - Fixed Django deprecation warning about using `ugettext_lazy()` 112 | 113 | * 0.5.1 - add support for Django 3.1 and Python 3.8 114 | 115 | * 0.5.0 116 | - add support for update on save (thank you @felubra) 117 | - no longer build on Python 3.5, deprecated 118 | 119 | * 0.4.3 - add support for Django 3.0 120 | 121 | * 0.4.2 - Minor fix for supported Django and Python versions 122 | 123 | * 0.4.0 - update supported versions 124 | 125 | - drop support for Python 3.4 126 | - drop support for Django 2.0 127 | - add support for Python 3.7 128 | - add support for Django 2.2 129 | - update tox3travis.py to not loose deployment feature 130 | 131 | * 0.3.4 - Use public Travis for packaging to remove dependency on outdated build 132 | system 133 | * 0.3.3 - drop Python 3.7 support due to build process problems 134 | * 0.3.1 - attempt to add Python 3.7 support 135 | * 0.3.0 - update supported versions according to 136 | https://www.djangoproject.com/download/#supported-versions and 137 | https://devguide.python.org/#status-of-python-branches 138 | 139 | - drop support for Python 3.2 140 | 141 | * 0.2.3 - support custom user model, drop Django 1.10 support 142 | * 0.2.2 - support Django 2.0 143 | * 0.2.1 - version fixes #9 144 | 145 | - support Django 1.11.x and not just 1.11.0 146 | 147 | * 0.2.0 - New middleclass format 148 | 149 | - Adapt to new object based middle class format of Django 1.10+ 150 | - Drop support for deprecated Django versions 1.8 and 1.9 151 | 152 | * 0.1.1 - minor release 153 | 154 | - suppress warning for passed kwargs as long as they match the defaults (avoids them being printed during running tests when fields are cloned) 155 | 156 | * 0.1.0 - initial release 157 | 158 | - provides middleware + methods to set + retrieve reference of currently logged in user from thread 159 | - provides CurrentUserField that by default stores the currently logged in user 160 | - supports Django 1.10, 1.11 on python 2.7, 3.4, 3.5, and 3.6 - as per the `official django docs `_ 161 | 162 | 163 | .. contributing start 164 | 165 | Contributing 166 | ------------ 167 | 168 | As an open source project, we welcome contributions. 169 | 170 | The code lives on `github `_. 171 | 172 | Reporting issues/improvements 173 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 174 | 175 | Please open an `issue on github `_ 176 | or provide a `pull request `_ 177 | whether for code or for the documentation. 178 | 179 | For non-trivial changes, we kindly ask you to open an issue, as it might be rejected. 180 | However, if the diff of a pull request better illustrates the point, feel free to make 181 | it a pull request anyway. 182 | 183 | Pull Requests 184 | ~~~~~~~~~~~~~ 185 | 186 | * for code changes 187 | 188 | * it must have tests covering the change. You might be asked to cover missing scenarios 189 | * the latest ``flake8`` will be run and shouldn't produce any warning 190 | * if the change is significant enough, documentation has to be provided 191 | 192 | To trigger the packaging, run `make release` on the master branch with a changed 193 | version number. 194 | 195 | Setting up all Python versions 196 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 197 | 198 | :: 199 | 200 | sudo apt-get -y install software-properties-common 201 | sudo add-apt-repository ppa:deadsnakes/ppa 202 | sudo apt-get update 203 | for version in 3.9 3.10 3.11 3.12 3.13; do 204 | py=python$version 205 | if ! which ${py}; then 206 | sudo apt-get -y install ${py} ${py}-dev 207 | fi 208 | done 209 | sudo add-apt-repository --remove ppa:deadsnakes/ppa 210 | sudo apt-get update 211 | 212 | Code of Conduct 213 | ~~~~~~~~~~~~~~~ 214 | 215 | As it is a Django extension, it follows 216 | `Django's own Code of Conduct `_. 217 | As there is no mailing list yet, please use `github issues`_ 218 | 219 | Contributors 220 | ~~~~~~~~~~~~ 221 | Current maintainer: @zsoldosp 222 | Initial development & maintenance: @PaesslerAG 223 | 224 | For contributors, see `github contributors`_. 225 | 226 | 227 | .. contributing end 228 | 229 | 230 | .. _github contributors: https://github.com/zsoldosp/django-currentuser/graphs/contributors 231 | .. _github issues: https://github.com/zsoldosp/django-currentuser/issues 232 | -------------------------------------------------------------------------------- /django_currentuser/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.9.0" 2 | -------------------------------------------------------------------------------- /django_currentuser/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsoldosp/django-currentuser/1a7c187eed8c212188e8dff936d1906970ebe11f/django_currentuser/db/__init__.py -------------------------------------------------------------------------------- /django_currentuser/db/models/__init__.py: -------------------------------------------------------------------------------- 1 | from django_currentuser.db.models.fields import CurrentUserField 2 | 3 | 4 | __all__ = ['CurrentUserField'] 5 | -------------------------------------------------------------------------------- /django_currentuser/db/models/fields.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | from django.utils.translation import gettext_lazy as _ 6 | from django_currentuser.middleware import get_current_authenticated_user 7 | 8 | 9 | class CurrentUserField(models.ForeignKey): 10 | 11 | warning = ("You passed an argument to CurrentUserField that will be " 12 | "ignored. Avoid args and following kwargs: default, null, to.") 13 | description = _( 14 | 'as default value sets the current logged in user if available') 15 | defaults = dict(null=True, default=get_current_authenticated_user, 16 | to=settings.AUTH_USER_MODEL) 17 | 18 | def __init__(self, *args, **kwargs): 19 | self.on_update = kwargs.pop("on_update", False) 20 | 21 | # If `to` is present in kwargs, and the same when ignoring case then 22 | # update `to` to use the defaults. 23 | # Fix for https://github.com/zsoldosp/django-currentuser/issues/43 24 | if "to" in kwargs \ 25 | and kwargs["to"].lower() == self.defaults['to'].lower(): 26 | kwargs["to"] = self.defaults['to'] 27 | 28 | self._warn_for_shadowing_args(*args, **kwargs) 29 | 30 | if "on_delete" not in kwargs: 31 | kwargs["on_delete"] = models.CASCADE 32 | 33 | if self.on_update: 34 | kwargs["editable"] = False 35 | kwargs["blank"] = True 36 | 37 | kwargs.update(self.defaults) 38 | super(CurrentUserField, self).__init__(**kwargs) 39 | 40 | def deconstruct(self): 41 | name, path, args, kwargs = super(CurrentUserField, self).deconstruct() 42 | if self.on_update: 43 | kwargs['on_update'] = self.on_update 44 | del kwargs["editable"] 45 | del kwargs["blank"] 46 | 47 | return name, path, args, kwargs 48 | 49 | def pre_save(self, model_instance, add): 50 | if self.on_update: 51 | value = get_current_authenticated_user() 52 | if value is not None: 53 | value = value.pk 54 | setattr(model_instance, self.attname, value) 55 | return value 56 | else: 57 | return super(CurrentUserField, self).pre_save(model_instance, add) 58 | 59 | def _warn_for_shadowing_args(self, *args, **kwargs): 60 | if args: 61 | warnings.warn(self.warning) 62 | else: 63 | for key in set(kwargs).intersection(set(self.defaults.keys())): 64 | if not kwargs[key] == self.defaults[key]: 65 | warnings.warn(self.warning) 66 | break 67 | -------------------------------------------------------------------------------- /django_currentuser/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.models import AnonymousUser 3 | from threading import local 4 | 5 | USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user') 6 | 7 | _thread_locals = local() 8 | 9 | 10 | def _do_set_current_user(user_fun): 11 | setattr(_thread_locals, USER_ATTR_NAME, user_fun.__get__(user_fun, local)) 12 | 13 | 14 | def _set_current_user(user=None): 15 | ''' 16 | Sets current user in local thread. 17 | 18 | Can be used as a hook e.g. for shell jobs (when request object is not 19 | available). 20 | ''' 21 | _do_set_current_user(lambda self: user) 22 | 23 | 24 | class SetCurrentUser: 25 | def __init__(this, request): 26 | this.request = request 27 | 28 | def __enter__(this): 29 | _do_set_current_user(lambda self: getattr(this.request, 'user', None)) 30 | 31 | def __exit__(this, type, value, traceback): 32 | _do_set_current_user(lambda self: None) 33 | 34 | 35 | class ThreadLocalUserMiddleware(object): 36 | 37 | def __init__(self, get_response): 38 | self.get_response = get_response 39 | 40 | def __call__(self, request): 41 | # request.user closure; asserts laziness; 42 | # memorization is implemented in 43 | # request.user (non-data descriptor) 44 | with SetCurrentUser(request): 45 | response = self.get_response(request) 46 | return response 47 | 48 | 49 | def get_current_user(): 50 | current_user = getattr(_thread_locals, USER_ATTR_NAME, None) 51 | if callable(current_user): 52 | return current_user() 53 | return current_user 54 | 55 | 56 | def get_current_authenticated_user(): 57 | current_user = get_current_user() 58 | if isinstance(current_user, AnonymousUser): 59 | return None 60 | return current_user 61 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0", "setuptools-scm[toml]>=5.0.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-currentuser" 7 | dynamic = ["version", "readme", "dependencies"] 8 | authors = [{ name = "Peter Zsoldos", email = "hello@zsoldosp.eu" }] 9 | maintainers = [{ name = "Peter Zsoldos", email = "hello@zsoldosp.eu" }] 10 | description = "Conveniently store reference to request user on thread/db level." 11 | requires-python = ">=3.9" 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Intended Audience :: Developers", 15 | "License :: OSI Approved :: BSD License", 16 | "Operating System :: OS Independent", 17 | "Natural Language :: English", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: 3.13", 23 | "Framework :: Django", 24 | "Framework :: Django :: 4.2", 25 | "Framework :: Django :: 5.1", 26 | "Framework :: Django :: 5.2", 27 | ] 28 | 29 | [project.urls] 30 | "Homepage" = "https://github.com/zsoldosp/django-currentuser" 31 | "Bug Tracker" = "https://github.com/zsoldosp/django-currentuser/issues" 32 | 33 | [tool.setuptools] 34 | packages = ["django_currentuser"] 35 | 36 | [tool.setuptools.dynamic] 37 | version = { attr = "django_currentuser.__version__" } 38 | readme = { file = ["README.rst"] } 39 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for autodata project. 2 | 3 | DEBUG = True 4 | 5 | TEMPLATES = [ 6 | { 7 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 8 | 'DIRS': [], 9 | 'APP_DIRS': True, 10 | 'OPTIONS': { 11 | 'context_processors': [ 12 | # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this 13 | # list if you haven't customized them: 14 | 'django.contrib.auth.context_processors.auth', 15 | 'django.template.context_processors.debug', 16 | 'django.template.context_processors.i18n', 17 | 'django.template.context_processors.media', 18 | 'django.template.context_processors.static', 19 | 'django.template.context_processors.tz', 20 | 'django.contrib.messages.context_processors.messages', 21 | ], 22 | }, 23 | }, 24 | ] 25 | 26 | DATABASES = { 27 | 'default': { 28 | 'ENGINE': 'django.db.backends.sqlite3', 29 | } 30 | } 31 | 32 | MIDDLEWARE = ( 33 | 'django_currentuser.middleware.ThreadLocalUserMiddleware', 34 | 'django.contrib.sessions.middleware.SessionMiddleware', 35 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 36 | 'django.contrib.messages.middleware.MessageMiddleware', 37 | ) 38 | 39 | # Make this unique, and don't share it with anybody. 40 | SECRET_KEY = 'mq%31q+sjj^)m^tvy(klwqw6ksv7du2yzdf9yn78iga*r%8w^t-django_currentuser' 41 | 42 | INSTALLED_APPS = ( 43 | 'django.contrib.admin', 44 | 'django.contrib.auth', 45 | 'django.contrib.contenttypes', 46 | 'django.contrib.sessions', 47 | 'django.contrib.messages', 48 | 'django_currentuser', 49 | 'testapp', 50 | ) 51 | 52 | STATIC_URL = '/static/' 53 | 54 | ROOT_URLCONF = 'testapp.urls' 55 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from setuptools import setup 4 | import os 5 | 6 | use_unsupported_django = os.environ.get('DJANGO_CURRENTUSER_USE_UNSUPPORTED_DJANGO', '0') == '1' 7 | 8 | dependencies = ['Django'] if use_unsupported_django else ["Django>=4.2, <6.0"] 9 | 10 | 11 | if __name__ == "__main__": 12 | setup(install_requires=dependencies) 13 | -------------------------------------------------------------------------------- /tests/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | 6 | try: 7 | from setuptools import setup 8 | except ImportError: 9 | from distutils.core import setup 10 | 11 | if sys.argv[-1] in ('publish', 'release'): 12 | raise Exception('this is a test app, do not release it!') 13 | 14 | readme = 'A simple test application to test django_currentuser' 15 | 16 | setup( 17 | name='testapp', 18 | version='0.0.0', 19 | description=readme, 20 | long_description=readme, 21 | packages=[ 22 | 'testapp', 23 | ], 24 | include_package_data=True, 25 | install_requires=[ 26 | ], 27 | license="BSD", 28 | zip_safe=False, 29 | keywords='django-currentuser-test-app', 30 | ) 31 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsoldosp/django-currentuser/1a7c187eed8c212188e8dff936d1906970ebe11f/tests/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.25 on 2019-10-21 17:06 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import django_currentuser.db.models.fields 9 | import django_currentuser.middleware 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | initial = True 15 | 16 | dependencies = [ 17 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='TestModelDefaultBehavior', 23 | fields=[ 24 | ('id', 25 | models.AutoField( 26 | auto_created=True, 27 | primary_key=True, 28 | serialize=False, 29 | verbose_name='ID')), 30 | ('created_by', 31 | django_currentuser.db.models.fields.CurrentUserField( 32 | default=django_currentuser.middleware. 33 | get_current_authenticated_user, 34 | null=True, 35 | on_delete=django.db.models.deletion.CASCADE, 36 | to=settings.AUTH_USER_MODEL)), 37 | ], 38 | ), 39 | migrations.CreateModel( 40 | name='TestModelOnUpdate', 41 | fields=[ 42 | ('id', 43 | models.AutoField( 44 | auto_created=True, 45 | primary_key=True, 46 | serialize=False, 47 | verbose_name='ID')), 48 | ('updated_by', 49 | django_currentuser.db.models.fields.CurrentUserField( 50 | default=django_currentuser.middleware. 51 | get_current_authenticated_user, 52 | null=True, 53 | on_delete=django.db.models.deletion.CASCADE, 54 | on_update=True, 55 | to=settings.AUTH_USER_MODEL)), 56 | ], 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /tests/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsoldosp/django-currentuser/1a7c187eed8c212188e8dff936d1906970ebe11f/tests/testapp/migrations/__init__.py -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django_currentuser.db.models import CurrentUserField 3 | 4 | 5 | class TestModelOnUpdate(models.Model): 6 | updated_by = CurrentUserField(on_update=True) 7 | 8 | 9 | class TestModelDefaultBehavior(models.Model): 10 | created_by = CurrentUserField() 11 | -------------------------------------------------------------------------------- /tests/testapp/sixmock.py: -------------------------------------------------------------------------------- 1 | try: 2 | from unittest.mock import patch, Mock, DEFAULT, call # noqa: F401 3 | except ImportError: 4 | from mock import patch, Mock, DEFAULT, call # noqa: F401 5 | -------------------------------------------------------------------------------- /tests/testapp/tests.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.conf import settings 4 | from django.contrib.auth.models import User, AnonymousUser, Group 5 | from django.urls import reverse 6 | from django.db import models 7 | from django.test.testcases import TestCase 8 | 9 | from hamcrest import assert_that, instance_of, equal_to, is_, empty, has_length 10 | 11 | from django_currentuser.middleware import ( 12 | SetCurrentUser, 13 | get_current_user, 14 | _set_current_user, 15 | get_current_authenticated_user 16 | ) 17 | from django_currentuser.db.models import CurrentUserField 18 | 19 | from .sixmock import patch 20 | from .models import TestModelOnUpdate, TestModelDefaultBehavior 21 | 22 | 23 | class TestUserBase(TestCase): 24 | def tearDown(self): 25 | super(TestUserBase, self).tearDown() 26 | _set_current_user(None) 27 | 28 | def setUp(self): 29 | super(TestUserBase, self).setUp() 30 | self.user1 = User.objects.create(username="user1", is_staff=True) 31 | self.user1.set_password("pw1") 32 | self.user1.save() 33 | self.user2 = User.objects.create(username="user2", is_staff=True) 34 | self.user2.set_password("pw2") 35 | self.user2.save() 36 | 37 | def login_and_create(self, username, password): 38 | data = {"username": username, "password": password} 39 | self.client.post(reverse("login"), follow=True, data=data) 40 | self.client.post(reverse("create"), follow=True, data=data) 41 | 42 | def login_and_update(self, username, password, pk): 43 | data = {"username": username, "password": password} 44 | self.client.post(reverse("login"), follow=True, data=data) 45 | self.client.patch(reverse("update", args=[pk]), follow=True, data=data) 46 | 47 | 48 | class TestSetUserToThread(TestUserBase): 49 | 50 | @patch.object(SetCurrentUser, "__exit__", lambda *args, **kwargs: None) 51 | def test__local_thread_var_is_set_to_logged_in_user(self): 52 | _set_current_user(None) 53 | self.assertIsNone(get_current_user()) 54 | 55 | self.login_and_create(username="user1", password="pw1") 56 | self.assertEqual(self.user1, get_current_user()) 57 | self.client.logout() 58 | 59 | self.login_and_create(username="user2", password="pw2") 60 | self.assertEqual(self.user2, get_current_user()) 61 | self.client.logout() 62 | 63 | self.client.get("/") 64 | current_user = get_current_user() 65 | assert_that(current_user, instance_of(AnonymousUser)) 66 | 67 | 68 | class GetCurrentPersistedUserTestCase(TestCase): 69 | 70 | def test_if_user_is_none_it_is_none(self): 71 | self.assert_becomes(current_user=None, expected_thread_user=None) 72 | 73 | def test_if_user_then_its_the_user(self): 74 | user = User(email='jane@acme.org') 75 | self.assert_becomes(current_user=user, expected_thread_user=user) 76 | 77 | def test_if_anon_user_then_none(self): 78 | self.assert_becomes( 79 | current_user=AnonymousUser(), expected_thread_user=None) 80 | 81 | def assert_becomes(self, current_user, expected_thread_user): 82 | _set_current_user(current_user) 83 | assert_that( 84 | get_current_authenticated_user(), equal_to(expected_thread_user)) 85 | 86 | 87 | class CurrentUserFieldTestCase(TestCase): 88 | 89 | field_cls = CurrentUserField 90 | 91 | def setUp(self): 92 | super(CurrentUserFieldTestCase, self).setUp() 93 | warnings.simplefilter("always") 94 | 95 | def test_is_a_foreignkey(self): 96 | assert_that(issubclass(self.field_cls, models.ForeignKey), is_(True)) 97 | 98 | @patch.object(models.ForeignKey, "__init__") 99 | def test_ignores_args_and_kwargs_for_default_null_and_to(self, 100 | mock_fk_init): 101 | self.field_cls(Group, default="foo", null="bar", to='baz') 102 | 103 | assert_that(mock_fk_init.was_called) 104 | assert_that(mock_fk_init.call_count, equal_to(1)) 105 | args, kwargs = mock_fk_init.call_args 106 | assert_that(args, empty()) 107 | assert_that(set(kwargs).intersection({"foo", "bar", "baz"}), empty()) 108 | 109 | def test_raises_warning_when_non_default_arguments_are_passed(self): 110 | with warnings.catch_warnings(record=True) as my_warnings: 111 | self.field_cls(Group) 112 | self.field_cls(default="foo") 113 | self.field_cls(null="bar") 114 | self.field_cls(to='baz') 115 | assert_that(my_warnings, has_length(4)) 116 | assert_that([str(m.message) for m in my_warnings], 117 | is_([CurrentUserField.warning] * 4)) 118 | 119 | def test_no_warning_raised_when_upper_case_user_model_passed(self): 120 | with warnings.catch_warnings(record=True) as my_warnings: 121 | self.field_cls(to='auth.User') 122 | assert_that(my_warnings, has_length(0)) 123 | 124 | def test_no_warning_raised_when_lower_case_user_model_passed(self): 125 | with warnings.catch_warnings(record=True) as my_warnings: 126 | self.field_cls(to='auth.user') 127 | assert_that(my_warnings, has_length(0)) 128 | 129 | def test_no_warning_raised_if_passed_argument_values_match_defaults(self): 130 | with warnings.catch_warnings(record=True) as my_warnings: 131 | self.field_cls(default=get_current_authenticated_user) 132 | self.field_cls(null=True) 133 | self.field_cls(to=settings.AUTH_USER_MODEL) 134 | assert_that(my_warnings, has_length(0)) 135 | 136 | def test_is_a_nullable_fk_to_the_user_model(self): 137 | field = self.field_cls() 138 | foreignkey_model = self.get_related_model(field) 139 | assert_that(foreignkey_model, is_(equal_to(settings.AUTH_USER_MODEL))) 140 | assert_that(field.null, is_(True)) 141 | 142 | def test_default_value_is_get_current_django_user(self): 143 | field = self.field_cls() 144 | assert_that(field.default, is_(get_current_authenticated_user)) 145 | 146 | def get_related_model(self, field): 147 | if hasattr(field, 'remote_field'): 148 | rel = getattr(field, 'remote_field', None) 149 | return getattr(rel, 'model') 150 | else: # only for Django <= 1.8 151 | rel = getattr(field, 'rel', None) 152 | return getattr(rel, 'to') 153 | 154 | 155 | class CurrentUserFieldOnUpdateTestCase(TestUserBase): 156 | 157 | def test_on_update_enabled(self): 158 | _set_current_user(None) 159 | test_model = TestModelOnUpdate() 160 | test_model.save() 161 | 162 | self.assertIs(test_model.updated_by_id, None) 163 | self.assertIs(test_model.updated_by, None) 164 | 165 | self.login_and_update(username="user1", password="pw1", pk=1) 166 | user = TestModelOnUpdate.objects.get(pk=1) 167 | 168 | self.assertEqual(self.user1.pk, user.updated_by_id) 169 | self.assertEqual(self.user1, user.updated_by) 170 | 171 | self.login_and_update(username="user2", password="pw2", pk=1) 172 | user = TestModelOnUpdate.objects.get(pk=1) 173 | 174 | self.assertEqual(self.user2.pk, user.updated_by_id) 175 | self.assertEqual(self.user2, user.updated_by) 176 | 177 | _set_current_user(None) 178 | test_model.save() 179 | user = TestModelOnUpdate.objects.get(pk=1) 180 | 181 | self.assertIs(test_model.updated_by_id, None) 182 | self.assertIs(test_model.updated_by, None) 183 | 184 | def test_on_update_disabled(self): 185 | self.login_and_create(username="user1", password="pw1") 186 | user1 = TestModelDefaultBehavior.objects.get(pk=1) 187 | 188 | self.assertEqual(self.user1.pk, user1.created_by_id) 189 | self.assertEqual(self.user1, user1.created_by) 190 | 191 | self.login_and_create(username="user2", password="pw2") 192 | user1 = TestModelDefaultBehavior.objects.get(pk=1) 193 | user2 = TestModelDefaultBehavior.objects.get(pk=2) 194 | 195 | self.assertEqual(self.user1.pk, user1.created_by_id) 196 | self.assertEqual(self.user1, user1.created_by) 197 | self.assertEqual(self.user2.pk, user2.created_by_id) 198 | self.assertEqual(self.user2, user2.created_by) 199 | 200 | _set_current_user(None) 201 | TestModelDefaultBehavior().save() 202 | user1 = TestModelDefaultBehavior.objects.get(pk=1) 203 | user2 = TestModelDefaultBehavior.objects.get(pk=2) 204 | 205 | self.assertEqual(self.user1.pk, user1.created_by_id) 206 | self.assertEqual(self.user1, user1.created_by) 207 | self.assertEqual(self.user2.pk, user2.created_by_id) 208 | self.assertEqual(self.user2, user2.created_by) 209 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.contrib.auth import views as auth_views 3 | from django.http import HttpResponse 4 | 5 | from .models import TestModelDefaultBehavior, TestModelOnUpdate 6 | 7 | 8 | if django.VERSION < (2, 0): 9 | from django.conf.urls import url as path 10 | else: 11 | from django.urls import path 12 | 13 | 14 | def create(request): 15 | if request.method == 'POST': 16 | TestModelDefaultBehavior.objects.create() 17 | return HttpResponse() 18 | 19 | 20 | def update(request, pk): 21 | if request.method == 'PATCH': 22 | TestModelOnUpdate.objects.get(pk=pk).save() 23 | return HttpResponse() 24 | 25 | 26 | urlpatterns = [ 27 | path(r'login/', auth_views.LoginView.as_view(), name="login"), 28 | path(r'create/', create, name="create"), 29 | path(r'update//', update, name="update") 30 | ] 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # See README.rst supported versions 2 | [tox] 3 | envlist = 4 | py{39,310,311,312}-django42, 5 | py{310,311,312,313}-django51, 6 | py{310,311,312,313}-django52, 7 | requires = 8 | tox >= 4.6.3 9 | setuptools >= 61.0.0 10 | setuptools-scm[toml] >= 5.0.0 11 | 12 | 13 | [testenv] 14 | commands = 15 | pip install -e tests 16 | make test lint docs 17 | setenv = 18 | DJANGO_SETTINGS_MODULE = settings 19 | PIP_INDEX_URL = https://pypi.python.org/simple/ 20 | deps = 21 | django42: Django>=4.2,<4.3 22 | django51: Django>=5.1,<5.2 23 | django52: Django>=5.2,<5.3 24 | py39,py310,py311: flake8==3.8.4 25 | py312: flake8==5.0 26 | py313: flake8==7.1.0 27 | # TODO: duplicated from pyproject.toml 28 | py313: setuptools>=61.0.0 29 | py313: setuptools-scm[toml]>=5.0.0 30 | docutils==0.15 31 | pyhamcrest<2.0 32 | 33 | whitelist_externals = make 34 | allowlist_externals = make 35 | --------------------------------------------------------------------------------