├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── TODO.txt ├── django_admin_caching ├── __init__.py ├── admin_row.py ├── apps.py ├── caching.py ├── models.py ├── patching.py └── signals.py ├── manage.py ├── settings.py ├── setup.cfg ├── setup.py ├── tests ├── setup.py └── testapp │ ├── __init__.py │ ├── admin.py │ ├── models.py │ ├── sixmock.py │ ├── sixmocks.py │ ├── test_helpers.py │ ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_admin_row_level_caching.py │ ├── test_autokeyed_cache.py │ ├── test_cache_invalidation.py │ ├── test_cache_key.py │ ├── test_cache_timeout.py │ ├── test_cache_to_use.py │ ├── test_patching.py │ ├── test_the_test_helpers_for_sanity.py │ └── test_whether_should_caching_is_enabled_for_given_modeladmin.py │ ├── to_be_patched.py │ └── urls.py ├── tox.ini └── tox2travis.py /.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 | # docs 42 | docs/_build 43 | readme-errors 44 | verify-is-on-latest-django.* 45 | 46 | # debug logs 47 | log 48 | 49 | # cache 50 | .cache 51 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | before_install: 3 | - sudo apt-get -qq update 4 | - sudo apt-get install -y make sed 5 | install: 6 | - pip install tox 7 | matrix: 8 | include: 9 | - python: "2.7" 10 | env: TOX_ENVS=py27-django111 11 | - python: "3.5" 12 | env: TOX_ENVS=py35-django111,py35-django20,py35-django21 13 | - python: "3.6" 14 | env: TOX_ENVS=py36-django111,py36-django20,py36-django21 15 | script: 16 | - tox -e $TOX_ENVS 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Paessler AG 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | * 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. 9 | 10 | * Neither the name of django-admin-caching nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | 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. 13 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | recursive-include django_admin_caching *py 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs clean-tox 2 | PYPI_SERVER?=pypi 3 | SHELL=/bin/bash 4 | VERSION=$(shell python -c"import django_admin_caching as m; print(m.__version__)") 5 | REMOTE_NAME?=origin 6 | 7 | help: 8 | @echo "clean-build - remove build artifacts" 9 | @echo "clean-pyc - remove Python file artifacts" 10 | @echo "lint - check style with flake8" 11 | @echo "test - run tests quickly with the default Python" 12 | @echo "testall - run tests on every Python version with tox" 13 | @echo "coverage - check code coverage quickly with the default Python" 14 | @echo "docs - generate Sphinx HTML documentation, including API docs" 15 | @echo "tag - tag the current version and push it to REMOTE_NAME" 16 | @echo "release - package and upload a release" 17 | @echo "sdist - package" 18 | 19 | clean: clean-build clean-pyc clean-tox 20 | 21 | clean-build: 22 | rm -fr build/ 23 | rm -fr dist/ 24 | find -name *.egg-info -type d | xargs rm -rf 25 | 26 | clean-pyc: 27 | find . -name '*.pyc' -exec rm -f {} + 28 | find . -name '*.pyo' -exec rm -f {} + 29 | find . -name '*~' -exec rm -f {} + 30 | 31 | lint: 32 | flake8 django_admin_caching tests 33 | 34 | test: 35 | #python manage.py test testapp --traceback 36 | pytest 37 | 38 | clean-tox: 39 | if [[ -d .tox ]]; then rm -r .tox; fi 40 | 41 | test-all: clean-tox 42 | tox 43 | 44 | coverage: 45 | coverage run --source django_admin_caching setup.py test 46 | coverage report -m 47 | coverage html 48 | open htmlcov/index.html 49 | 50 | docs: outfile=readme-errors 51 | docs: 52 | rst2html.py README.rst > /dev/null 2> ${outfile} 53 | cat ${outfile} 54 | test 0 -eq `cat ${outfile} | wc -l` 55 | 56 | 57 | tag: TAG:=v${VERSION} 58 | tag: exit_code=$(shell git ls-remote ${REMOTE_NAME} | grep -q tags/${TAG}; echo $$?) 59 | tag: 60 | ifeq ($(exit_code),0) 61 | @echo "Tag ${TAG} already present" 62 | else 63 | git tag -a ${TAG} -m"${TAG}"; git push --tags ${REMOTE_NAME} 64 | endif 65 | 66 | verify-is-on-latest-django: 67 | pip freeze | grep "^Django=" > $@.before 68 | pip install -U Django 69 | pip freeze | grep "^Django=" > $@.after 70 | diff $@.before $@.after 71 | 72 | package: clean 73 | python setup.py sdist 74 | python setup.py bdist_wheel 75 | 76 | release: whl=dist/django_admin_caching-${VERSION}-py2.py3-none-any.whl 77 | release: clean package tag 78 | test -f ${whl} 79 | echo "if the release fails, setup a ~/pypirc file as per https://docs.python.org/2/distutils/packageindex.html#pypirc" 80 | twine register ${whl} -r ${PYPI_SERVER} 81 | twine upload dist/* -r ${PYPI_SERVER} 82 | 83 | sdist: clean 84 | python setup.py sdist 85 | ls -l dist 86 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | django-admin-caching 3 | ========================== 4 | 5 | :WARNING: 6 | This project is currently frozen and will not longer be maintained. If there is someone interested in continuing to maintain this project please contact: security@paessler.com . 7 | 8 | .. image:: https://travis-ci.org/PaesslerAG/django-admin-caching.svg?branch=master 9 | :target: https://travis-ci.org/PaesslerAG/django-admin-caching 10 | 11 | .. contents:: Django Admin Caching made easy 12 | 13 | The Django admin changelist rows are not contained within a block that could be 14 | extended through standard template caching with tags. Also, the generation of the 15 | cache key for more complex objects might be too complicated to do in the templates. 16 | Plus there might be out-of-process changes (e.g.: one of manual data fixes) that 17 | don't change the cache key, but should invalidate the cached row. 18 | 19 | Hence the existence of this application - declaratively cache your admin rows! 20 | 21 | Setup 22 | ===== 23 | 24 | * install it via ``pip install django-admin-caching`` 25 | * add it to your settings and it auto-registers itself 26 | :: 27 | 28 | settings.INSTALLED_APPS = [ 29 | ... 30 | 'django_admin_caching', 31 | ... 32 | ] 33 | * configure the admins you want cached (see below for detail) 34 | 35 | Configuring the admin 36 | ===================== 37 | 38 | * to enable cahcing, the ``admin_caching_enabled`` attribute of the model's 39 | admin class must be set to ``True``. Note this means you might need to 40 | ``unregister`` the default admin and register your custom one for third 41 | party models (e.g.: ``django.contrib.auth.models.Group``) 42 | * the cache key by default is ``.- 43 | .-``. This could 44 | be customized by adding a custom key method to the admin class that returns 45 | the string key for the model object part of the key - 46 | ``def admin_caching_key(self, obj)`` 47 | 48 | * if ``settings.USE_I18N`` (and ``settings.USE_L10N``) are enabled, for each 49 | enabled setting, a prefix will be added to the above, e.g.: 50 | ``..`` 51 | 52 | * on the admin level, the cache's name can be specified through the 53 | ``admin_caching_cache_name`` attribute. If omitted, it defaults to ``default`` 54 | * on the admin level, the cache's timeout can be specified through the 55 | ``admin_caching_timeout_seconds`` attribute. If omitted, it defaults to the 56 | cache's ``default_timeout`` 57 | 58 | Release Notes 59 | ============= 60 | 61 | * 0.1.5 Update to supported versions. 62 | * Drop support for Django 1.8, 1.9 and 1.10 63 | * Drop support for Python 3.2 and 3.4 64 | * Remove compatibility code 65 | 66 | * 0.1.5 67 | 68 | * bugfix: AttributeError if all translation has been deactivated 69 | - `issue #10 `_ 70 | 71 | * 0.1.4 72 | 73 | * bugfix: ``setup.py`` should not roll back latest Django version 74 | - `issue #6 `_ 75 | 76 | * 0.1.3 77 | 78 | * add support for Django 1.11 (and thus for Python 3.6 too) 79 | 80 | * 0.1.2 81 | 82 | * if i18n/l10n is enabled, account for it in the cache prefix 83 | 84 | * 0.1.1 85 | 86 | * allow specifying the cache timeout on the admin class 87 | 88 | * 0.1.0 - initial release 89 | 90 | * supports Django 1.8, 1.9, 1.10 on python 2.7, 3.3, 3.4, and 3.5 91 | * supports the following configuration attributes on the admin class 92 | 93 | * ``admin_caching_enabled`` 94 | * ``admin_caching_cache_name`` 95 | * ``admin_caching_key`` for custom object cache key 96 | 97 | .. contributing start 98 | 99 | Contributing 100 | ============ 101 | 102 | As an open source project, we welcome contributions. 103 | 104 | The code lives on `github `_. 105 | 106 | Reporting issues/improvements 107 | ----------------------------- 108 | 109 | Please open an `issue on github `_ 110 | or provide a `pull request `_ 111 | whether for code or for the documentation. 112 | 113 | For non-trivial changes, we kindly ask you to open an issue, as it might be rejected. 114 | However, if the diff of a pull request better illustrates the point, feel free to make 115 | it a pull request anyway. 116 | 117 | Pull Requests 118 | ------------- 119 | 120 | * for code changes 121 | 122 | * it must have tests covering the change. You might be asked to cover missing scenarios 123 | * the latest ``flake8`` will be run and shouldn't produce any warning 124 | * if the change is significant enough, documentation has to be provided 125 | 126 | Setting up all Python versions 127 | ------------------------------ 128 | 129 | :: 130 | 131 | sudo apt-get -y install software-properties-common 132 | sudo add-apt-repository ppa:fkrull/deadsnakes 133 | sudo apt-get update 134 | for version in 3.5 3.6; do 135 | py=python$version 136 | sudo apt-get -y install ${py} ${py}-dev 137 | done 138 | 139 | Code of Conduct 140 | --------------- 141 | 142 | As it is a Django extension, it follows 143 | `Django's own Code of Conduct `_. 144 | As there is no mailing list yet, please just email one of the main authors 145 | (see ``setup.py`` file or `github contributors`_) 146 | 147 | 148 | .. contributing end 149 | 150 | 151 | .. _github contributors: https://github.com/PaesslerAG/django-admin-caching/graphs/contributors 152 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | * for Patched, should we use __code__.co_varnames to ensure the right 2 | signature is patched? 3 | -------------------------------------------------------------------------------- /django_admin_caching/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.8' 2 | 3 | default_app_config = \ 4 | 'django_admin_caching.apps.DjangoAdminCachingAppConfig' 5 | -------------------------------------------------------------------------------- /django_admin_caching/admin_row.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.templatetags import admin_list 2 | from django_admin_caching.caching import AutoKeyedCache 3 | from django_admin_caching.patching import Patched 4 | 5 | 6 | class PatchedAdminListItemsForResult(Patched): 7 | 8 | auto_keyed_cache_cls = AutoKeyedCache 9 | 10 | def __init__(self): 11 | super(PatchedAdminListItemsForResult, self).__init__( 12 | orig=admin_list.items_for_result, 13 | new=self.cached_items_for_result 14 | ) 15 | 16 | def to_akc(self, cl, result): 17 | return self.auto_keyed_cache_cls( 18 | model_admin=cl.model_admin, result=result) 19 | 20 | def cached_items_for_result(self, orig, cl, result, form): 21 | akc = self.to_akc(cl=cl, result=result) 22 | if akc.has_value(): 23 | return akc.get() 24 | res = list(orig(cl=cl, result=result, form=form)) 25 | akc.set(res) 26 | return res 27 | -------------------------------------------------------------------------------- /django_admin_caching/apps.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.apps import AppConfig 3 | from django.db.models.signals import post_save 4 | from django.contrib.admin.templatetags import admin_list 5 | from django_admin_caching.admin_row import PatchedAdminListItemsForResult 6 | from django_admin_caching.signals import \ 7 | auto_delete_from_cache_on_model_post_save 8 | 9 | 10 | class DjangoAdminCachingAppConfig(AppConfig): 11 | 12 | name = 'django_admin_caching' 13 | 14 | def ready(self): 15 | if django.VERSION[:2] == (1, 9): 16 | import warnings 17 | msg = "You are using an unsupported Django version. " \ 18 | "django-admin-caching support" \ 19 | " might be dropped in any following release. See " \ 20 | "https://www.djangoproject.com/download/#supported-versions" 21 | warnings.warn(msg) 22 | 23 | admin_list.items_for_result = PatchedAdminListItemsForResult() 24 | post_save.connect(auto_delete_from_cache_on_model_post_save) 25 | -------------------------------------------------------------------------------- /django_admin_caching/caching.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.admin.sites import site 3 | from django.core import cache 4 | from django.utils import translation 5 | 6 | 7 | class CacheConfig(object): 8 | def __init__(self, model_admin): 9 | self.model_admin = model_admin 10 | 11 | @property 12 | def is_enabled(self): 13 | return getattr(self.model_admin, 'admin_caching_enabled', False) 14 | 15 | def cache_to_use_name(self): 16 | return getattr( 17 | self.model_admin, 'admin_caching_cache_name', 'default') 18 | 19 | @property 20 | def cache_timeout(self): 21 | return getattr( 22 | self.model_admin, 'admin_caching_timeout_seconds', 23 | self.cache.default_timeout) 24 | 25 | @property 26 | def cache(self): 27 | return cache.caches[self.cache_to_use_name()] 28 | 29 | 30 | class CacheKey(object): 31 | def __init__(self, result, model_admin=None): 32 | if model_admin is None: 33 | self.model_admin = site._registry.get(type(result)) 34 | else: 35 | self.model_admin = model_admin 36 | self.result = result 37 | 38 | @property 39 | def key(self): 40 | admin_cls = type(self.model_admin) 41 | parts = [ 42 | self.i18n_l10n_prefix, 43 | admin_cls.__module__, 44 | admin_cls.__name__, 45 | ] 46 | beginning = '.'.join(p for p in parts if p) 47 | return '{}-{}.{}-{}'.format( 48 | beginning, 49 | self.result._meta.app_label, 50 | type(self.result).__name__, 51 | self.result_key() 52 | ) 53 | 54 | @property 55 | def i18n_l10n_prefix(self): 56 | parts = [] 57 | lang = translation.get_language() 58 | if lang is None: 59 | lang = '' 60 | locale = '' 61 | else: 62 | locale = translation.to_locale(lang) 63 | if settings.USE_I18N: 64 | parts += [lang] 65 | if settings.USE_L10N: 66 | parts += [locale] 67 | return '.'.join(parts) 68 | 69 | def result_key(self): 70 | custom_key = getattr(self.model_admin, 'admin_caching_key', None) 71 | if custom_key: 72 | return custom_key(self.result) 73 | return '{}'.format( 74 | self.result.pk 75 | ) 76 | 77 | 78 | class AutoKeyedCache(object): 79 | 80 | def __init__(self, result, model_admin=None): 81 | self.ck = CacheKey(result=result, model_admin=model_admin) 82 | self.cfg = CacheConfig(model_admin=self.ck.model_admin) 83 | 84 | def set(self, value): 85 | if self.cfg.is_enabled: 86 | self.cfg.cache.set( 87 | key=self.ck.key, value=value, 88 | timeout=self.cfg.cache_timeout) 89 | 90 | def get(self): 91 | return self.cfg.cache.get(key=self.ck.key) 92 | 93 | def has_value(self): 94 | return self.cfg.is_enabled and self.ck.key in self.cfg.cache 95 | 96 | def delete(self): 97 | if self.cfg.is_enabled: 98 | self.cfg.cache.delete(key=self.ck.key) 99 | -------------------------------------------------------------------------------- /django_admin_caching/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /django_admin_caching/patching.py: -------------------------------------------------------------------------------- 1 | class Patched(object): 2 | def __init__(self, orig, new): 3 | self.orig = orig 4 | self.new = new 5 | 6 | def __call__(self, *a, **kw): 7 | return self.new(self.orig, *a, **kw) 8 | -------------------------------------------------------------------------------- /django_admin_caching/signals.py: -------------------------------------------------------------------------------- 1 | from django_admin_caching.caching import AutoKeyedCache 2 | 3 | 4 | def auto_delete_from_cache_on_model_post_save(sender, signal, **kwargs): 5 | instance = kwargs['instance'] 6 | AutoKeyedCache(result=instance).delete() 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for autodata project. 2 | 3 | DEBUG = True 4 | 5 | DATABASES = { 6 | 'default': { 7 | 'ENGINE': 'django.db.backends.sqlite3', 8 | } 9 | } 10 | 11 | # Make this unique, and don't share it with anybody. 12 | SECRET_KEY = 'mq%31q+sjj^)m^tvy(klwqw6ksv7du2yzdf9yn78iga*r%8w^t-django_admin_caching' 13 | 14 | INSTALLED_APPS = ( 15 | 'django.contrib.admin', 16 | 'django.contrib.auth', 17 | 'django.contrib.contenttypes', 18 | 'django.contrib.sessions', 19 | 'django_admin_caching', 20 | 'testapp', 21 | ) 22 | 23 | MIDDLEWARE_CLASSES = ( 24 | 'django.contrib.sessions.middleware.SessionMiddleware', 25 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 26 | ) 27 | 28 | MIDDLEWARE = ( 29 | 'django.contrib.sessions.middleware.SessionMiddleware', 30 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 31 | ) 32 | 33 | STATIC_URL = '/static/' 34 | 35 | ROOT_URLCONF = 'testapp.urls' 36 | 37 | TEMPLATES = [ 38 | { 39 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 40 | 'DIRS': [], 41 | 'APP_DIRS': True, 42 | 'OPTIONS': { 43 | 'context_processors': [ 44 | # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this 45 | # list if you haven't customized them: 46 | 'django.contrib.auth.context_processors.auth', 47 | 'django.template.context_processors.debug', 48 | 'django.template.context_processors.i18n', 49 | 'django.template.context_processors.media', 50 | 'django.template.context_processors.static', 51 | 'django.template.context_processors.tz', 52 | 'django.contrib.messages.context_processors.messages', 53 | ], 54 | }, 55 | }, 56 | ] 57 | 58 | CACHES = { 59 | 'default': { 60 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 61 | 'LOCATION': 'default', 62 | } 63 | } 64 | 65 | USE_I18N = USE_L10N = False 66 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | 7 | import django_admin_caching 8 | 9 | try: 10 | from setuptools import setup 11 | except ImportError: 12 | from distutils.core import setup 13 | 14 | version = django_admin_caching.__version__ 15 | 16 | if sys.argv[-1] == 'publish': 17 | os.system('make release') 18 | sys.exit() 19 | 20 | readme = open('README.rst').read() 21 | 22 | description = "Django Admin caching made easy" 23 | 24 | setup( 25 | name='django_admin_caching', 26 | version=version, 27 | description=description, 28 | long_description=readme, 29 | author='Paessler AG', 30 | author_email='bis@paessler.com', 31 | url='https://github.com/PaesslerAG/django-admin-caching', 32 | packages=[ 33 | 'django_admin_caching', 34 | ], 35 | include_package_data=True, 36 | install_requires=[ 37 | 'Django>=1.8,<2.2', 38 | ], 39 | license="BSD", 40 | zip_safe=False, 41 | keywords='Django, admin, caching', 42 | classifiers=[ 43 | 'Development Status :: 3 - Alpha', 44 | 'Intended Audience :: Developers', 45 | 'License :: OSI Approved :: BSD License', 46 | 'Natural Language :: English', 47 | 'Programming Language :: Python :: 2', 48 | 'Programming Language :: Python :: 2.7', 49 | 'Programming Language :: Python :: 3', 50 | 'Programming Language :: Python :: 3.5', 51 | 'Programming Language :: Python :: 3.6', 52 | 'Framework :: Django', 53 | 'Framework :: Django :: 1.11', 54 | 'Framework :: Django :: 2.0', 55 | 'Framework :: Django :: 2.1', 56 | ], 57 | ) 58 | -------------------------------------------------------------------------------- /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_admin_caching' 15 | 16 | setup( 17 | name='testapp', 18 | version='0.0.0', 19 | description=readme, 20 | long_description=readme, 21 | author='Paessler AG', 22 | author_email='bis@paessler.com', 23 | url='https://github.com/PaesslerAG/django-admin-caching', 24 | packages=[ 25 | 'testapp', 26 | ], 27 | include_package_data=True, 28 | install_requires=[ 29 | ], 30 | license="BSD", 31 | zip_safe=False, 32 | keywords='django-admin-caching', 33 | classifiers=[ 34 | 'Development Status :: 2 - Pre-Alpha', 35 | 'Framework :: Django', 36 | 'Intended Audience :: Developers', 37 | 'License :: OSI Approved :: BSD License', 38 | 'Natural Language :: English', 39 | 'Programming Language :: Python :: 2', 40 | 'Programming Language :: Python :: 2.7', 41 | 'Programming Language :: Python :: 3', 42 | 'Programming Language :: Python :: 3.4', 43 | ], 44 | ) 45 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsoldosp/django-admin-caching/efc25d0304257434056d5725a72b3fe2983274ab/tests/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testapp/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.models import Group 3 | 4 | 5 | class MyGroupAdmin(admin.ModelAdmin): 6 | list_display = ('name', 'capitalized_name') 7 | readonly_fields = ('pk', 'capitalized_name') 8 | fieldsets = ( 9 | ('editable', ['name', ]), 10 | ('readonly', ['pk', 'capitalized_name']), 11 | ) 12 | 13 | admin_caching_enabled = True 14 | 15 | def capitalized_name(self, obj): 16 | return obj.name.capitalize() 17 | 18 | def admin_caching_key(self, obj): 19 | return obj.name 20 | 21 | 22 | admin.site.unregister(Group) 23 | admin.site.register(Group, MyGroupAdmin) 24 | -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsoldosp/django-admin-caching/efc25d0304257434056d5725a72b3fe2983274ab/tests/testapp/models.py -------------------------------------------------------------------------------- /tests/testapp/sixmock.py: -------------------------------------------------------------------------------- 1 | try: 2 | from mock import patch, Mock # noqa: F401 3 | except ImportError: 4 | from unittest.mock import patch, Mock # noqa: F401 5 | -------------------------------------------------------------------------------- /tests/testapp/sixmocks.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/test_helpers.py: -------------------------------------------------------------------------------- 1 | from django.utils import translation 2 | import lxml.html 3 | 4 | 5 | def text_content(e): 6 | return e.text_content().strip() 7 | 8 | 9 | def texts_for(e, selector): 10 | return list(text_content(m) for m in e.cssselect(selector)) 11 | 12 | 13 | def text_for_tags(e, tags): 14 | return list( 15 | text_content(c) 16 | for c in e.getchildren() 17 | if c.tag in tags 18 | ) 19 | 20 | 21 | def parse_table(response): 22 | html = lxml.html.fromstring(response.content) 23 | table, = html.cssselect('table#result_list') 24 | headers = texts_for(table, 'thead th') 25 | rows = list( 26 | text_for_tags(row, ('td', 'th')) 27 | for row in table.cssselect('tbody tr') 28 | ) 29 | return headers, rows 30 | 31 | 32 | def get_group_changelist_table(admin_client): 33 | response = admin_client.get('/admin/auth/group/') 34 | assert response.status_code == 200 35 | return parse_table(response) 36 | 37 | 38 | class translation_being(object): 39 | 40 | def __init__(self, language): 41 | self.language = language 42 | 43 | def __enter__(self): 44 | fn_type = type(get_group_changelist_table) 45 | t = translation._trans.__dict__ 46 | keys_to_delete = list(k for k in t.keys() if isinstance(t[k], fn_type)) 47 | for k in keys_to_delete: 48 | del t[k] 49 | self.retained_language = translation.get_language() 50 | translation.activate(self.language) 51 | 52 | def __exit__(self, exc_type, exc_val, exc_tb): 53 | translation.activate(self.retained_language) 54 | -------------------------------------------------------------------------------- /tests/testapp/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsoldosp/django-admin-caching/efc25d0304257434056d5725a72b3fe2983274ab/tests/testapp/tests/__init__.py -------------------------------------------------------------------------------- /tests/testapp/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.sites import site 2 | from django.contrib.auth.models import Group 3 | from django.core.cache import caches 4 | import pytest 5 | from testapp.sixmocks import patch 6 | from testapp.test_helpers import get_group_changelist_table 7 | 8 | 9 | @pytest.fixture() 10 | def django_caches(): 11 | yield caches 12 | # finalizer 13 | for cache in caches.all(): 14 | cache.clear() 15 | 16 | 17 | @pytest.fixture() 18 | def capitalized_name_mock(): 19 | admin = site._registry[Group] 20 | mock = patch.object(admin, 'capitalized_name', autospec=True) 21 | yield mock.start() 22 | mock.stop() 23 | 24 | 25 | @pytest.fixture() 26 | def myadmin_cl_table(db, admin_client): 27 | return lambda: get_group_changelist_table(admin_client) 28 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_admin_row_level_caching.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Group 2 | from django.contrib.admin.templatetags import admin_list 3 | from django_admin_caching.admin_row import PatchedAdminListItemsForResult 4 | import pytest 5 | from testapp.sixmocks import Mock, call 6 | 7 | 8 | @pytest.mark.acceptance 9 | def test_caches_admin_row( 10 | myadmin_cl_table, capitalized_name_mock): 11 | foo = Group.objects.create(name='foo') 12 | bar = Group.objects.create(name='bar') 13 | baz = Group.objects.create(name='baz') 14 | capitalized_name_mock.return_value = 'xyz' 15 | 16 | uncached_headers, uncached_rows = myadmin_cl_table() 17 | assert uncached_rows == [ 18 | ['', 'baz', 'xyz'], 19 | ['', 'bar', 'xyz'], 20 | ['', 'foo', 'xyz'], 21 | ] 22 | assert 3 == capitalized_name_mock.call_count 23 | capitalized_name_mock.assert_has_calls( 24 | [call(baz), call(bar), call(foo)], 25 | any_order=False 26 | ) 27 | 28 | capitalized_name_mock.reset_mock() 29 | cached_headers, cached_rows = myadmin_cl_table() 30 | assert 0 == capitalized_name_mock.call_count, 'no new calls' 31 | assert uncached_headers == cached_headers 32 | assert uncached_rows == cached_rows 33 | 34 | foo.name = 'FOO' 35 | foo.save() 36 | capitalized_name_mock.reset_mock() 37 | partly_cached_headers, partly_cached_rows = myadmin_cl_table() 38 | assert 1 == capitalized_name_mock.call_count, 'no new calls' 39 | capitalized_name_mock.assert_has_calls( 40 | [call(foo)], 41 | any_order=False 42 | ) 43 | assert uncached_headers == partly_cached_headers 44 | assert partly_cached_rows == [ 45 | ['', 'baz', 'xyz'], 46 | ['', 'bar', 'xyz'], 47 | ['', 'FOO', 'xyz'], 48 | ] 49 | 50 | 51 | def test_admin_list_items_for_result_is_patched_by_app(): 52 | assert isinstance( 53 | admin_list.items_for_result, PatchedAdminListItemsForResult) 54 | orig = admin_list.items_for_result.orig 55 | new = admin_list.items_for_result.new 56 | assert orig.__code__.co_filename == \ 57 | admin_list.paginator_number.__code__.co_filename 58 | assert new == admin_list.items_for_result.cached_items_for_result 59 | assert get_argnames(new) == ('self', 'orig', ) + get_argnames(orig) 60 | 61 | 62 | def get_argnames(fn): 63 | return fn.__code__.co_varnames[:fn.__code__.co_argcount] 64 | 65 | 66 | @pytest.fixture() 67 | def palifr(): 68 | class MockedPatchedAdminListItemsForResult(PatchedAdminListItemsForResult): 69 | 70 | def __init__(self): 71 | super(MockedPatchedAdminListItemsForResult, self).__init__() 72 | self.orig = Mock(name='orig list items for result') 73 | self.orig.return_value = ['rendered', 'parts'] 74 | self.to_akc = Mock(name='to akc factory method') 75 | self.akc_mock = Mock(name='the actual akc mock') 76 | self.to_akc.return_value = self.akc_mock 77 | 78 | @property 79 | def all_mocks(self): 80 | return [self.to_akc, self.orig, self.akc_mock] 81 | 82 | def foreach_mock(self, fn): 83 | for mock in self.all_mocks: 84 | fn(mock) 85 | 86 | obj = MockedPatchedAdminListItemsForResult() 87 | obj.foreach_mock(lambda m: m.start()) 88 | yield obj 89 | obj.foreach_mock(lambda m: m.stop()) 90 | 91 | 92 | def test_if_not_in_cache_calls_orig_and_caches(palifr): 93 | cl = object() 94 | result = object() 95 | form = object() 96 | 97 | palifr.akc_mock.has_value.return_value = False 98 | rendered = palifr(cl=cl, result=result, form=form) 99 | 100 | palifr.to_akc.assert_called_once_with(cl=cl, result=result) 101 | palifr.akc_mock.has_value.assert_called_once_with() 102 | palifr.orig.assert_called_once_with(cl=cl, result=result, form=form) 103 | palifr.akc_mock.set.assert_called_once_with(rendered) 104 | assert not palifr.akc_mock.get.called 105 | 106 | 107 | def test_if_cache_has_item_it_is_returned_from_there(palifr): 108 | cl = object() 109 | result = object() 110 | form = object() 111 | 112 | palifr.akc_mock.has_value.return_value = True 113 | palifr.akc_mock.get.return_value = ['cached', 'rendered', 'items'] 114 | 115 | second_rendered = palifr(cl=cl, result=result, form=form) 116 | assert second_rendered == ['cached', 'rendered', 'items'] 117 | palifr.to_akc.assert_called_once_with(cl=cl, result=result) 118 | palifr.akc_mock.has_value.assert_called_once_with() 119 | assert not palifr.orig.called 120 | assert not palifr.akc_mock.set.called 121 | palifr.akc_mock.get.assert_called_once_with() 122 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_autokeyed_cache.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Group, User 2 | from django_admin_caching.caching import CacheKey, CacheConfig, AutoKeyedCache 3 | 4 | 5 | def test_creates_the_correct_objects(django_caches): 6 | result = Group(name='result') 7 | model_admin = Group(name='admin') 8 | akc = AutoKeyedCache(result=result, model_admin=model_admin) 9 | assert isinstance(akc.ck, CacheKey) 10 | assert akc.ck.model_admin == model_admin 11 | assert akc.ck.result == result 12 | assert isinstance(akc.cfg, CacheConfig) 13 | assert akc.cfg.model_admin == model_admin 14 | 15 | 16 | def test_get_set_uses_correct_cache(django_caches): 17 | result = Group(name='foo') 18 | akc = AutoKeyedCache(result=result) 19 | akc.set('sdf') 20 | assert akc.ck.key in akc.cfg.cache 21 | assert akc.cfg.cache.get(akc.ck.key) == 'sdf' 22 | assert 'sdf' == akc.get() 23 | 24 | 25 | def test_can_be_created_without_a_model_admin_specified(django_caches): 26 | akc = AutoKeyedCache(result=Group(name='random')) 27 | assert akc.cfg.model_admin is not None 28 | assert akc.cfg.model_admin == akc.ck.model_admin 29 | 30 | 31 | def test_has_value_works_correctly(django_caches): 32 | akc = AutoKeyedCache(result=Group(name='group')) 33 | assert not akc.has_value() 34 | akc.set('abcde') 35 | assert akc.has_value() 36 | 37 | 38 | def test_akc_is_null_object_so_set_and_has_value_works(django_caches): 39 | class DisabledModelAdmin(object): 40 | admin_caching_enabled = False 41 | 42 | akc = AutoKeyedCache( 43 | result=Group(name='baz'), model_admin=DisabledModelAdmin()) 44 | assert not akc.has_value() 45 | assert akc.ck.key not in akc.cfg.cache 46 | akc.set('sdf') # should be a noop 47 | assert not akc.has_value() 48 | assert akc.ck.key not in akc.cfg.cache 49 | 50 | 51 | def test_after_runtime_key_change_its_not_in_cache(django_caches): 52 | akc = AutoKeyedCache(result=Group(name='first key')) 53 | assert not akc.has_value() 54 | akc.set('abcde') 55 | assert akc.has_value() 56 | akc.ck.result.name = 'second key' 57 | assert not akc.has_value() 58 | 59 | 60 | def test_can_remove_itself_from_the_cache(django_caches): 61 | akc = AutoKeyedCache(result=Group(name='key')) 62 | akc.cfg.cache.set('other key', 'other val') 63 | assert not akc.has_value() 64 | akc.set('foo') 65 | akc.delete() 66 | assert not akc.has_value() 67 | assert akc.ck.key not in akc.cfg.cache 68 | assert akc.cfg.cache.get('other key') == \ 69 | 'other val' # don't remove unrelated entries 70 | 71 | 72 | def test_can_remove_itself_from_cache_even_if_not_in_it(django_caches): 73 | akc = AutoKeyedCache(result=Group(name='key')) 74 | akc.cfg.cache.set('foo', 'foo') 75 | assert not akc.has_value() 76 | akc.delete() 77 | assert not akc.has_value() 78 | assert akc.cfg.cache.get('foo') == 'foo' # don't remove unrelated entries 79 | 80 | 81 | def test_works_with_object_that_isnt_enabled_for(django_caches): 82 | akc = AutoKeyedCache(result=User()) 83 | assert not akc.cfg.is_enabled 84 | assert not akc.has_value() 85 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_cache_invalidation.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Group 2 | from django_admin_caching.caching import AutoKeyedCache 3 | from django_admin_caching.signals import \ 4 | auto_delete_from_cache_on_model_post_save 5 | 6 | 7 | def test_signal_handler_removes_item_from_cache(django_caches): 8 | g = Group(name='group') 9 | akc = AutoKeyedCache(result=g) 10 | akc.cfg.cache.set('foo', 'foo') 11 | akc.set('value') 12 | auto_delete_from_cache_on_model_post_save( 13 | sender=None, signal=None, instance=g) 14 | assert not akc.has_value() 15 | assert akc.cfg.cache.get('foo') == 'foo' 16 | 17 | 18 | def test_when_object_present_in_cache_saved_it_is_removed(db, django_caches): 19 | g = Group(name='foo') 20 | akc = AutoKeyedCache(result=g) 21 | assert not akc.has_value() 22 | akc.set('cached val') 23 | g.save() 24 | assert not akc.has_value() 25 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_cache_key.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.sites import site 2 | from django.contrib.auth.models import Group 3 | from django.contrib.sessions.models import Session 4 | from django.utils import translation 5 | from django_admin_caching.caching import CacheKey 6 | import pytest 7 | from testapp.test_helpers import translation_being 8 | 9 | 10 | @pytest.mark.parametrize( 11 | 'admin_cls,result,expected_key', [ 12 | (Session, Group(pk=3), 13 | 'django.contrib.sessions.models.Session-auth.Group-3'), 14 | (Group, Group(pk=5), 15 | 'django.contrib.auth.models.Group-auth.Group-5'), 16 | ] 17 | ) 18 | def test_cache_key_is_derived_from_admin_and_obj_by_default( 19 | admin_cls, result, expected_key): 20 | ck = CacheKey(model_admin=admin_cls(), result=result) 21 | assert ck.result_key() == '{}'.format(result.pk) 22 | assert ck.key == expected_key 23 | 24 | 25 | def test_can_provide_custom_override_to_cache_key_through_model_admin(): 26 | class AdminWithCustomCacheKey(object): 27 | def admin_caching_key(self, obj): 28 | return 'Foo:Bar:9' 29 | 30 | ck = CacheKey(model_admin=AdminWithCustomCacheKey(), result=Group(pk=55)) 31 | assert ck.result_key() == 'Foo:Bar:9' 32 | assert ck.key == \ 33 | '{}.AdminWithCustomCacheKey-auth.Group-Foo:Bar:9'.format( 34 | AdminWithCustomCacheKey.__module__) 35 | 36 | 37 | def test_when_model_admin_is_not_provided_it_is_derived_from_admin_registry(): 38 | session_obj = Session() 39 | ck_explicit_admin = CacheKey(result=Group(), model_admin=session_obj) 40 | assert ck_explicit_admin.model_admin == session_obj 41 | 42 | ck_derived_admin = CacheKey(result=Group()) 43 | assert ck_derived_admin.model_admin == site._registry[Group] 44 | 45 | orig_admin = site._registry[Group] 46 | try: 47 | admin_obj = object() 48 | site._registry[Group] = admin_obj 49 | ck_derived_admin = CacheKey(result=Group()) 50 | assert ck_derived_admin.model_admin == site._registry[Group] 51 | finally: 52 | site._registry[Group] = orig_admin 53 | 54 | 55 | @pytest.mark.parametrize( 56 | 'language,i18n,l10n,expected_key_prefix', [ 57 | ('en', True, True, 'en.en'), 58 | ('de-ch', True, True, 'de-ch.de_CH'), 59 | ('en-us', True, True, 'en-us.en_US'), 60 | ('en-us', True, False, 'en-us'), 61 | # L10N doesn't work w/out I18N 62 | # ('en-us', False, False, 'en_US'), 63 | ('en-us', False, False, ''), 64 | ] 65 | ) 66 | def test_key_is_i18n_l10n_aware_if_settings_enabled(settings, language, i18n, 67 | l10n, expected_key_prefix): 68 | settings.USE_I18N = i18n 69 | settings.USE_L10N = l10n 70 | with translation_being(language): 71 | ck = CacheKey(result=Group(pk=1)) 72 | assert ck.i18n_l10n_prefix == expected_key_prefix 73 | assert ck.key.startswith(expected_key_prefix) 74 | if expected_key_prefix: 75 | assert ck.key.startswith('{}.'.format(expected_key_prefix)) 76 | 77 | 78 | def test_when_all_language_is_deactivated(settings): 79 | settings.USE_I18N = True 80 | settings.USE_L10N = True 81 | with translation_being('en'): 82 | translation.deactivate_all() 83 | ck = CacheKey(result=Group(pk=1)) 84 | prefix = ck.i18n_l10n_prefix 85 | assert prefix == '.' 86 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_cache_timeout.py: -------------------------------------------------------------------------------- 1 | from django.core import cache 2 | from django_admin_caching.caching import CacheConfig, AutoKeyedCache 3 | from testapp.sixmock import patch, Mock 4 | 5 | 6 | class TestTimeoutIsSpecifiedFromModelAdminsProperty(object): 7 | def test_when_nothing_is_provided_caches_default_will_be_used(self): 8 | no_attribute_model_admin = object() 9 | cfg = CacheConfig(model_admin=no_attribute_model_admin) 10 | assert cfg.cache_timeout == cache.caches['default'].default_timeout 11 | 12 | def test_when_attribute_is_provided_that_is_used_to_add_to_autocache(self): 13 | class WithValidTimeout(object): 14 | admin_caching_timeout_seconds = 60 * 60 * 2 # 2 hours 15 | cfg = CacheConfig(model_admin=WithValidTimeout()) 16 | assert cfg.cache_timeout == 60 * 60 * 2 17 | 18 | 19 | def test_configs_cache_timeout_is_passed_on_to_cache_set_method(): 20 | class MockAdmin(object): 21 | admin_caching_timeout_seconds = Mock() 22 | admin_caching_enabled = True 23 | 24 | with patch.object(CacheConfig, 'cache') as cache_mock: 25 | akc = AutoKeyedCache(result=Mock(), model_admin=MockAdmin()) 26 | akc.set('foo') 27 | cache_mock.set.assert_called_once_with( 28 | key=akc.ck.key, value='foo', 29 | timeout=MockAdmin.admin_caching_timeout_seconds) 30 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_cache_to_use.py: -------------------------------------------------------------------------------- 1 | from django.core import cache 2 | from django.core.cache.backends.dummy import DummyCache 3 | from django.core.cache.backends.base import InvalidCacheBackendError 4 | from django.test.utils import override_settings 5 | from django_admin_caching.caching import CacheConfig 6 | import pytest 7 | from testapp.sixmock import patch 8 | 9 | 10 | class TestAdminClassCanSpecifyWhichCacheToUse(object): 11 | def test_nothing_specified_default_cache_is_used(self): 12 | class NoAttributeAdmin(object): 13 | pass 14 | cfg = CacheConfig(model_admin=NoAttributeAdmin()) 15 | assert cfg.cache_to_use_name() == 'default' 16 | assert cfg.cache == cache.caches['default'] 17 | 18 | def test_specified_cache_is_used(self): 19 | class AttributeSpecifiesCacheToUse(object): 20 | admin_caching_cache_name = 'foo' 21 | with self.caches('foo'): 22 | cfg = CacheConfig(model_admin=AttributeSpecifiesCacheToUse()) 23 | assert cfg.cache_to_use_name() == 'foo' 24 | assert cfg.cache == cache.caches['foo'] 25 | 26 | def test_if_wrong_cache_is_specified_there_is_an_error(self): 27 | class AttributeSpecifiesCacheToUse(object): 28 | admin_caching_cache_name = 'bar' 29 | with self.caches('default'): 30 | cfg = CacheConfig(model_admin=AttributeSpecifiesCacheToUse()) 31 | with pytest.raises(InvalidCacheBackendError): 32 | cfg.cache # accessing the cache 33 | 34 | def test_allows_other_apps_to_wrap_the_cache(self, django_caches): 35 | manual_cache = DummyCache('dummy', {}) 36 | to_patch = 'django.core.cache.CacheHandler.__getitem__' 37 | with patch(to_patch, return_value=manual_cache) as mock: 38 | cfg = CacheConfig(model_admin=None) 39 | assert cache.caches['default'] == manual_cache 40 | assert cfg.cache == manual_cache 41 | assert mock.called 42 | 43 | class caches(override_settings): 44 | def __init__(self, *names): 45 | self.names = names 46 | self.caches_dict = dict( 47 | (name, { 48 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 49 | 'LOCATION': name, 50 | }) 51 | for name in names 52 | ) 53 | super( 54 | TestAdminClassCanSpecifyWhichCacheToUse.caches, self).__init__( 55 | CACHES=self.caches_dict) 56 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_patching.py: -------------------------------------------------------------------------------- 1 | from django_admin_caching.patching import Patched 2 | import pytest 3 | from testapp.sixmocks import Mock 4 | from testapp import to_be_patched 5 | 6 | 7 | class Greeter(object): 8 | def hello(self, name): 9 | return 'Hello {}!'.format(name) 10 | 11 | 12 | def test_patching_has_orig_item_and_wrapper(): 13 | mock = Mock() 14 | p = Patched(orig=Greeter.hello, new=mock) 15 | assert p.orig == Greeter.hello 16 | assert p.new == mock 17 | 18 | 19 | @pytest.mark.parametrize( 20 | 'args,kwargs', [ 21 | [('World',), {}], 22 | [tuple(), {'name': 'Universe'}], 23 | ], ids=['args', 'kwargs'] 24 | ) 25 | def test_patched_is_called_with_caller_args_and_orig_method(args, kwargs): 26 | mock = Mock() 27 | p = Patched(orig=Greeter.hello, new=mock) 28 | g = Greeter() 29 | 30 | # call via args 31 | p(g, *args, **kwargs) 32 | assert mock.called 33 | should_have_been_called_with_args = (Greeter.hello, g, ) + args 34 | should_have_been_called_with_kwargs = kwargs 35 | mock.assert_called_once_with( 36 | *should_have_been_called_with_args, 37 | **should_have_been_called_with_kwargs 38 | ) 39 | 40 | 41 | def test_can_patch_instance_method(): 42 | mock = Mock() 43 | mock.return_value = 'hello' 44 | p = Patched(orig=Greeter.hello, new=mock) 45 | try: 46 | Greeter.hello = p 47 | g = Greeter() 48 | assert 'hello' == g.hello('somebody') 49 | finally: 50 | Greeter.hello = Greeter.hello.orig 51 | 52 | 53 | def test_can_patch_regular_method(): 54 | mock = Mock() 55 | try: 56 | to_be_patched.nfoo = Patched(orig=to_be_patched.nfoo, new=mock) 57 | to_be_patched.nfoo(3) 58 | finally: 59 | to_be_patched.nfoo = to_be_patched.nfoo.orig 60 | 61 | assert mock.called 62 | mock.assert_called_once_with(to_be_patched.nfoo, 3) 63 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_the_test_helpers_for_sanity.py: -------------------------------------------------------------------------------- 1 | from django.urls import resolve 2 | from django.contrib.admin.sites import site 3 | from django.contrib.auth.models import Group 4 | import pytest 5 | from testapp.sixmocks import patch 6 | 7 | 8 | def test_the_admin_is_configured_working(myadmin_cl_table): 9 | Group.objects.create(name='foo') 10 | assert resolve('/admin/') is not None 11 | assert resolve('/admin/auth/') is not None 12 | assert resolve('/admin/auth/group/') is not None 13 | headers, rows = myadmin_cl_table() 14 | assert ['', 'Name', 'Capitalized name'] == headers 15 | assert len(rows) == 1 16 | assert ['', 'foo', 'Foo'] == rows[0] 17 | 18 | 19 | @pytest.mark.parametrize('i', [1, 2]) 20 | def test_caches_fixture_provides_an_empty_cache(django_caches, i): 21 | key = 'foo' 22 | for cache in django_caches.all(): 23 | assert key not in cache 24 | cache.set(key=key, value=i) 25 | assert key in cache 26 | assert cache.get(key) == i 27 | 28 | 29 | def test_can_mock_custom_method_on_mygroupadmin(myadmin_cl_table): 30 | Group.objects.create(name='foo') 31 | admin = site._registry[Group] 32 | with patch.object(admin, 'capitalized_name') as capitalized_name_mock: 33 | capitalized_name_mock.boolean = False 34 | capitalized_name_mock.return_value = 'bar' 35 | headers, rows = myadmin_cl_table() 36 | assert rows == [['', 'foo', 'bar']] 37 | assert capitalized_name_mock.called 38 | assert 1 == capitalized_name_mock.call_count 39 | 40 | 41 | def test_can_use_mocked_mygroupadmin_capitalized_name_fixture( 42 | myadmin_cl_table, capitalized_name_mock): 43 | Group.objects.create(name='abc') 44 | # assert root cause for failure in 45 | # django/contrib/admin/templatetags/admin_list.py 46 | assert not getattr(capitalized_name_mock, 'boolean', False) 47 | capitalized_name_mock.return_value = 'xyz' 48 | headers, rows = myadmin_cl_table() 49 | assert rows == [['', 'abc', 'xyz']] 50 | assert capitalized_name_mock.called 51 | assert 1 == capitalized_name_mock.call_count 52 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_whether_should_caching_is_enabled_for_given_modeladmin.py: -------------------------------------------------------------------------------- 1 | from django_admin_caching.caching import CacheConfig 2 | 3 | 4 | def test_explicit_attribute_with_true_value_enables_caching(): 5 | class WithCachingEnabled(object): 6 | admin_caching_enabled = True 7 | cfg = CacheConfig(model_admin=WithCachingEnabled()) 8 | assert cfg.is_enabled is True 9 | 10 | 11 | def test_explicit_attribute_with_false_value_disables_caching(): 12 | class WithCachingDisabled(object): 13 | admin_caching_enabled = False 14 | cfg = CacheConfig(model_admin=WithCachingDisabled()) 15 | assert cfg.is_enabled is False 16 | 17 | 18 | def test_without_explicit_attribute_caching_is_disabled(): 19 | class NoExplicitCacheEnabler(object): 20 | pass 21 | cfg = CacheConfig(model_admin=NoExplicitCacheEnabler()) 22 | assert cfg.is_enabled is False 23 | -------------------------------------------------------------------------------- /tests/testapp/to_be_patched.py: -------------------------------------------------------------------------------- 1 | def nfoo(n): 2 | return 'foo'*n 3 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.contrib import admin 3 | 4 | 5 | urlpatterns = [ 6 | url(r'^admin/', admin.site.urls, {}, "admin-index"), 7 | # url(r'^some-path/$', some_view, {}, 'some_view'), 8 | ] 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django 2 | [tox] 3 | envlist = 4 | py{27,35,36}-django111, 5 | py{35,36}-django20, 6 | py{35,36}-django21, 7 | 8 | [testenv] 9 | commands = 10 | pip install -e tests 11 | make docs lint test 12 | setenv = 13 | DJANGO_SETTINGS_MODULE = settings 14 | PIP_INDEX_URL = https://pypi.python.org/simple/ 15 | deps = 16 | django111: Django>=1.11,<1.12 17 | django20: Django>=2.0,<2.1 18 | django21: Django>=2.1,<2.2 19 | py27: mock 20 | flake8 21 | pytest-django 22 | lxml 23 | cssselect 24 | docutils 25 | whitelist_externals = make 26 | -------------------------------------------------------------------------------- /tox2travis.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import subprocess 4 | 5 | 6 | class ToxToTravis: 7 | 8 | def __init__(self, cwd): 9 | self.cwd = cwd 10 | 11 | def parse_tox(self): 12 | proc = subprocess.Popen( 13 | "tox -l", shell=True, stdout=subprocess.PIPE, cwd=self.cwd) 14 | self.tox_lines = proc.stdout.read().strip().split('\n') 15 | self.parse_python_versions() 16 | 17 | def parse_python_versions(self): 18 | tox_pys = set([]) 19 | djangos = set([]) 20 | tox_py_to_djangos = {} 21 | for tox_line in self.tox_lines: 22 | py, env = tox_line.split('-') 23 | tox_pys.add(py) 24 | djangos.add(env) 25 | tox_py_to_djangos.setdefault(py, []) 26 | tox_py_to_djangos[py].append(env) 27 | 28 | self.djangos = sorted(djangos) 29 | self.tox_pys = sorted(tox_pys) 30 | self.tox_py_to_djangos = tox_py_to_djangos 31 | 32 | def write_travis(self): 33 | lines = self.setup_python() + self.matrix() + self.test_command() 34 | print('\n'.join(lines)) 35 | 36 | def setup_python(self): 37 | return [ 38 | 'language: python', 39 | 'before_install:', 40 | ' - sudo apt-get -qq update', 41 | ' - sudo apt-get install -y make sed', 42 | 'install:', 43 | ' - pip install tox', 44 | ] 45 | 46 | def matrix(self): 47 | self.tox2travis_py = dict( 48 | py27='2.7', 49 | py35='3.5', 50 | py36='3.6', 51 | ) 52 | output = [ 53 | 'matrix:', 54 | ' include:', 55 | ] 56 | for tox_py, djangos in self.tox_py_to_djangos.items(): 57 | tox_envs_gen = ('-'.join((tox_py, d)) for d in djangos) 58 | item = [ 59 | ' - python: "%s"' % self.tox2travis_py[tox_py], 60 | ' env: TOX_ENVS=%s' % ','.join(tox_envs_gen), 61 | ] 62 | output += item 63 | return output 64 | 65 | def test_command(self): 66 | return [ 67 | 'script:', 68 | ' - tox -e $TOX_ENVS', 69 | ] 70 | 71 | 72 | def main(): 73 | cwd = os.path.abspath(os.path.dirname(__file__)) 74 | ttt = ToxToTravis(cwd) 75 | ttt.parse_tox() 76 | ttt.write_travis() 77 | 78 | 79 | if __name__ == '__main__': 80 | main() 81 | --------------------------------------------------------------------------------