├── .bumpversion.cfg ├── .flake8 ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .isort.cfg ├── CHANGES ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── _ext │ ├── applyxrefs.py │ ├── djangodocs.py │ └── literals_to_xrefs.py ├── _static │ └── rtd.css ├── api.rst ├── changes.rst ├── conf.py ├── howto.rst ├── image.png └── index.rst ├── setup.cfg ├── setup.py ├── src └── admin_extra_urls │ ├── __init__.py │ ├── api.py │ ├── button.py │ ├── checks.py │ ├── config.py │ ├── decorators.py │ ├── mixins.py │ ├── templates │ └── admin_extra_urls │ │ ├── action_page.html │ │ ├── change_form.html │ │ ├── change_list.html │ │ ├── confirm.html │ │ └── includes │ │ ├── action_buttons.html │ │ ├── attrs.html │ │ ├── button.html │ │ ├── change_form_buttons.html │ │ ├── change_list_buttons.html │ │ └── styles.html │ ├── templatetags │ ├── __init__.py │ └── extra_urls.py │ └── utils.py ├── tests ├── .coveragerc ├── conftest.py ├── demoapp │ └── demo │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── backends.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ │ ├── models.py │ │ ├── settings.py │ │ ├── templates │ │ └── admin_extra_urls │ │ │ └── upload.html │ │ ├── upload.py │ │ └── urls.py ├── manage.py ├── test_actions.py ├── test_confirm.py ├── test_links.py ├── test_upload.py └── test_utils.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 4.1.1 3 | parse = (?P\d+)\.(?P\d+)\.(?P\d+) 4 | serialize = {major}.{minor}.{patch} 5 | commit = True 6 | tag = False 7 | allow_dirty = True 8 | 9 | [bumpversion:file:src/admin_extra_urls/__init__.py] 10 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-complexity = 14 3 | max-line-length = 120 4 | exclude = _plugin_template migrations settings python_twitter tweepy 5 | ignore = E401,W391,E128,E261,E731,Q000,W504,W606 6 | putty-ignore = 7 | 8 | 9 | per-file-ignores = 10 | */__init__.py:F401,F403 11 | src/admin_extra_urls/extras.py:F401 12 | src/admin_extra_urls/api.py:F401 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-python@v2 16 | 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip flake8 isort 20 | - name: Lint with flake8 21 | run: | 22 | flake8 src 23 | isort -c src 24 | 25 | test: 26 | # if: ${{github.event}} && ${{ !contains(github.event.head_commit.message, 'ci skip') }} 27 | runs-on: ubuntu-latest 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | python-version: [ "3.8", "3.9", "3.10" ] 32 | django-version: [ "2.2", "3.2", "4.0" ] 33 | env: 34 | PY_VER: ${{ matrix.python-version}} 35 | DJ_VER: ${{ matrix.django-version}} 36 | 37 | steps: 38 | - uses: actions/checkout@v2 39 | 40 | - name: Set up Python ${{ matrix.python-version }} 41 | uses: actions/setup-python@v2 42 | with: 43 | python-version: ${{ matrix.python-version }} 44 | 45 | - name: Install dependencies 46 | run: python -m pip install --upgrade pip .[test] "django==${DJ_VER}.*" 47 | 48 | - name: Test with 49 | run: py.test tests/ -v 50 | 51 | - uses: codecov/codecov-action@v1 52 | with: 53 | # files: ./coverage1.xml,./coverage2.xml # optional 54 | # flags: unittests # optional 55 | # name: codecov-umbrella # optional 56 | # fail_ci_if_error: true # optional (default = false) 57 | verbose: true # optional (default = false) 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | ~* 3 | /build 4 | !.gitignore 5 | !.github 6 | !.isort.cfg 7 | !.flake8 8 | !.bumpversion.cfg 9 | *.py[co] 10 | *.egg-info 11 | dist/* 12 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | combine_as_imports = true 3 | default_section = THIRDPARTY 4 | include_trailing_comma = true 5 | ;forced_separate = django.contrib,django.utils 6 | line_length = 120 7 | known_future_library = future,pies 8 | known_first_party = admin_extra_urls 9 | multi_line_output = 0 10 | balanced_wrapping = true 11 | ;not_skip = __init__.py 12 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 13 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | Release 4.2 2 | ---------------------------- 3 | * make visible a bit more backward compatible 4 | 5 | 6 | Release 4.1 7 | ---------------------------- 8 | * code refactoring 9 | * BACKWARD INCOMPATIBLE - visible() callable now expect 1 params (context) 10 | 11 | 12 | Release 4.0 13 | ---------------------------- 14 | * code refactoring 15 | * added support Django 4.0 and Python 3.10 16 | 17 | 18 | Release 3.5 19 | ----------- 20 | * new `group` attribute allows to set buttons visibility - 21 | * BACKWARD INCOMPATIBLE - visible() callable now expect 2 params (obj, request) 22 | 23 | Release 3.4 24 | ----------- 25 | * new `display` attribute allows button to be displayed both on changelist/change_form pages 26 | 27 | 28 | Release 3.3 29 | ----------- 30 | * deprecate action() new `button()` decorator - action() will be removed in future releases 31 | * new `href()` decorator 32 | * removed deprecated `link()` decorator 33 | * new colored buttons 34 | 35 | 36 | Release 3.2 37 | ----------- 38 | * Code refactoring 39 | * New Feature: disable buttons when form values are changed 40 | * add ability to customize urls and add extra paramenters 41 | * new `action_page.html` to be used as is or as template for multi-step actions 42 | 43 | 44 | Release 3.1 45 | ----------- 46 | * ButtonLink splitted in ChangeFormButton, ChangeListButton 47 | 48 | 49 | Release 3.0 50 | ----------- 51 | * full code refactoring 52 | * new ButtonLink and `extra_buttons` 53 | * deprecate `link()` decorator 54 | * new decorator `try_catch` to wrap action with try..catch and sent user feedback 55 | * drop support Django<2.2 56 | * project renamed as "django-admin-extra-urls" 57 | 58 | 59 | Release 2.3 60 | ----------- 61 | * added support Django 3.1 62 | * added support Python 3.9 63 | * dropped support to Django <2.2 64 | * dropped support to Python <3.6 65 | 66 | Release 2.2 67 | ----------- 68 | * add support Django 3.0 69 | * add support Django 2.2 70 | * dropped support Python <3.5 71 | * dropped support Django <2.0 72 | 73 | 74 | Release 2.1 75 | ----------- 76 | * code cleanup 77 | 78 | 79 | Release 2.0 80 | ----------- 81 | * dropped support Python 2.x 82 | * dropped support Django <2.0 83 | * add Django 2.1 support 84 | * Updates _confirm_action utility to properly handle exceptions 85 | 86 | 87 | Release 1.9 88 | ----------- 89 | * Fixed the link css classes (issue #15) 90 | * Fixed broken download url (issue #12) 91 | * Fixed broken pypip.in badges (issue #11) 92 | 93 | 94 | Release 1.8 95 | ----------- 96 | * standard "post action redirect" now preserve existing filters 97 | * check arguments of decorated methods 98 | * fixes permission check using callable 99 | * removed UploadMixin. It is now part of the demo app. 100 | 101 | 102 | Release 1.7 103 | ----------- 104 | * removed deprecated `has_permission` 105 | * add support django 2.0 106 | 107 | 108 | Release 1.6 109 | ----------- 110 | * dropped support for django 1.8 111 | * `action()` default behaviour do not display button if in `add` mode 112 | * all `**kwargs` passed to action() will be passed to decorated method 113 | * deprecated `has_permission` templatetag 114 | 115 | 116 | Release 1.5 117 | ----------- 118 | * fixes permissions check on change list template 119 | 120 | Release 1.4 121 | ----------- 122 | * fixes #8 use django jquery 123 | * fixes #7 fixes upload mixin 124 | 125 | 126 | Release 1.3.1 127 | ------------- 128 | * fixes issue when callable is used to manage permission in detail form 129 | 130 | 131 | Release 1.3 132 | ----------- 133 | * fixes template check permissio issue 134 | 135 | Release 1.2 136 | ----------- 137 | * check permission before run action/link 138 | * permission argument can be a callable. It should raise PermissionDenied if needed 139 | 140 | 141 | Release 1.1 142 | ----------- 143 | * @action now has 'exclude_if_adding' to hide a button in add mode 144 | * fixes button ordering 145 | * update templates to django admin 1.9 and grappelli 146 | * disable @action button if object not saved 147 | 148 | Release 1.0 149 | ----------- 150 | * drop suppport for django<1.7 151 | 152 | 153 | Release 0.8 154 | ----------- 155 | * Django 1.9 compatibility 156 | 157 | 158 | Release 0.7.1 159 | ------------- 160 | * fixes wrong template that produce wrong link for action() 161 | 162 | 163 | Release 0.7 164 | ----------- 165 | * fixes wrong template that produce double button on screen 166 | * add django 1.8 to travis config 167 | * wheel package 168 | 169 | 170 | Release 0.6.1 171 | ------------- 172 | * templates refactoring 173 | 174 | 175 | Release 0.5 176 | ----------- 177 | * Potentially backward incompatible: 178 | Changed the way the urls options are put in the context. 179 | * add 'visible' attribute to `link()` and `action()` 180 | * add new custom tag `extraurl` 181 | 182 | 183 | Release 0.4 184 | ----------- 185 | * add css_class attribute 186 | 187 | 188 | Release 0.3 189 | ----------- 190 | * add ability to order buttons 191 | 192 | 193 | Release 0.2 194 | ----------- 195 | * python 3.3, 3.4 compatibility 196 | * add ability to customize the upload template 197 | * disable button after click to prevent double click 198 | * improved automatic button's label creation 199 | * it's not anymore mandatory to return a HttpResponse 200 | 201 | 202 | Release 0.1 203 | ----------- 204 | * Initial release 205 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | * Copyright (c) 2010, Stefano Apostolico (s.apostolico@gmail.com) 2 | * Dual licensed under the MIT or GPL Version 2 licenses. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 11 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 12 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 13 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 14 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 15 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 16 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 17 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 18 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 19 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 20 | 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include CHANGES 3 | include LICENSE 4 | include MANIFEST.in 5 | include tox.ini 6 | include setup.py 7 | include setup.cfg 8 | 9 | include tests/.coveragerc 10 | recursive-include src/requirements *.pip 11 | recursive-include docs * 12 | recursive-include src/admin_extra_urls * 13 | recursive-include tests * 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | help: 2 | echo 3 | 4 | demo: 5 | cd tests && ./manage.py migrate 6 | cd tests && ./manage.py runserver 7 | 8 | develop: 9 | python3 -m venv .venv 10 | .venv/bin/pip install -U pip setuptools 11 | .venv/bin/pip install -e .[dev] 12 | 13 | clean: 14 | # cleaning 15 | @rm -fr dist '~build' .pytest_cache .coverage src/admin_extra_urls.egg-info 16 | @find . -name __pycache__ -o -name .eggs | xargs rm -rf 17 | @find . -name "*.py?" -o -name ".DS_Store" -o -name "*.orig" -o -name "*.min.min.js" -o -name "*.min.min.css" -prune | xargs rm -rf 18 | 19 | fullclean: 20 | @rm -rf .tox .cache 21 | $(MAKE) clean 22 | 23 | docs: 24 | rm -fr ~build/docs/ 25 | sphinx-build -n docs/ ~build/docs/ 26 | 27 | lint: 28 | @flake8 src/ 29 | @isort -c src/ 30 | 31 | release: 32 | tox 33 | rm -fr dist/ 34 | ./setup.py sdist 35 | #PACKAGE_NAME=django-admin-extra-buttons ./setup.py sdist 36 | twine upload dist/ 37 | 38 | 39 | .PHONY: build docs 40 | 41 | 42 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-admin-extra-urls 2 | ======================= 3 | 4 | ------ 5 | 6 | **This project is not actively maintained. Please check https://github.com/saxix/django-admin-extra-buttons** 7 | 8 | ------ 9 | 10 | 11 | 12 | 13 | 14 | Pluggable django application that offers one single mixin class ``ExtraUrlMixin`` 15 | to easily add new url (and related buttons on the screen) to any ModelAdmin. 16 | 17 | - ``url()`` decorator will create a new view for the ModelAdmin. 18 | - ``button()`` shortcut for ``url(button={...})``. 19 | - ``link()`` to add button that point to external urls. 20 | 21 | 22 | 23 | Install 24 | ------- 25 | 26 | .. code-block:: python 27 | 28 | pip install django-admin-extra-urls 29 | 30 | 31 | After installation add it to ``INSTALLED_APPS`` 32 | 33 | .. code-block:: python 34 | 35 | 36 | INSTALLED_APPS = ( 37 | ... 38 | 'admin_extra_urls', 39 | ) 40 | 41 | How to use it 42 | ------------- 43 | 44 | .. code-block:: python 45 | 46 | from admin_extra_urls.api import url, button, link, href 47 | 48 | class MyModelModelAdmin(extras.ExtraUrlMixin, admin.ModelAdmin): 49 | 50 | @link(label='Search On Google', 'http://www.google.com?q={target}') # /admin/myapp/mymodel/update_all/ 51 | def search_on_google(self, button): 52 | # this is called by the template engine just before rendering the button 53 | # `context` is the Context instance in the template 54 | if 'original' in button.context: 55 | obj = button.context['original'] 56 | return {'target': obj.name} 57 | else: 58 | button.visible = False 59 | 60 | @link() 61 | def search_on_bing(self, button): 62 | return 'http://www.bing.com?q=target' 63 | 64 | 65 | @button() # /admin/myapp/mymodel/update_all/ 66 | def consolidate(self, request): 67 | ... 68 | ... 69 | 70 | @button() # /admin/myapp/mymodel/update/10/ 71 | def update(self, request, pk): 72 | # if we use `pk` in the args, the button will be in change_form 73 | obj = self.get_object(request, pk) 74 | ... 75 | 76 | @button(urls=[r'^aaa/(?P.*)/(?P.*)/$', 77 | r'^bbb/(?P.*)/$']) 78 | def revert(self, request, pk, state=None): 79 | obj = self.get_object(request, pk) 80 | ... 81 | 82 | 83 | @button(label='Truncate', permission=lambda request, obj: request.user.is_superuser) 84 | def truncate(self, request): 85 | 86 | if request.method == 'POST': 87 | self.model.objects._truncate() 88 | else: 89 | return extras._confirm_action(self, request, self.truncate, 90 | 'Continuing will erase the entire content of the table.', 91 | 'Successfully executed', ) 92 | 93 | 94 | 95 | If the return value from a `button` decorated method is a HttpResponse, that will be used. Otherwise if the method contains the `pk` 96 | argument user will be redirected to the 'update' view, otherwise and the browser will be redirected to the admin's list view 97 | 98 | 99 | ``button()`` options 100 | -------------------- 101 | 102 | These are the arguments that ``button()`` accepts 103 | 104 | +-------------+----------------------+----------------------------------------------------------------------------------------+ 105 | | path | None | `path` url path for the button. Will be the url where the button will point to. | 106 | +-------------+----------------------+----------------------------------------------------------------------------------------+ 107 | | label | None | Label for the button. By default the "labelized" function name. | 108 | +-------------+----------------------+----------------------------------------------------------------------------------------+ 109 | | icon | '' | Icon for the button. | 110 | +-------------+----------------------+----------------------------------------------------------------------------------------+ 111 | | permission | None | Permission required to use the button. Can be a callable (current object as argument). | 112 | +-------------+----------------------+----------------------------------------------------------------------------------------+ 113 | | css_class | "btn btn-success" | Extra css classes to use for the button | 114 | +-------------+----------------------+----------------------------------------------------------------------------------------+ 115 | | order | 999 | In case of multiple button the order to use | 116 | +-------------+----------------------+----------------------------------------------------------------------------------------+ 117 | | visible | lambda o: o and o.pk | callable or bool. By default do not display "action" button if in `add` mode | 118 | +-------------+----------------------+----------------------------------------------------------------------------------------+ 119 | | urls | None | list of urls to be linked to the action. | 120 | +-------------+----------------------+----------------------------------------------------------------------------------------+ 121 | 122 | 123 | 124 | Integration with other libraries 125 | -------------------------------- 126 | 127 | django-import-export 128 | ~~~~~~~~~~~~~~~~~~~~ 129 | 130 | .. code-block:: python 131 | 132 | @admin.register(Rule) 133 | class RuleAdmin(ExtraUrlMixin, ImportExportMixin, BaseModelAdmin): 134 | @button(label='Export') 135 | def _export(self, request): 136 | if '_changelist_filters' in request.GET: 137 | real_query = QueryDict(request.GET.get('_changelist_filters')) 138 | request.GET = real_query 139 | return self.export_action(request) 140 | 141 | @button(label='Import') 142 | def _import(self, request): 143 | return self.import_action(request) 144 | 145 | 146 | Running project tests locally 147 | ----------------------------- 148 | 149 | Install the dev dependencies with ``pip install -e '.[dev]'`` and then run tox. 150 | 151 | Links 152 | ----- 153 | 154 | +--------------------+----------------+--------------+-----------------------------+ 155 | | Stable | |master-build| | |master-cov| | | 156 | +--------------------+----------------+--------------+-----------------------------+ 157 | | Development | |dev-build| | |dev-cov| | | 158 | +--------------------+----------------+--------------+-----------------------------+ 159 | | Project home page: |https://github.com/saxix/django-admin-extra-urls | 160 | +--------------------+---------------+---------------------------------------------+ 161 | | Issue tracker: |https://github.com/saxix/django-admin-extra-urls/issues?sort | 162 | +--------------------+---------------+---------------------------------------------+ 163 | | Download: |http://pypi.python.org/pypi/admin-extra-urls/ | 164 | +--------------------+---------------+---------------------------------------------+ 165 | 166 | 167 | .. |master-build| image:: https://github.com/saxix/django-admin-extra-urls/actions/workflows/test.yml/badge.svg?branch=master 168 | :target: https://github.com/saxix/django-admin-extra-urls 169 | 170 | .. |master-cov| image:: https://codecov.io/gh/saxix/django-admin-extra-urls/branch/master/graph/badge.svg 171 | :target: https://codecov.io/gh/saxix/django-admin-extra-urls 172 | 173 | .. |dev-build| image:: https://github.com/saxix/django-admin-extra-urls/actions/workflows/test.yml/badge.svg?branch=develop 174 | :target: https://github.com/saxix/django-admin-extra-urls 175 | 176 | .. |dev-cov| image:: https://codecov.io/gh/saxix/django-admin-extra-urls/branch/develop/graph/badge.svg 177 | :target: https://codecov.io/gh/saxix/django-admin-extra-urls 178 | 179 | 180 | .. |python| image:: https://img.shields.io/pypi/pyversions/admin-extra-urls.svg 181 | :target: https://pypi.python.org/pypi/admin-extra-urls/ 182 | :alt: Supported Python versions 183 | 184 | .. |pypi| image:: https://img.shields.io/pypi/v/admin-extra-urls.svg?label=version 185 | :target: https://pypi.python.org/pypi/admin-extra-urls/ 186 | :alt: Latest Version 187 | 188 | .. |license| image:: https://img.shields.io/pypi/l/admin-extra-urls.svg 189 | :target: https://pypi.python.org/pypi/admin-extra-urls/ 190 | :alt: License 191 | 192 | .. |travis| image:: https://travis-ci.org/saxix/django-admin-extra-urls.svg?branch=develop 193 | :target: https://travis-ci.org/saxix/django-admin-extra-urls 194 | 195 | .. |django| image:: https://img.shields.io/badge/Django-1.8-orange.svg 196 | :target: http://djangoproject.com/ 197 | :alt: Django 1.7, 1.8 198 | -------------------------------------------------------------------------------- /docs/_ext/applyxrefs.py: -------------------------------------------------------------------------------- 1 | """Adds xref targets to the top of files.""" 2 | 3 | import os 4 | import sys 5 | 6 | testing = False 7 | 8 | DONT_TOUCH = ( 9 | './index.txt', 10 | ) 11 | 12 | 13 | def target_name(fn): 14 | if fn.endswith('.txt'): 15 | fn = fn[:-4] 16 | return '_' + fn.lstrip('./').replace('/', '-') 17 | 18 | 19 | def process_file(fn, lines): 20 | lines.insert(0, '\n') 21 | lines.insert(0, '.. %s:\n' % target_name(fn)) 22 | try: 23 | f = open(fn, 'w') 24 | except IOError: 25 | print("Can't open %s for writing. Not touching it." % fn) 26 | return 27 | try: 28 | f.writelines(lines) 29 | except IOError: 30 | print("Can't write to %s. Not touching it." % fn) 31 | finally: 32 | f.close() 33 | 34 | 35 | def has_target(fn): 36 | try: 37 | f = open(fn, 'r') 38 | except IOError: 39 | print("Can't open %s. Not touching it." % fn) 40 | return (True, None) 41 | readok = True 42 | try: 43 | lines = f.readlines() 44 | except IOError: 45 | print("Can't read %s. Not touching it." % fn) 46 | readok = False 47 | finally: 48 | f.close() 49 | if not readok: 50 | return (True, None) 51 | 52 | #print fn, len(lines) 53 | if len(lines) < 1: 54 | print("Not touching empty file %s." % fn) 55 | return True, None 56 | if lines[0].startswith('.. _'): 57 | return True, None 58 | return False, lines 59 | 60 | 61 | def main(argv=None): 62 | if argv is None: 63 | argv = sys.argv 64 | 65 | if len(argv) == 1: 66 | argv.extend('.') 67 | 68 | files = [] 69 | for root in argv[1:]: 70 | for (dirpath, dirnames, filenames) in os.walk(root): 71 | files.extend([(dirpath, f) for f in filenames]) 72 | files.sort() 73 | files = [os.path.join(p, fn) for p, fn in files if fn.endswith('.txt')] 74 | #print files 75 | 76 | for fn in files: 77 | if fn in DONT_TOUCH: 78 | print("Skipping blacklisted file %s." % fn) 79 | continue 80 | 81 | target_found, lines = has_target(fn) 82 | if not target_found: 83 | if testing: 84 | print '%s: %s' % (fn, lines[0]), 85 | else: 86 | print "Adding xref to %s" % fn 87 | process_file(fn, lines) 88 | else: 89 | print "Skipping %s: already has a xref" % fn 90 | 91 | 92 | if __name__ == '__main__': 93 | sys.exit(main()) 94 | -------------------------------------------------------------------------------- /docs/_ext/djangodocs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sphinx plugins for Django documentation. 3 | """ 4 | 5 | import os 6 | 7 | import docutils.nodes 8 | import docutils.transforms 9 | import sphinx 10 | import sphinx.addnodes 11 | import sphinx.directives 12 | import sphinx.environment 13 | import sphinx.roles 14 | from docutils import nodes # noqa 15 | from sphinx.util.console import bold 16 | 17 | try: 18 | import json 19 | except ImportError: 20 | try: 21 | import simplejson as json 22 | except ImportError: 23 | try: 24 | from django.utils import simplejson as json 25 | except ImportError: 26 | json = None 27 | 28 | try: 29 | from sphinx import builders 30 | except ImportError: 31 | import sphinx.builder as builders 32 | try: 33 | import sphinx.builders.html as builders_html 34 | except ImportError: 35 | builders_html = builders 36 | 37 | try: 38 | import sphinx.writers.html as sphinx_htmlwriter 39 | except ImportError: 40 | import sphinx.htmlwriter as sphinx_htmlwriter 41 | 42 | 43 | def setup(app): 44 | app.add_crossref_type( 45 | directivename="setting", 46 | rolename="setting", 47 | indextemplate="pair: %s; setting", 48 | ) 49 | app.add_crossref_type( 50 | directivename="templatetag", 51 | rolename="ttag", 52 | indextemplate="pair: %s; template tag" 53 | ) 54 | app.add_crossref_type( 55 | directivename="templatefilter", 56 | rolename="tfilter", 57 | indextemplate="pair: %s; template filter" 58 | ) 59 | app.add_crossref_type( 60 | directivename="fieldlookup", 61 | rolename="lookup", 62 | indextemplate="pair: %s; field lookup type", 63 | ) 64 | app.add_description_unit( 65 | directivename="django-admin", 66 | rolename="djadmin", 67 | indextemplate="pair: %s; django-admin command", 68 | parse_node=parse_django_admin_node, 69 | ) 70 | app.add_description_unit( 71 | directivename="django-admin-option", 72 | rolename="djadminopt", 73 | indextemplate="pair: %s; django-admin command-line option", 74 | parse_node=parse_django_adminopt_node, 75 | ) 76 | app.add_config_value('django_next_version', '0.0', True) 77 | app.add_directive('versionadded', parse_version_directive, 1, (1, 1, 1)) 78 | app.add_directive('versionchanged', parse_version_directive, 1, (1, 1, 1)) 79 | app.add_transform(SuppressBlockquotes) 80 | app.add_builder(DjangoStandaloneHTMLBuilder) 81 | 82 | # Monkeypatch PickleHTMLBuilder so that it doesn't die in Sphinx 0.4.2 83 | if sphinx.__version__ == '0.4.2': 84 | monkeypatch_pickle_builder() 85 | 86 | 87 | def parse_version_directive(name, arguments, options, content, lineno, 88 | content_offset, block_text, state, state_machine): 89 | env = state.document.settings.env 90 | is_nextversion = env.config.django_next_version == arguments[0] 91 | ret = [] 92 | node = sphinx.addnodes.versionmodified() 93 | ret.append(node) 94 | if not is_nextversion: 95 | if len(arguments) == 1: 96 | linktext = 'Please, see the release notes ' % (arguments[0]) 97 | xrefs = sphinx.roles.xfileref_role('ref', linktext, linktext, lineno, state) 98 | node.extend(xrefs[0]) 99 | node['version'] = arguments[0] 100 | else: 101 | node['version'] = "Development version" 102 | node['type'] = name 103 | if len(arguments) == 2: 104 | inodes, messages = state.inline_text(arguments[1], lineno + 1) 105 | node.extend(inodes) 106 | if content: 107 | state.nested_parse(content, content_offset, node) 108 | ret = ret + messages 109 | env.note_versionchange(node['type'], node['version'], node, lineno) 110 | return ret 111 | 112 | 113 | class SuppressBlockquotes(docutils.transforms.Transform): 114 | """ 115 | Remove the default blockquotes that encase indented list, tables, etc. 116 | """ 117 | default_priority = 300 118 | 119 | suppress_blockquote_child_nodes = ( 120 | docutils.nodes.bullet_list, 121 | docutils.nodes.enumerated_list, 122 | docutils.nodes.definition_list, 123 | docutils.nodes.literal_block, 124 | docutils.nodes.doctest_block, 125 | docutils.nodes.line_block, 126 | docutils.nodes.table 127 | ) 128 | 129 | def apply(self): 130 | for node in self.document.traverse(docutils.nodes.block_quote): 131 | if len(node.children) == 1 and isinstance(node.children[0], self.suppress_blockquote_child_nodes): 132 | node.replace_self(node.children[0]) 133 | 134 | 135 | class DjangoHTMLTranslator(sphinx_htmlwriter.SmartyPantsHTMLTranslator): 136 | """ 137 | Django-specific reST to HTML tweaks. 138 | """ 139 | 140 | # Don't use border=1, which docutils does by default. 141 | def visit_table(self, node): 142 | self.body.append(self.starttag(node, 'table', CLASS='docutils')) 143 | 144 | # ? Really? 145 | def visit_desc_parameterlist(self, node): 146 | self.body.append('(') 147 | self.first_param = 1 148 | 149 | def depart_desc_parameterlist(self, node): 150 | self.body.append(')') 151 | pass 152 | 153 | # 154 | # Don't apply smartypants to literal blocks 155 | # 156 | def visit_literal_block(self, node): 157 | self.no_smarty += 1 158 | sphinx_htmlwriter.SmartyPantsHTMLTranslator.visit_literal_block(self, node) 159 | 160 | def depart_literal_block(self, node): 161 | sphinx_htmlwriter.SmartyPantsHTMLTranslator.depart_literal_block(self, node) 162 | self.no_smarty -= 1 163 | 164 | # 165 | # Turn the "new in version" stuff (versoinadded/versionchanged) into a 166 | # better callout -- the Sphinx default is just a little span, 167 | # which is a bit less obvious that I'd like. 168 | # 169 | # FIXME: these messages are all hardcoded in English. We need to chanage 170 | # that to accomodate other language docs, but I can't work out how to make 171 | # that work and I think it'll require Sphinx 0.5 anyway. 172 | # 173 | version_text = { 174 | 'deprecated': 'Deprecated in Django %s', 175 | 'versionchanged': 'Changed in Django %s', 176 | 'versionadded': 'New in Django %s', 177 | } 178 | 179 | def visit_versionmodified(self, node): 180 | self.body.append( 181 | self.starttag(node, 'div', CLASS=node['type']) 182 | ) 183 | title = "%s%s" % ( 184 | self.version_text[node['type']] % node['version'], 185 | len(node) and ":" or "." 186 | ) 187 | self.body.append('%s ' % title) 188 | 189 | def depart_versionmodified(self, node): 190 | self.body.append("\n") 191 | 192 | # Give each section a unique ID -- nice for custom CSS hooks 193 | # This is different on docutils 0.5 vs. 0.4... 194 | 195 | if hasattr(sphinx_htmlwriter.SmartyPantsHTMLTranslator, 'start_tag_with_title') and sphinx.__version__ == '0.4.2': 196 | def start_tag_with_title(self, node, tagname, **atts): 197 | node = { 198 | 'classes': node.get('classes', []), 199 | 'ids': ['s-%s' % i for i in node.get('ids', [])] 200 | } 201 | return self.starttag(node, tagname, **atts) 202 | 203 | else: 204 | def visit_section(self, node): 205 | old_ids = node.get('ids', []) 206 | node['ids'] = ['s-' + i for i in old_ids] 207 | if sphinx.__version__ != '0.4.2': 208 | node['ids'].extend(old_ids) 209 | sphinx_htmlwriter.SmartyPantsHTMLTranslator.visit_section(self, node) 210 | node['ids'] = old_ids 211 | 212 | 213 | def parse_django_admin_node(env, sig, signode): 214 | command = sig.split(' ')[0] 215 | env._django_curr_admin_command = command 216 | title = "django-admin.py %s" % sig 217 | signode += sphinx.addnodes.desc_name(title, title) 218 | return sig 219 | 220 | 221 | def parse_django_adminopt_node(env, sig, signode): 222 | """A copy of sphinx.directives.CmdoptionDesc.parse_signature()""" 223 | from sphinx import addnodes 224 | from sphinx.directives.desc import option_desc_re 225 | 226 | count = 0 227 | firstname = '' 228 | for m in option_desc_re.finditer(sig): 229 | optname, args = m.groups() 230 | if count: 231 | signode += addnodes.desc_addname(', ', ', ') 232 | signode += addnodes.desc_name(optname, optname) 233 | signode += addnodes.desc_addname(args, args) 234 | if not count: 235 | firstname = optname 236 | count += 1 237 | if not firstname: 238 | raise ValueError 239 | return firstname 240 | 241 | 242 | def monkeypatch_pickle_builder(): 243 | import shutil 244 | from os import path 245 | 246 | try: 247 | import cPickle as pickle 248 | except ImportError: 249 | import pickle 250 | 251 | def handle_finish(self): 252 | # dump the global context 253 | outfilename = path.join(self.outdir, 'globalcontext.pickle') 254 | f = open(outfilename, 'wb') 255 | try: 256 | pickle.dump(self.globalcontext, f, 2) 257 | finally: 258 | f.close() 259 | 260 | self.info(bold('dumping search index...')) 261 | self.indexer.prune(self.env.all_docs) 262 | f = open(path.join(self.outdir, 'searchindex.pickle'), 'wb') 263 | try: 264 | self.indexer.dump(f, 'pickle') 265 | finally: 266 | f.close() 267 | 268 | # copy the environment file from the doctree dir to the output dir 269 | # as needed by the web app 270 | shutil.copyfile(path.join(self.doctreedir, builders.ENV_PICKLE_FILENAME), 271 | path.join(self.outdir, builders.ENV_PICKLE_FILENAME)) 272 | 273 | # touch 'last build' file, used by the web application to determine 274 | # when to reload its environment and clear the cache 275 | open(path.join(self.outdir, builders.LAST_BUILD_FILENAME), 'w').close() 276 | 277 | builders.PickleHTMLBuilder.handle_finish = handle_finish 278 | 279 | 280 | class DjangoStandaloneHTMLBuilder(builders_html.StandaloneHTMLBuilder): 281 | """ 282 | Subclass to add some extra things we need. 283 | """ 284 | 285 | name = 'djangohtml' 286 | 287 | def finish(self): 288 | super().finish() 289 | if json is None: 290 | self.warn("cannot create templatebuiltins.js due to missing simplejson dependency") 291 | return 292 | self.info(bold("writing templatebuiltins.js...")) 293 | xrefs = self.env.reftargets.keys() 294 | templatebuiltins = dict([('ttags', [n for (t, n) in xrefs if t == 'ttag']), 295 | ('tfilters', [n for (t, n) in xrefs if t == 'tfilter'])]) 296 | outfilename = os.path.join(self.outdir, "templatebuiltins.js") 297 | f = open(outfilename, 'wb') 298 | f.write('var django_template_builtins = ') 299 | json.dump(templatebuiltins, f) 300 | f.write(';\n') 301 | f.close() 302 | -------------------------------------------------------------------------------- /docs/_ext/literals_to_xrefs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Runs through a reST file looking for old-style literals, and helps replace them 3 | with new-style references. 4 | """ 5 | 6 | import re 7 | import shelve 8 | import sys 9 | 10 | refre = re.compile(r'``([^`\s]+?)``') 11 | 12 | ROLES = ( 13 | 'attr', 14 | 'class', 15 | "djadmin", 16 | 'data', 17 | 'exc', 18 | 'file', 19 | 'func', 20 | 'lookup', 21 | 'meth', 22 | 'mod', 23 | "djadminopt", 24 | "ref", 25 | "setting", 26 | "term", 27 | "tfilter", 28 | "ttag", 29 | 30 | # special 31 | "skip" 32 | ) 33 | 34 | ALWAYS_SKIP = [ 35 | "NULL", 36 | "True", 37 | "False", 38 | ] 39 | 40 | 41 | def fixliterals(fname): 42 | data = open(fname).read() 43 | 44 | last = 0 45 | new = [] 46 | storage = shelve.open("/tmp/literals_to_xref.shelve") 47 | lastvalues = storage.get("lastvalues", {}) 48 | 49 | for m in refre.finditer(data): 50 | 51 | new.append(data[last:m.start()]) 52 | last = m.end() 53 | 54 | line_start = data.rfind("\n", 0, m.start()) 55 | line_end = data.find("\n", m.end()) 56 | prev_start = data.rfind("\n", 0, line_start) 57 | next_end = data.find("\n", line_end + 1) 58 | 59 | # Skip always-skip stuff 60 | if m.group(1) in ALWAYS_SKIP: 61 | new.append(m.group(0)) 62 | continue 63 | 64 | # skip when the next line is a title 65 | next_line = data[m.end():next_end].strip() 66 | if next_line[0] in "!-/:-@[-`{-~" and all(c == next_line[0] for c in next_line): 67 | new.append(m.group(0)) 68 | continue 69 | 70 | sys.stdout.write("\n" + "-" * 80 + "\n") 71 | sys.stdout.write(data[prev_start + 1:m.start()]) 72 | sys.stdout.write(colorize(m.group(0), fg="red")) 73 | sys.stdout.write(data[m.end():next_end]) 74 | sys.stdout.write("\n\n") 75 | 76 | replace_type = None 77 | while replace_type is None: 78 | replace_type = raw_input( 79 | colorize("Replace role: ", fg="yellow") 80 | ).strip().lower() 81 | if replace_type and replace_type not in ROLES: 82 | replace_type = None 83 | 84 | if replace_type == "": 85 | new.append(m.group(0)) 86 | continue 87 | 88 | if replace_type == "skip": 89 | new.append(m.group(0)) 90 | ALWAYS_SKIP.append(m.group(1)) 91 | continue 92 | 93 | default = lastvalues.get(m.group(1), m.group(1)) 94 | if default.endswith("()") and replace_type in ("class", "func", "meth"): 95 | default = default[:-2] 96 | replace_value = raw_input( 97 | colorize("Text [", fg="yellow") + default + colorize("]: ", fg="yellow") 98 | ).strip() 99 | if not replace_value: 100 | replace_value = default 101 | new.append(":%s:`%s`" % (replace_type, replace_value)) 102 | lastvalues[m.group(1)] = replace_value 103 | 104 | new.append(data[last:]) 105 | open(fname, "w").write("".join(new)) 106 | 107 | storage["lastvalues"] = lastvalues 108 | storage.close() 109 | 110 | # 111 | # The following is taken from django.utils.termcolors and is copied here to 112 | # avoid the dependancy. 113 | # 114 | 115 | 116 | def colorize(text='', opts=(), **kwargs): 117 | """ 118 | Returns your text, enclosed in ANSI graphics codes. 119 | 120 | Depends on the keyword arguments 'fg' and 'bg', and the contents of 121 | the opts tuple/list. 122 | 123 | Returns the RESET code if no parameters are given. 124 | 125 | Valid colors: 126 | 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white' 127 | 128 | Valid options: 129 | 'bold' 130 | 'underscore' 131 | 'blink' 132 | 'reverse' 133 | 'conceal' 134 | 'noreset' - string will not be auto-terminated with the RESET code 135 | 136 | Examples: 137 | colorize('hello', fg='red', bg='blue', opts=('blink',)) 138 | colorize() 139 | colorize('goodbye', opts=('underscore',)) 140 | print colorize('first line', fg='red', opts=('noreset',)) 141 | print 'this should be red too' 142 | print colorize('and so should this') 143 | print 'this should not be red' 144 | """ 145 | color_names = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white') 146 | foreground = dict([(color_names[x], '3%s' % x) for x in range(8)]) 147 | background = dict([(color_names[x], '4%s' % x) for x in range(8)]) 148 | 149 | RESET = '0' 150 | opt_dict = {'bold': '1', 'underscore': '4', 'blink': '5', 'reverse': '7', 'conceal': '8'} 151 | 152 | text = str(text) 153 | code_list = [] 154 | if text == '' and len(opts) == 1 and opts[0] == 'reset': 155 | return '\x1b[%sm' % RESET 156 | for k, v in kwargs.iteritems(): 157 | if k == 'fg': 158 | code_list.append(foreground[v]) 159 | elif k == 'bg': 160 | code_list.append(background[v]) 161 | for o in opts: 162 | if o in opt_dict: 163 | code_list.append(opt_dict[o]) 164 | if 'noreset' not in opts: 165 | text = text + '\x1b[%sm' % RESET 166 | return ('\x1b[%sm' % ';'.join(code_list)) + text 167 | 168 | 169 | if __name__ == '__main__': 170 | try: 171 | fixliterals(sys.argv[1]) 172 | except (KeyboardInterrupt, SystemExit): 173 | print 174 | -------------------------------------------------------------------------------- /docs/_static/rtd.css: -------------------------------------------------------------------------------- 1 | /* 2 | * rtd.css 3 | * ~~~~~~~~~~~~~~~ 4 | * 5 | * Sphinx stylesheet -- sphinxdoc theme. Originally created by 6 | * Armin Ronacher for Werkzeug. 7 | * 8 | * Customized for ReadTheDocs by Eric Pierce & Eric Holscher 9 | * 10 | * :copyright: Copyright 2007-2010 by the Sphinx team, see AUTHORS. 11 | * :license: BSD, see LICENSE for details. 12 | * 13 | */ 14 | 15 | /* RTD colors 16 | * light blue: #e8ecef 17 | * medium blue: #8ca1af 18 | * dark blue: #465158 19 | * dark grey: #444444 20 | * 21 | * white hover: #d1d9df; 22 | * medium blue hover: #697983; 23 | * green highlight: #8ecc4c 24 | * light blue (project bar): #e8ecef 25 | */ 26 | 27 | @import url("basic.css"); 28 | 29 | /* PAGE LAYOUT -------------------------------------------------------------- */ 30 | 31 | body { 32 | font: 100%/1.5 "ff-meta-web-pro-1","ff-meta-web-pro-2",Arial,"Helvetica Neue",sans-serif; 33 | text-align: center; 34 | color: black; 35 | background-color: #465158; 36 | padding: 0; 37 | margin: 0; 38 | } 39 | 40 | div.document { 41 | text-align: left; 42 | background-color: #e8ecef; 43 | } 44 | 45 | div.bodywrapper { 46 | background-color: #ffffff; 47 | border-left: 1px solid #ccc; 48 | border-bottom: 1px solid #ccc; 49 | margin: 0 0 0 16em; 50 | } 51 | 52 | div.body { 53 | margin: 0; 54 | padding: 0.5em 1.3em; 55 | min-width: 20em; 56 | } 57 | 58 | div.related { 59 | font-size: 1em; 60 | background-color: #465158; 61 | } 62 | 63 | div.documentwrapper { 64 | float: left; 65 | width: 100%; 66 | background-color: #e8ecef; 67 | } 68 | 69 | 70 | /* HEADINGS --------------------------------------------------------------- */ 71 | 72 | h1 { 73 | margin: 0; 74 | padding: 0.7em 0 0.3em 0; 75 | font-size: 1.5em; 76 | line-height: 1.15; 77 | color: #111; 78 | clear: both; 79 | } 80 | 81 | h2 { 82 | margin: 2em 0 0.2em 0; 83 | font-size: 1.35em; 84 | padding: 0; 85 | color: #465158; 86 | } 87 | 88 | h3 { 89 | margin: 1em 0 -0.3em 0; 90 | font-size: 1.2em; 91 | color: #6c818f; 92 | } 93 | 94 | div.body h1 a, div.body h2 a, div.body h3 a, div.body h4 a, div.body h5 a, div.body h6 a { 95 | color: black; 96 | } 97 | 98 | h1 a.anchor, h2 a.anchor, h3 a.anchor, h4 a.anchor, h5 a.anchor, h6 a.anchor { 99 | display: none; 100 | margin: 0 0 0 0.3em; 101 | padding: 0 0.2em 0 0.2em; 102 | color: #aaa !important; 103 | } 104 | 105 | h1:hover a.anchor, h2:hover a.anchor, h3:hover a.anchor, h4:hover a.anchor, 106 | h5:hover a.anchor, h6:hover a.anchor { 107 | display: inline; 108 | } 109 | 110 | h1 a.anchor:hover, h2 a.anchor:hover, h3 a.anchor:hover, h4 a.anchor:hover, 111 | h5 a.anchor:hover, h6 a.anchor:hover { 112 | color: #777; 113 | background-color: #eee; 114 | } 115 | 116 | 117 | /* LINKS ------------------------------------------------------------------ */ 118 | 119 | /* Normal links get a pseudo-underline */ 120 | a { 121 | color: #444; 122 | text-decoration: none; 123 | border-bottom: 1px solid #ccc; 124 | } 125 | 126 | /* Links in sidebar, TOC, index trees and tables have no underline */ 127 | .sphinxsidebar a, 128 | .toctree-wrapper a, 129 | .indextable a, 130 | #indices-and-tables a { 131 | color: #444; 132 | text-decoration: none; 133 | border-bottom: none; 134 | } 135 | 136 | /* Most links get an underline-effect when hovered */ 137 | a:hover, 138 | div.toctree-wrapper a:hover, 139 | .indextable a:hover, 140 | #indices-and-tables a:hover { 141 | color: #111; 142 | text-decoration: none; 143 | border-bottom: 1px solid #111; 144 | } 145 | 146 | /* Footer links */ 147 | div.footer a { 148 | color: #86989B; 149 | text-decoration: none; 150 | border: none; 151 | } 152 | div.footer a:hover { 153 | color: #a6b8bb; 154 | text-decoration: underline; 155 | border: none; 156 | } 157 | 158 | /* Permalink anchor (subtle grey with a red hover) */ 159 | div.body a.headerlink { 160 | color: #ccc; 161 | font-size: 1em; 162 | margin-left: 6px; 163 | padding: 0 4px 0 4px; 164 | text-decoration: none; 165 | border: none; 166 | } 167 | div.body a.headerlink:hover { 168 | color: #c60f0f; 169 | border: none; 170 | } 171 | 172 | 173 | /* NAVIGATION BAR --------------------------------------------------------- */ 174 | 175 | div.related ul { 176 | height: 2.5em; 177 | } 178 | 179 | div.related ul li { 180 | margin: 0; 181 | padding: 0.65em 0; 182 | float: left; 183 | display: block; 184 | color: white; /* For the >> separators */ 185 | font-size: 0.8em; 186 | } 187 | 188 | div.related ul li.right { 189 | float: right; 190 | margin-right: 5px; 191 | color: transparent; /* Hide the | separators */ 192 | } 193 | 194 | /* "Breadcrumb" links in nav bar */ 195 | div.related ul li a { 196 | order: none; 197 | background-color: inherit; 198 | font-weight: bold; 199 | margin: 6px 0 6px 4px; 200 | line-height: 1.75em; 201 | color: #ffffff; 202 | padding: 0.4em 0.8em; 203 | border: none; 204 | border-radius: 3px; 205 | } 206 | /* previous / next / modules / index links look more like buttons */ 207 | div.related ul li.right a { 208 | margin: 0.375em 0; 209 | background-color: #697983; 210 | text-shadow: 0 1px rgba(0, 0, 0, 0.5); 211 | border-radius: 3px; 212 | -webkit-border-radius: 3px; 213 | -moz-border-radius: 3px; 214 | } 215 | /* All navbar links light up as buttons when hovered */ 216 | div.related ul li a:hover { 217 | background-color: #8ca1af; 218 | color: #ffffff; 219 | text-decoration: none; 220 | border-radius: 3px; 221 | -webkit-border-radius: 3px; 222 | -moz-border-radius: 3px; 223 | } 224 | /* Take extra precautions for tt within links */ 225 | a tt, 226 | div.related ul li a tt { 227 | background: inherit !important; 228 | color: inherit !important; 229 | } 230 | 231 | 232 | /* SIDEBAR ---------------------------------------------------------------- */ 233 | 234 | div.sphinxsidebarwrapper { 235 | padding: 0; 236 | } 237 | 238 | div.sphinxsidebar { 239 | margin: 0; 240 | margin-left: -100%; 241 | float: left; 242 | top: 3em; 243 | left: 0; 244 | padding: 0 1em; 245 | width: 14em; 246 | font-size: 1em; 247 | text-align: left; 248 | background-color: #e8ecef; 249 | } 250 | 251 | div.sphinxsidebar img { 252 | max-width: 12em; 253 | } 254 | 255 | div.sphinxsidebar h3, 256 | div.sphinxsidebar h4, 257 | div.sphinxsidebar p.logo { 258 | margin: 1.2em 0 0.3em 0; 259 | font-size: 1em; 260 | padding: 0; 261 | color: #222222; 262 | font-family: "ff-meta-web-pro-1", "ff-meta-web-pro-2", "Arial", "Helvetica Neue", sans-serif; 263 | } 264 | 265 | div.sphinxsidebar h3 a { 266 | color: #444444; 267 | } 268 | 269 | div.sphinxsidebar ul, 270 | div.sphinxsidebar p { 271 | margin-top: 0; 272 | padding-left: 0; 273 | line-height: 130%; 274 | background-color: #e8ecef; 275 | } 276 | 277 | /* No bullets for nested lists, but a little extra indentation */ 278 | div.sphinxsidebar ul ul { 279 | list-style-type: none; 280 | margin-left: 1.5em; 281 | padding: 0; 282 | } 283 | 284 | /* A little top/bottom padding to prevent adjacent links' borders 285 | * from overlapping each other */ 286 | div.sphinxsidebar ul li { 287 | padding: 1px 0; 288 | } 289 | 290 | /* A little left-padding to make these align with the ULs */ 291 | div.sphinxsidebar p.topless { 292 | padding-left: 0 0 0 1em; 293 | } 294 | 295 | /* Make these into hidden one-liners */ 296 | div.sphinxsidebar ul li, 297 | div.sphinxsidebar p.topless { 298 | white-space: nowrap; 299 | overflow: hidden; 300 | } 301 | /* ...which become visible when hovered */ 302 | div.sphinxsidebar ul li:hover, 303 | div.sphinxsidebar p.topless:hover { 304 | overflow: visible; 305 | } 306 | 307 | /* Search text box and "Go" button */ 308 | #searchbox { 309 | margin-top: 2em; 310 | margin-bottom: 1em; 311 | background: #ddd; 312 | padding: 0.5em; 313 | border-radius: 6px; 314 | -moz-border-radius: 6px; 315 | -webkit-border-radius: 6px; 316 | } 317 | #searchbox h3 { 318 | margin-top: 0; 319 | } 320 | 321 | /* Make search box and button abut and have a border */ 322 | input, 323 | div.sphinxsidebar input { 324 | border: 1px solid #999; 325 | float: left; 326 | } 327 | 328 | /* Search textbox */ 329 | input[type="text"] { 330 | margin: 0; 331 | padding: 0 3px; 332 | height: 20px; 333 | width: 144px; 334 | border-top-left-radius: 3px; 335 | border-bottom-left-radius: 3px; 336 | -moz-border-radius-topleft: 3px; 337 | -moz-border-radius-bottomleft: 3px; 338 | -webkit-border-top-left-radius: 3px; 339 | -webkit-border-bottom-left-radius: 3px; 340 | } 341 | /* Search button */ 342 | input[type="submit"] { 343 | margin: 0 0 0 -1px; /* -1px prevents a double-border with textbox */ 344 | height: 22px; 345 | color: #444; 346 | background-color: #e8ecef; 347 | padding: 1px 4px; 348 | font-weight: bold; 349 | border-top-right-radius: 3px; 350 | border-bottom-right-radius: 3px; 351 | -moz-border-radius-topright: 3px; 352 | -moz-border-radius-bottomright: 3px; 353 | -webkit-border-top-right-radius: 3px; 354 | -webkit-border-bottom-right-radius: 3px; 355 | } 356 | input[type="submit"]:hover { 357 | color: #ffffff; 358 | background-color: #8ecc4c; 359 | } 360 | 361 | div.sphinxsidebar p.searchtip { 362 | clear: both; 363 | padding: 0.5em 0 0 0; 364 | background: #ddd; 365 | color: #666; 366 | font-size: 0.9em; 367 | } 368 | 369 | /* Sidebar links are unusual */ 370 | div.sphinxsidebar li a, 371 | div.sphinxsidebar p a { 372 | background: #e8ecef; /* In case links overlap main content */ 373 | border-radius: 3px; 374 | -moz-border-radius: 3px; 375 | -webkit-border-radius: 3px; 376 | border: 1px solid transparent; /* To prevent things jumping around on hover */ 377 | padding: 0 5px 0 5px; 378 | } 379 | div.sphinxsidebar li a:hover, 380 | div.sphinxsidebar p a:hover { 381 | color: #111; 382 | text-decoration: none; 383 | border: 1px solid #888; 384 | } 385 | div.sphinxsidebar p.logo a { 386 | border: 0; 387 | } 388 | 389 | /* Tweak any link appearing in a heading */ 390 | div.sphinxsidebar h3 a { 391 | } 392 | 393 | 394 | 395 | 396 | /* OTHER STUFF ------------------------------------------------------------ */ 397 | 398 | cite, code, tt { 399 | font-family: 'Consolas', 'Deja Vu Sans Mono', 400 | 'Bitstream Vera Sans Mono', monospace; 401 | font-size: 0.95em; 402 | letter-spacing: 0.01em; 403 | } 404 | 405 | tt { 406 | background-color: #f2f2f2; 407 | color: #444; 408 | } 409 | 410 | tt.descname, tt.descclassname, tt.xref { 411 | border: 0; 412 | } 413 | 414 | hr { 415 | border: 1px solid #abc; 416 | margin: 2em; 417 | } 418 | 419 | 420 | pre, #_fontwidthtest { 421 | font-family: 'Consolas', 'Deja Vu Sans Mono', 422 | 'Bitstream Vera Sans Mono', monospace; 423 | margin: 1em 2em; 424 | font-size: 0.95em; 425 | letter-spacing: 0.015em; 426 | line-height: 120%; 427 | padding: 0.5em; 428 | border: 1px solid #ccc; 429 | background-color: #eee; 430 | border-radius: 6px; 431 | -moz-border-radius: 6px; 432 | -webkit-border-radius: 6px; 433 | } 434 | 435 | pre a { 436 | color: inherit; 437 | text-decoration: underline; 438 | } 439 | 440 | td.linenos pre { 441 | margin: 1em 0em; 442 | } 443 | 444 | td.code pre { 445 | margin: 1em 0em; 446 | } 447 | 448 | div.quotebar { 449 | background-color: #f8f8f8; 450 | max-width: 250px; 451 | float: right; 452 | padding: 2px 7px; 453 | border: 1px solid #ccc; 454 | } 455 | 456 | div.topic { 457 | background-color: #f8f8f8; 458 | } 459 | 460 | table { 461 | border-collapse: collapse; 462 | margin: 0 -0.5em 0 -0.5em; 463 | } 464 | 465 | table td, table th { 466 | padding: 0.2em 0.5em 0.2em 0.5em; 467 | } 468 | 469 | 470 | /* ADMONITIONS AND WARNINGS ------------------------------------------------- */ 471 | 472 | /* Shared by admonitions, warnings and sidebars */ 473 | div.admonition, 474 | div.warning, 475 | div.sidebar { 476 | font-size: 0.9em; 477 | margin: 2em; 478 | padding: 0; 479 | /* 480 | border-radius: 6px; 481 | -moz-border-radius: 6px; 482 | -webkit-border-radius: 6px; 483 | */ 484 | } 485 | div.admonition p, 486 | div.warning p, 487 | div.sidebar p { 488 | margin: 0.5em 1em 0.5em 1em; 489 | padding: 0; 490 | } 491 | div.admonition pre, 492 | div.warning pre, 493 | div.sidebar pre { 494 | margin: 0.4em 1em 0.4em 1em; 495 | } 496 | div.admonition p.admonition-title, 497 | div.warning p.admonition-title, 498 | div.sidebar p.sidebar-title { 499 | margin: 0; 500 | padding: 0.1em 0 0.1em 0.5em; 501 | color: white; 502 | font-weight: bold; 503 | font-size: 1.1em; 504 | text-shadow: 0 1px rgba(0, 0, 0, 0.5); 505 | } 506 | div.admonition ul, div.admonition ol, 507 | div.warning ul, div.warning ol, 508 | div.sidebar ul, div.sidebar ol { 509 | margin: 0.1em 0.5em 0.5em 3em; 510 | padding: 0; 511 | } 512 | 513 | 514 | /* Admonitions and sidebars only */ 515 | div.admonition, div.sidebar { 516 | border: 1px solid #609060; 517 | background-color: #e9ffe9; 518 | } 519 | div.admonition p.admonition-title, 520 | div.sidebar p.sidebar-title { 521 | background-color: #70A070; 522 | border-bottom: 1px solid #609060; 523 | } 524 | 525 | 526 | /* Warnings only */ 527 | div.warning { 528 | border: 1px solid #900000; 529 | background-color: #ffe9e9; 530 | } 531 | div.warning p.admonition-title { 532 | background-color: #b04040; 533 | border-bottom: 1px solid #900000; 534 | } 535 | 536 | 537 | /* Sidebars only */ 538 | div.sidebar { 539 | max-width: 30%; 540 | } 541 | 542 | 543 | 544 | div.versioninfo { 545 | margin: 1em 0 0 0; 546 | border: 1px solid #ccc; 547 | background-color: #DDEAF0; 548 | padding: 8px; 549 | line-height: 1.3em; 550 | font-size: 0.9em; 551 | } 552 | 553 | .viewcode-back { 554 | font-family: 'Lucida Grande', 'Lucida Sans Unicode', 'Geneva', 555 | 'Verdana', sans-serif; 556 | } 557 | 558 | div.viewcode-block:target { 559 | background-color: #f4debf; 560 | border-top: 1px solid #ac9; 561 | border-bottom: 1px solid #ac9; 562 | } 563 | 564 | dl { 565 | margin: 1em 0 2.5em 0; 566 | } 567 | 568 | /* Highlight target when you click an internal link */ 569 | dt:target { 570 | background: #ffe080; 571 | } 572 | /* Don't highlight whole divs */ 573 | div.highlight { 574 | background: transparent; 575 | } 576 | /* But do highlight spans (so search results can be highlighted) */ 577 | span.highlight { 578 | background: #ffe080; 579 | } 580 | 581 | div.footer { 582 | background-color: #465158; 583 | color: #eeeeee; 584 | padding: 0 2em 2em 2em; 585 | clear: both; 586 | font-size: 0.8em; 587 | text-align: center; 588 | } 589 | 590 | p { 591 | margin: 0.8em 0 0.5em 0; 592 | } 593 | 594 | .section p img.math { 595 | margin: 0; 596 | } 597 | 598 | 599 | .section p img { 600 | margin: 1em 2em; 601 | } 602 | 603 | 604 | /* MOBILE LAYOUT -------------------------------------------------------------- */ 605 | 606 | @media screen and (max-width: 600px) { 607 | 608 | h1, h2, h3, h4, h5 { 609 | position: relative; 610 | } 611 | 612 | ul { 613 | padding-left: 1.25em; 614 | } 615 | 616 | div.bodywrapper a.headerlink, #indices-and-tables h1 a { 617 | color: #e6e6e6; 618 | font-size: 80%; 619 | float: right; 620 | line-height: 1.8; 621 | position: absolute; 622 | right: -0.7em; 623 | visibility: inherit; 624 | } 625 | 626 | div.bodywrapper h1 a.headerlink, #indices-and-tables h1 a { 627 | line-height: 1.5; 628 | } 629 | 630 | pre { 631 | font-size: 0.7em; 632 | overflow: auto; 633 | word-wrap: break-word; 634 | white-space: pre-wrap; 635 | } 636 | 637 | div.related ul { 638 | height: 2.5em; 639 | padding: 0; 640 | text-align: left; 641 | } 642 | 643 | div.related ul li { 644 | clear: both; 645 | color: #465158; 646 | padding: 0.2em 0; 647 | } 648 | 649 | div.related ul li:last-child { 650 | border-bottom: 1px dotted #8ca1af; 651 | padding-bottom: 0.4em; 652 | margin-bottom: 1em; 653 | width: 100%; 654 | } 655 | 656 | div.related ul li a { 657 | color: #465158; 658 | padding-right: 0; 659 | } 660 | 661 | div.related ul li a:hover { 662 | background: inherit; 663 | color: inherit; 664 | } 665 | 666 | div.related ul li.right { 667 | clear: none; 668 | padding: 0.65em 0; 669 | margin-bottom: 0.5em; 670 | } 671 | 672 | div.related ul li.right a { 673 | color: #fff; 674 | padding-right: 0.8em; 675 | } 676 | 677 | div.related ul li.right a:hover { 678 | background-color: #8ca1af; 679 | } 680 | 681 | div.body { 682 | clear: both; 683 | min-width: 0; 684 | word-wrap: break-word; 685 | } 686 | 687 | div.bodywrapper { 688 | margin: 0 0 0 0; 689 | } 690 | 691 | div.sphinxsidebar { 692 | float: none; 693 | margin: 0; 694 | width: auto; 695 | } 696 | 697 | div.sphinxsidebar input[type="text"] { 698 | height: 2em; 699 | line-height: 2em; 700 | width: 70%; 701 | } 702 | 703 | div.sphinxsidebar input[type="submit"] { 704 | height: 2em; 705 | margin-left: 0.5em; 706 | width: 20%; 707 | } 708 | 709 | div.sphinxsidebar p.searchtip { 710 | background: inherit; 711 | margin-bottom: 1em; 712 | } 713 | 714 | div.sphinxsidebar ul li, div.sphinxsidebar p.topless { 715 | white-space: normal; 716 | } 717 | 718 | .bodywrapper img { 719 | display: block; 720 | margin-left: auto; 721 | margin-right: auto; 722 | max-width: 100%; 723 | } 724 | 725 | div.documentwrapper { 726 | float: none; 727 | } 728 | 729 | div.admonition, div.warning, pre, blockquote { 730 | margin-left: 0em; 731 | margin-right: 0em; 732 | } 733 | 734 | .body p img { 735 | margin: 0; 736 | } 737 | 738 | #searchbox { 739 | background: transparent; 740 | } 741 | 742 | .related:not(:first-child) li { 743 | display: none; 744 | } 745 | 746 | .related:not(:first-child) li.right { 747 | display: block; 748 | } 749 | 750 | div.footer { 751 | padding: 1em; 752 | } 753 | 754 | .rtd_doc_footer .badge { 755 | float: none; 756 | margin: 1em auto; 757 | position: static; 758 | } 759 | 760 | .rtd_doc_footer .badge.revsys-inline { 761 | margin-right: auto; 762 | margin-bottom: 2em; 763 | } 764 | 765 | table.indextable { 766 | display: block; 767 | width: auto; 768 | } 769 | 770 | .indextable tr { 771 | display: block; 772 | } 773 | 774 | .indextable td { 775 | display: block; 776 | padding: 0; 777 | width: auto !important; 778 | } 779 | 780 | .indextable td dt { 781 | margin: 1em 0; 782 | } 783 | 784 | ul.search { 785 | margin-left: 0.25em; 786 | } 787 | 788 | ul.search li div.context { 789 | font-size: 90%; 790 | line-height: 1.1; 791 | margin-bottom: 1; 792 | margin-left: 0; 793 | } 794 | 795 | } 796 | 797 | em.permission{ 798 | padding-top: 20px; 799 | line-height: 30px; 800 | } 801 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | === 4 | API 5 | === 6 | 7 | .. autofunction:: admin_extra_urls.api.button 8 | 9 | 10 | .. autofunction:: admin_extra_urls.api.href 11 | 12 | 13 | ========= 14 | Utilities 15 | ========= 16 | 17 | .. autofunction:: admin_extra_urls.mixins.ExtraUrlMixin.get_common_context 18 | 19 | -------------------------------------------------------------------------------- /docs/changes.rst: -------------------------------------------------------------------------------- 1 | .. _changes: 2 | 3 | :tocdepth: 2 4 | 5 | ========== 6 | Changelog 7 | ========== 8 | 9 | This sections lists the biggest changes done on each release. 10 | 11 | .. contents:: 12 | :local: 13 | 14 | .. include:: ../CHANGES 15 | 16 | 17 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Django site maintenance documentation build configuration file, created by 2 | # sphinx-quickstart on Sun Dec 5 19:11:46 2010. 3 | # 4 | # This file is execfile()d with the current directory set to its containing dir. 5 | # 6 | # Note that not all possible configuration values are present in this 7 | # autogenerated file. 8 | # 9 | # All configuration values have a default; values that are commented out 10 | # serve to show the default. 11 | 12 | import os 13 | import sys 14 | 15 | 16 | import admin_extra_urls as app 17 | 18 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) 19 | 20 | import django 21 | from django.conf import settings 22 | settings.configure() 23 | django.setup() 24 | # If extensions (or modules to document with autodoc) are in another directory, 25 | # add these directories to sys.path here. If the directory is relative to the 26 | # documentation root, use os.path.abspath to make it absolute, like shown here. 27 | #sys.path.insert(0, os.path.abspath('.')) 28 | sys.path.append(os.path.join(os.path.dirname(__file__), "_ext")) 29 | 30 | # -- General configuration ----------------------------------------------------- 31 | 32 | # If your documentation needs a minimal Sphinx version, state it here. 33 | #needs_sphinx = '1.0' 34 | 35 | # Add any Sphinx extension module names here, as strings. They can be extensions 36 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 37 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.todo', 38 | 'sphinx.ext.graphviz', 'sphinx.ext.intersphinx', 39 | 'sphinx.ext.doctest', 'sphinx.ext.extlinks'] 40 | 41 | todo_include_todos = True 42 | #intersphinx_mapping = { 43 | # 'python': ('http://python.readthedocs.org/en/latest/', None), 44 | # 'django': ('http://django.readthedocs.org/en/1.4/', 'http://docs.djangoproject.com/en/dev/_objects/'), 45 | # 'sphinx': ('http://sphinx.readthedocs.org/en/latest/', None)} 46 | 47 | intersphinx_cache_limit = 90 # days 48 | 49 | # Add any paths that contain templates here, relative to this directory. 50 | templates_path = ['_templates'] 51 | 52 | # The suffix of source filenames. 53 | source_suffix = '.rst' 54 | 55 | # The encoding of source files. 56 | #source_encoding = 'utf-8-sig' 57 | 58 | # The master toctree document. 59 | master_doc = 'index' 60 | 61 | # General information about the project. 62 | project = u'Django Admin Extra-Urls' 63 | copyright = u'2015, Stefano Apostolico' 64 | 65 | # The version info for the project you're documenting, acts as replacement for 66 | # |version| and |release|, also used in various other places throughout the 67 | # built documents. 68 | # 69 | # The short X.Y version. 70 | version = app.VERSION 71 | # The full version, including alpha/beta/rc tags. 72 | release = app.VERSION 73 | next_version = '1.1' 74 | 75 | # The language for content autogenerated by Sphinx. Refer to documentation 76 | # for a list of supported languages. 77 | #language = None 78 | 79 | # There are two options for replacing |today|: either, you set today to some 80 | # non-false value, then it is used: 81 | #today = '' 82 | # Else, today_fmt is used as the format for a strftime call. 83 | #today_fmt = '%B %d, %Y' 84 | 85 | # List of patterns, relative to source directory, that match files and 86 | # directories to ignore when looking for source files. 87 | exclude_patterns = ['_build'] 88 | 89 | # The reST default role (used for this markup: `text`) to use for all documents. 90 | #default_role = None 91 | 92 | # If true, '()' will be appended to :func: etc. cross-reference text. 93 | #add_function_parentheses = True 94 | 95 | # If true, the current module name will be prepended to all description 96 | # unit titles (such as .. function::). 97 | #add_module_names = True 98 | 99 | # If true, sectionauthor and moduleauthor directives will be shown in the 100 | # output. They are ignored by default. 101 | #show_authors = False 102 | 103 | # The name of the Pygments (syntax highlighting) style to use. 104 | pygments_style = 'sphinx' 105 | 106 | # A list of ignored prefixes for module index sorting. 107 | #modindex_common_prefix = [] 108 | 109 | 110 | # -- Options for HTML output --------------------------------------------------- 111 | 112 | # The theme to use for HTML and HTML Help pages. See the documentation for 113 | # a list of builtin themes. 114 | 115 | # html_static_path = ['_static'] 116 | 117 | # Theme options are theme-specific and customize the look and feel of a theme 118 | # further. For a list of options available for each theme, see the 119 | # documentation. 120 | #html_theme_options = {} 121 | 122 | # Add any paths that contain custom themes here, relative to this directory. 123 | # html_theme_path = ['.'] 124 | 125 | # The name for this set of Sphinx documents. If None, it defaults to 126 | # " v documentation". 127 | #html_title = None 128 | 129 | # A shorter title for the navigation bar. Default is the same as html_title. 130 | #html_short_title = None 131 | 132 | # The name of an image file (relative to this directory) to place at the top 133 | # of the sidebar. 134 | #html_logo = '_static/pasport_logo.gif' 135 | 136 | # The name of an image file (within the static path) to use as favicon of the 137 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 138 | # pixels large. 139 | #html_favicon = None 140 | 141 | # Add any paths that contain custom static files (such as style sheets) here, 142 | # relative to this directory. They are copied after the builtin static files, 143 | # so a file named "default.css" will overwrite the builtin "default.css". 144 | #html_static_path = ['_static'] 145 | 146 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 147 | # using the given strftime format. 148 | #html_last_updated_fmt = '%b %d, %Y' 149 | 150 | # If true, SmartyPants will be used to convert quotes and dashes to 151 | # typographically correct entities. 152 | #html_use_smartypants = True 153 | 154 | # Custom sidebar templates, maps document names to template names. 155 | #html_sidebars = {} 156 | 157 | # Additional templates that should be rendered to pages, maps page names to 158 | # template names. 159 | #html_additional_pages = {} 160 | 161 | # If false, no module index is generated. 162 | #html_domain_indices = True 163 | 164 | # If false, no index is generated. 165 | #html_use_index = True 166 | 167 | # If true, the index is split into individual pages for each letter. 168 | #html_split_index = False 169 | 170 | # If true, links to the reST sources are added to the pages. 171 | #html_show_sourcelink = True 172 | 173 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 174 | #html_show_sphinx = True 175 | 176 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 177 | #html_show_copyright = True 178 | 179 | # If true, an OpenSearch description file will be output, and all pages will 180 | # contain a tag referring to it. The value of this option must be the 181 | # base URL from which the finished HTML is served. 182 | #html_use_opensearch = '' 183 | 184 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 185 | #html_file_suffix = None 186 | 187 | # Output file base name for HTML help builder. 188 | htmlhelp_basename = 'admin_extra_urls' 189 | 190 | 191 | # -- Options for LaTeX output -------------------------------------------------- 192 | 193 | # The paper size ('letter' or 'a4'). 194 | #latex_paper_size = 'letter' 195 | 196 | # The font size ('10pt', '11pt' or '12pt'). 197 | #latex_font_size = '10pt' 198 | 199 | # Grouping the document tree into LaTeX files. List of tuples 200 | # (source start file, target name, title, author, documentclass [howto/manual]). 201 | latex_documents = [ 202 | ('index', 'admin_extra_urls.tex', u"Django Admin Extra-urls Documentation", 203 | u'Stefano Apostolico', 'manual'), 204 | ] 205 | 206 | # The name of an image file (relative to this directory) to place at the top of 207 | # the title page. 208 | #latex_logo = None 209 | 210 | # For "manual" documents, if this is true, then toplevel headings are parts, 211 | # not chapters. 212 | #latex_use_parts = False 213 | 214 | # If true, show page references after internal links. 215 | #latex_show_pagerefs = False 216 | 217 | # If true, show URL addresses after external links. 218 | #latex_show_urls = False 219 | 220 | # Additional stuff for the LaTeX preamble. 221 | #latex_preamble = '' 222 | 223 | # Documents to append as an appendix to all manuals. 224 | #latex_appendices = [] 225 | 226 | # If false, no module index is generated. 227 | #latex_domain_indices = True 228 | 229 | 230 | # -- Options for manual page output -------------------------------------------- 231 | 232 | # One entry per manual page. List of tuples 233 | # (source start file, name, description, authors, manual section). 234 | man_pages = [ 235 | ('index', 'admin_extra_urls', u"Django Admin Extra-urls", 236 | [u'Stefano Apostolico'], 1) 237 | ] 238 | -------------------------------------------------------------------------------- /docs/howto.rst: -------------------------------------------------------------------------------- 1 | .. _howto: 2 | 3 | ===== 4 | HowTo 5 | ===== 6 | 7 | Integrate with `django-import-export` 8 | ------------------------------------- 9 | Import-export uses request to filter queryset, 10 | but extra-urls save all filters in single query parameter ``_changelist_filters`` 11 | when building @link href. 12 | 13 | :: 14 | 15 | from django.http.request import QueryDict 16 | 17 | @link(label='Export') 18 | def _export(self, request): 19 | if '_changelist_filters' in request.GET: 20 | real_query = QueryDict(request.GET.get('_changelist_filters')) 21 | request.GET = real_query 22 | return self.export_action(request) 23 | 24 | Credits to `@dglinyanov https://github.com/dglinyanov 25 | -------------------------------------------------------------------------------- /docs/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-admin-extra-urls/ab67f009bb8ae44d8f2300be846163aaf5e680f2/docs/image.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. _index: 2 | 3 | ======================= 4 | Django Admin Extra-urls 5 | ======================= 6 | 7 | 8 | Django application that offers one single mixin class ``ExtraUrlMixin`` 9 | to easily add new url (and related buttons on the screen) to any ModelAdmin. 10 | 11 | It provides two decorators ``button()`` and ``href()``. 12 | 13 | - ``button()`` decorator will produce a button in the list and change form views. 14 | 15 | - ``href()`` to add button that point to external urls. 16 | 17 | 18 | Install 19 | ------- 20 | 21 | .. code-block:: python 22 | 23 | pip install admin-extra-urls 24 | 25 | 26 | After installation add it to ``INSTALLED_APPS`` 27 | 28 | .. code-block:: python 29 | 30 | 31 | INSTALLED_APPS = ( 32 | ... 33 | 'admin_extra_urls', 34 | ) 35 | 36 | 37 | How to use it 38 | ------------- 39 | 40 | .. code-block:: python 41 | 42 | from admin_extra_urls.api import href, button 43 | 44 | class MyModelModelAdmin(extras.ExtraUrlMixin, admin.ModelAdmin): 45 | 46 | @href(label='Search On Google', 'http://www.google.com?q={target}') 47 | def search_on_google(self, button): 48 | # this is called by the template engine just before rendering the button 49 | # `context` is the Context instance in the template 50 | if 'original' in button.context: 51 | obj = button.context['original'] 52 | return {'target': obj.name} 53 | else: 54 | button.visible = False 55 | 56 | @href() 57 | def search_on_bing(self, button): 58 | return 'http://www.bing.com?q=target' 59 | 60 | 61 | @button() # /admin/myapp/mymodel/update_all/ 62 | def consolidate(self, request): 63 | ... 64 | ... 65 | 66 | @button() # /admin/myapp/mymodel/update/10/ 67 | def update(self, request, pk): 68 | # if we use `pk` in the args, the button will be in change_form 69 | obj = self.get_object(request, pk) 70 | ... 71 | 72 | @button(urls=[r'^aaa/(?P.*)/(?P.*)/$', 73 | r'^bbb/(?P.*)/$']) 74 | def revert(self, request, pk, state=None): 75 | obj = self.get_object(request, pk) 76 | ... 77 | 78 | @button(label='Truncate', permission=lambda request, obj: request.user.is_superuser) 79 | def truncate(self, request): 80 | 81 | if request.method == 'POST': 82 | self.model.objects._truncate() 83 | else: 84 | return extras._confirm_action(self, request, self.truncate, 85 | 'Continuing will erase the entire content of the table.', 86 | 'Successfully executed', ) 87 | 88 | 89 | 90 | .. toctree:: 91 | :maxdepth: 1 92 | 93 | api 94 | howto 95 | changes 96 | 97 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pep8] 2 | ignore = E501,E401,W391,E128,E261,E731 3 | max-line-length = 160 4 | exclude = .tox,migrations,.git,docs,diff_match_patch.py,deploy/**,settings,sheet_template 5 | 6 | 7 | [flake8] 8 | max-complexity = 12 9 | max-line-length = 160 10 | exclude = .tox,migrations,.git,docs,diff_match_patch.py,deploy/**,settings,sheet_template 11 | ignore = E501,E401,W391,E128,E261,E731 12 | 13 | [isort] 14 | combine_as_imports = true 15 | default_section = THIRDPARTY 16 | include_trailing_comma = true 17 | line_length=80 18 | known_future_library=future,pies 19 | known_standard_library = six 20 | known_third_party = django 21 | known_first_party = admin_extra_urls 22 | multi_line_output = 0 23 | not_skip = __init__.py 24 | sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 25 | 26 | [metadata] 27 | license-file = LICENSE 28 | 29 | [wheel] 30 | universal = 1 31 | 32 | [devpi:upload] 33 | formats=bdist_wheel,sdist.tgz 34 | 35 | [egg_info] 36 | tag_build = 37 | tag_date = 0 38 | tag_svn_revision = 0 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import ast 3 | import codecs 4 | import os 5 | import sys 6 | 7 | import re 8 | from setuptools import find_packages, setup 9 | from setuptools.command.test import test as TestCommand 10 | 11 | ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__))) 12 | init = os.path.join(ROOT, 'src', 'admin_extra_urls', '__init__.py') 13 | 14 | _version_re = re.compile(r'__version__\s+=\s+(.*)') 15 | _name_re = re.compile(r'NAME\s+=\s+(.*)') 16 | 17 | with open(init, 'rb') as f: 18 | content = f.read().decode('utf-8') 19 | version = str(ast.literal_eval(_version_re.search(content).group(1))) 20 | name = os.getenv('PACKAGE_NAME', 21 | str(ast.literal_eval(_name_re.search(content).group(1)))) 22 | 23 | 24 | def read(*parts): 25 | here = os.path.abspath(os.path.dirname(__file__)) 26 | return codecs.open(os.path.join(here, *parts), "r").read() 27 | 28 | 29 | tests_require =['coverage', 30 | 'factory-boy', 31 | 'django_webtest', 32 | 'pdbpp', 33 | 'pyquery', 34 | 'pytest', 35 | 'pytest-cov', 36 | 'pytest-django', 37 | 'pytest-echo', 38 | 'pytest-pythonpath', 39 | 'tox>=2.3', 40 | 'wheel', 41 | ] 42 | dev_require = ['autopep8', 43 | 'check-manifest', 44 | 'django', 45 | 'flake8', 46 | 'pep8', 47 | 'readme', 48 | 'sphinx', 49 | 'wheel', 50 | 'isort', 51 | ] 52 | 53 | setup( 54 | name=name, 55 | version=version, 56 | url='https://github.com/saxix/django-admin-extra-urls', 57 | download_url='https://pypi.python.org/pypi/admin-extra-urls', 58 | 59 | description='Django mixin to easily add urls to any ModelAdmin', 60 | long_description=read("README.rst"), 61 | package_dir={'': 'src'}, 62 | packages=find_packages('src'), 63 | include_package_data=True, 64 | platforms=['linux'], 65 | extras_require={ 66 | 'test': tests_require, 67 | 'dev': dev_require, 68 | }, 69 | tests_require=tests_require, 70 | classifiers=[ 71 | 'Environment :: Web Environment', 72 | 'Operating System :: OS Independent', 73 | 'Framework :: Django', 74 | 'Framework :: Django :: 2.2', 75 | 'Framework :: Django :: 3.2', 76 | 'Framework :: Django :: 4.0', 77 | 'License :: OSI Approved :: BSD License', 78 | 'Programming Language :: Python :: 3', 79 | 'Programming Language :: Python :: 3.8', 80 | 'Programming Language :: Python :: 3.9', 81 | 'Programming Language :: Python :: 3.10', 82 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 83 | 'Topic :: Software Development :: Libraries :: Python Modules', 84 | 85 | 'Intended Audience :: Developers' 86 | ] 87 | ) 88 | -------------------------------------------------------------------------------- /src/admin_extra_urls/__init__.py: -------------------------------------------------------------------------------- 1 | NAME = 'django-admin-extra-urls' 2 | VERSION = __version__ = '4.1.1' 3 | __author__ = 'sax' 4 | -------------------------------------------------------------------------------- /src/admin_extra_urls/api.py: -------------------------------------------------------------------------------- 1 | from .button import UrlButton # noqa: F401 2 | from .decorators import button, href, link, url # noqa: F401 3 | from .mixins import ExtraUrlMixin, confirm_action # noqa: F401 4 | -------------------------------------------------------------------------------- /src/admin_extra_urls/button.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from inspect import getfullargspec 3 | 4 | from django.urls import NoReverseMatch, reverse 5 | 6 | from admin_extra_urls.utils import check_permission, get_preserved_filters, labelize 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class UrlHandler: 12 | def __init__(self): 13 | pass 14 | 15 | 16 | class BaseExtraButton: 17 | default_css_class = 'btn disable-on-click auto-disable' 18 | 19 | def __init__(self, **options): 20 | self.options = options 21 | # self.options.setdefault('html_attrs', {}) 22 | # self.options.setdefault('display', lambda context: True) 23 | self.options.setdefault('icon', None) 24 | self.options.setdefault('change_list', False) 25 | self.options.setdefault('change_form', False) 26 | self.options.setdefault('url_name', None) 27 | self.options.setdefault('permission', None) 28 | self.options.setdefault('url', None) 29 | # self.options.setdefault('label', self.options['url']) 30 | # self.options.setdefault('name', None) 31 | self.options.setdefault('visible', True) 32 | # self.order = order 33 | # if display := options.get('display', empty) != empty: 34 | # self.display = (lambda context: True) if options.get('display') is None else options.get('display') 35 | # self.change_form = change_form 36 | # self.change_list = change_list 37 | # self.url_name = url_name 38 | # self._url = url 39 | self.context = None 40 | self.html_attrs = self.options.get('html_attrs', {}) 41 | 42 | def __repr__(self): 43 | return f'' 44 | 45 | def __html__(self): 46 | return f'[ExtraButton {self.options}]' 47 | 48 | def __copy__(self): 49 | return type(self)(**self.options) 50 | 51 | def __getattr__(self, item): 52 | if item in self.options: 53 | return self.options.get(item) 54 | raise ValueError(f'{item} is not a valid attribute for {self}') 55 | 56 | def visible(self): 57 | # if self.details and not self.original: 58 | # return False 59 | f = self.options['visible'] 60 | if callable(f): 61 | info = getfullargspec(f) 62 | params = [] 63 | # BACKWARD COMPATIBLE CODE: To remove in future release 64 | if len(info.args) == 1: 65 | params = [self.context] 66 | elif len(info.args) == 2: 67 | params = [self.context.get('original', None), 68 | self.context.get('request', None)] 69 | elif len(info.args) == 3: 70 | params = [self.context, 71 | self.context.get('original', None), 72 | self.context.get('request', None)] 73 | return f(*params) 74 | return f 75 | 76 | def authorized(self): 77 | if self.permission: 78 | return check_permission(self.permission, self.request, self.original) 79 | return True 80 | 81 | @property 82 | def request(self): 83 | if not self.context: 84 | raise ValueError(f"You need to call bind() to access 'request' on {self}") 85 | return self.context['request'] 86 | 87 | @property 88 | def original(self): 89 | if not self.context: 90 | raise ValueError(f"You need to call bind() to access 'original' on {self}") 91 | return self.context.get('original', None) 92 | 93 | def bind(self, context): 94 | self.context = context 95 | # this is only for backward compatibility 96 | if self.name and 'id' not in self.html_attrs: 97 | self.html_attrs['id'] = f'btn-{self.name}' 98 | return self 99 | 100 | 101 | class UrlButton(BaseExtraButton): 102 | def __init__(self, **options): 103 | super().__init__(**options) 104 | 105 | def label(self): 106 | return self.options.get('label', labelize(self.options.get('name', ''))) 107 | 108 | @property 109 | def url(self): 110 | if self.options.get('url'): 111 | return self.options.get('url') 112 | try: 113 | if self.original: 114 | url = reverse(f'admin:{self.url_name}', args=[self.original.pk]) 115 | else: 116 | url = reverse(f'admin:{self.url_name}') 117 | filters = get_preserved_filters(self.request) 118 | return f'{url}?{filters}' 119 | except NoReverseMatch as e: 120 | logger.exception(e) 121 | return f'javascript:alert("{e}")' 122 | 123 | 124 | class Button(BaseExtraButton): 125 | def __init__(self, **options): 126 | super().__init__(**options) 127 | self._params = {} 128 | 129 | def label(self): 130 | return self.options.get('label', str(self.url)) 131 | 132 | @property 133 | def url(self): 134 | if isinstance(self._params, dict): 135 | return self.options.get('url').format(**self._params) 136 | else: 137 | return self._params 138 | 139 | def bind(self, context): 140 | self.context = context 141 | if 'func' in self.options: 142 | self._params = (self.func(self) or {}) 143 | -------------------------------------------------------------------------------- /src/admin_extra_urls/checks.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import inspect 3 | 4 | from django.conf import settings 5 | from django.core.checks import Warning 6 | 7 | 8 | def get_all_permissions(): 9 | from django.contrib.auth.models import Permission 10 | return [f'{p[0]}.{p[1]}' 11 | for p in (Permission.objects 12 | .select_related('content_type') 13 | .values_list('content_type__app_label', 'codename'))] 14 | 15 | 16 | def check_decorator_errors(cls): 17 | target = cls 18 | standard_permissions = [] 19 | errors = [] 20 | if 'django.contrib.auth' in settings.INSTALLED_APPS: 21 | standard_permissions = get_all_permissions() 22 | 23 | def visit_FunctionDef(node): 24 | # deco = [] 25 | for n in node.decorator_list: 26 | if isinstance(n, ast.Call): 27 | name = n.func.attr if isinstance(n.func, ast.Attribute) else n.func.id 28 | else: 29 | name = n.attr if isinstance(n, ast.Attribute) else n.id 30 | if name in ['href', ]: 31 | errors.append(Warning(f'"{cls.__name__}.{node.name}" uses deprecated decorator "@{name}"', 32 | id='admin_extra_urls.W001')) 33 | elif name in ['button']: 34 | if standard_permissions: 35 | for k in n.keywords: 36 | if k.arg == 'permission' and isinstance(k.value, ast.Constant): 37 | perm_name = k.value.value 38 | if perm_name not in standard_permissions: 39 | errors.append(Warning(f'"{cls.__name__}.{node.name}" ' 40 | f'is checking for a non existent permission ' 41 | f'"{perm_name}"', 42 | id='admin_extra_urls.PERM', )) 43 | 44 | node_iter = ast.NodeVisitor() 45 | node_iter.visit_FunctionDef = visit_FunctionDef 46 | node_iter.visit(ast.parse(inspect.getsource(target))) 47 | return errors 48 | -------------------------------------------------------------------------------- /src/admin_extra_urls/config.py: -------------------------------------------------------------------------------- 1 | 2 | class UrlConfig: 3 | def __init__(self, *, func=None, path=None, button=None, 4 | details=False, permission=None, object_id_arg_name='object_id', **extra): 5 | self.func = func 6 | self.method = func.__name__ 7 | self.path = path 8 | self.permission = permission 9 | self.details = details 10 | self.button = button 11 | self.object_id_arg_name = object_id_arg_name 12 | self.extra = extra 13 | 14 | def __repr__(self): 15 | return f'' 16 | -------------------------------------------------------------------------------- /src/admin_extra_urls/decorators.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from django.contrib.admin.templatetags.admin_urls import admin_urlname 4 | from django.http import HttpResponse, HttpResponseRedirect 5 | from django.urls import reverse 6 | from django.utils.http import urlencode 7 | 8 | from .button import Button 9 | from .config import UrlConfig 10 | from .utils import check_permission, empty, encapsulate 11 | 12 | 13 | def url(permission=None, button=False, details=empty, path=None, **extra): 14 | if callable(permission): 15 | permission = encapsulate(permission) 16 | 17 | def decorator(func): 18 | sig = inspect.signature(func) 19 | object_id_arg_name = 'pk' # backward compatibility 20 | if len(sig.parameters) > 2: 21 | object_id_arg_name = list(sig.parameters)[2] 22 | if details == empty: 23 | _details = object_id_arg_name in sig.parameters 24 | else: 25 | _details = details 26 | 27 | url_config = UrlConfig(func=func, 28 | permission=permission, 29 | button=button, 30 | details=_details, 31 | path=path, 32 | object_id_arg_name=object_id_arg_name, 33 | **extra) 34 | 35 | def view(modeladmin, request, *args, **kwargs): 36 | if url_config.details: 37 | pk = kwargs[object_id_arg_name] 38 | obj = modeladmin.get_object(request, pk) 39 | url_path = reverse(admin_urlname(modeladmin.model._meta, 'change'), 40 | args=[pk]) 41 | if url_config.permission: 42 | check_permission(url_config.permission, request, obj) 43 | 44 | else: 45 | url_path = reverse(admin_urlname(modeladmin.model._meta, 'changelist')) 46 | if url_config.permission: 47 | check_permission(url_config.permission, request) 48 | 49 | ret = func(modeladmin, request, *args, **kwargs) 50 | 51 | if not isinstance(ret, HttpResponse): 52 | preserved_filters = request.GET.get('_changelist_filters', '') 53 | filters = urlencode({'_changelist_filters': preserved_filters}) 54 | return HttpResponseRedirect('?'.join([url_path, filters])) 55 | return ret 56 | 57 | view.url = url_config 58 | 59 | return view 60 | 61 | return decorator 62 | 63 | 64 | def button(**kwargs): 65 | url_args = {'permission': kwargs.pop('permission', None), 66 | 'details': kwargs.pop('details', empty), 67 | 'path': kwargs.pop('path', None), 68 | } 69 | if urls := kwargs.pop('urls', None): 70 | if len(urls) > 1: 71 | raise ValueError('urls in not supported in this version of admin-extra-urls') 72 | url_args['path'] = urls[0] 73 | 74 | url_args['button'] = {'visible': kwargs.pop('visible', True)} 75 | if 'label' in kwargs: 76 | url_args['button']['label'] = kwargs.pop('label') 77 | return url(**url_args) 78 | 79 | 80 | def link(**kwargs): 81 | url_args = {'permission': kwargs.pop('permission', None), 82 | 'button': Button(html_attrs=kwargs.pop('html_attrs', {}), 83 | change_list=True, 84 | change_form=True, 85 | visible=True, 86 | url=kwargs.get('url', '.') 87 | ), 88 | 'details': kwargs.pop('details', empty), 89 | 'path': kwargs.pop('path', None) 90 | } 91 | if 'label' in kwargs: 92 | url_args['button'].options['label'] = kwargs.pop('label') 93 | 94 | return url(**url_args) 95 | 96 | 97 | href = link 98 | -------------------------------------------------------------------------------- /src/admin_extra_urls/mixins.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import logging 3 | from functools import partial, update_wrapper 4 | 5 | from django.conf import settings 6 | from django.contrib import messages 7 | from django.contrib.admin.templatetags.admin_urls import admin_urlname 8 | from django.http import HttpResponseRedirect 9 | from django.template.response import TemplateResponse 10 | from django.urls import path, reverse 11 | from django.utils.text import slugify 12 | 13 | from admin_extra_urls.button import Button, UrlButton 14 | from admin_extra_urls.checks import check_decorator_errors 15 | from admin_extra_urls.utils import labelize 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | IS_GRAPPELLI_INSTALLED = 'grappelli' in settings.INSTALLED_APPS 20 | 21 | NOTSET = object() 22 | 23 | 24 | class ActionFailed(Exception): 25 | pass 26 | 27 | 28 | def confirm_action(modeladmin, request, 29 | action, message, 30 | success_message='', 31 | description='', 32 | pk=None, 33 | extra_context=None, 34 | template='admin_extra_urls/confirm.html', 35 | error_message=None, 36 | **kwargs): 37 | opts = modeladmin.model._meta 38 | context = dict( 39 | modeladmin.admin_site.each_context(request), 40 | opts=opts, 41 | app_label=opts.app_label, 42 | message=message, 43 | description=description, 44 | **kwargs) 45 | if extra_context: 46 | context.update(extra_context) 47 | 48 | if request.method == 'POST': 49 | ret = None 50 | try: 51 | ret = action(request) 52 | modeladmin.message_user(request, success_message, messages.SUCCESS) 53 | except Exception as e: 54 | modeladmin.message_user(request, error_message or str(e), messages.ERROR) 55 | 56 | return ret or HttpResponseRedirect(reverse(admin_urlname(opts, 57 | 'changelist'))) 58 | 59 | return TemplateResponse(request, 60 | template, 61 | context) 62 | 63 | 64 | _confirm_action = confirm_action 65 | 66 | 67 | class ExtraUrlConfigException(RuntimeError): 68 | pass 69 | 70 | 71 | class DummyAdminform: 72 | def __init__(self, **kwargs): 73 | self.prepopulated_fields = [] 74 | self.__dict__.update(**kwargs) 75 | 76 | def __iter__(self): 77 | yield 78 | 79 | 80 | class ExtraUrlMixin: 81 | """ 82 | Allow to add new 'url' to the standard ModelAdmin 83 | """ 84 | if IS_GRAPPELLI_INSTALLED: # pragma: no cover 85 | change_list_template = 'admin_extra_urls/grappelli/change_list.html' 86 | change_form_template = 'admin_extra_urls/grappelli/change_form.html' 87 | else: 88 | change_list_template = 'admin_extra_urls/change_list.html' 89 | change_form_template = 'admin_extra_urls/change_form.html' 90 | buttons = [] 91 | 92 | def __init__(self, model, admin_site): 93 | self.extra_actions = [] 94 | self.extra_buttons = [] 95 | super().__init__(model, admin_site) 96 | 97 | for btn in self.buttons: 98 | self.extra_buttons.append(btn) 99 | 100 | def message_error_to_user(self, request, exception): 101 | self.message_user(request, f'{exception.__class__.__name__}: {exception}', messages.ERROR) 102 | 103 | @classmethod 104 | def check(cls, **kwargs): 105 | import sys 106 | errors = [] 107 | # HACK: why django does not pass this flag? 108 | if '--deploy' in sys.argv: 109 | from django.core.checks import Error 110 | for btn in cls.buttons: 111 | if not isinstance(btn, Button): 112 | errors.append(Error(f'{cls}.buttons can only contains "dict()" or ' 113 | f'"admin_extra.url.api.Button" instances')) 114 | errors.extend(check_decorator_errors(cls)) 115 | 116 | return errors 117 | 118 | def get_common_context(self, request, pk=None, **kwargs): 119 | opts = self.model._meta 120 | app_label = opts.app_label 121 | self.object = None 122 | 123 | context = { 124 | **self.admin_site.each_context(request), 125 | **kwargs, 126 | 'opts': opts, 127 | 'add': False, 128 | 'change': True, 129 | 'save_as': False, 130 | 'has_delete_permission': self.has_delete_permission(request, pk), 131 | 'has_editable_inline_admin_formsets': False, 132 | 'has_view_permission': self.has_view_permission(request, pk), 133 | 'has_change_permission': self.has_change_permission(request, pk), 134 | 'has_add_permission': self.has_add_permission(request), 135 | 'app_label': app_label, 136 | 'adminform': DummyAdminform(model_admin=self), 137 | } 138 | context.setdefault('title', '') 139 | context.update(**kwargs) 140 | if pk: 141 | self.object = self.get_object(request, pk) 142 | context['original'] = self.object 143 | return context 144 | 145 | def get_urls(self): 146 | extra_urls = {} 147 | for cls in inspect.getmro(self.__class__): 148 | for method_name, method in cls.__dict__.items(): 149 | if callable(method) and hasattr(method, 'url'): 150 | extra_urls[method_name] = getattr(method, 'url') 151 | 152 | original = super().get_urls() 153 | 154 | def wrap(view): 155 | def wrapper(*args, **kwargs): 156 | return self.admin_site.admin_view(view)(*args, **kwargs) 157 | 158 | return update_wrapper(wrapper, view) 159 | 160 | info = self.model._meta.app_label, self.model._meta.model_name 161 | extras = [] 162 | 163 | for __, url_config in extra_urls.items(): 164 | sig = inspect.signature(url_config.func) 165 | uri = '' 166 | if url_config.path: 167 | uri = url_config.path 168 | else: 169 | for arg in list(sig.parameters)[2:]: 170 | uri += f'/' 171 | uri += f'{url_config.func.__name__}/' 172 | 173 | url_name = f'%s_%s_{url_config.func.__name__}' % info 174 | extras.append(path(uri, 175 | wrap(getattr(self, url_config.func.__name__)), 176 | name=url_name)) 177 | 178 | if url_config.button: 179 | params = dict(label=labelize(url_config.func.__name__), 180 | # func=url_config.func, 181 | func=partial(url_config.func, self), 182 | name=slugify(url_config.func.__name__), 183 | details=url_config.details, 184 | permission=url_config.permission, 185 | change_form=url_config.details, 186 | change_list=not url_config.details, 187 | order=9999) 188 | 189 | if isinstance(url_config.button, Button): 190 | params.update(url_config.button.options) 191 | button = Button(**params) 192 | else: 193 | if isinstance(url_config.button, UrlButton): 194 | params.update(url_config.button.options) 195 | elif isinstance(url_config.button, dict): 196 | params.update(url_config.button) 197 | elif bool(url_config.button): 198 | pass 199 | else: 200 | raise ValueError(url_config.button) 201 | params.update({'url_name': url_name}) 202 | button = UrlButton(**params) 203 | self.extra_buttons.append(button) 204 | 205 | return extras + original 206 | 207 | def get_changeform_buttons(self, request, original): 208 | return self.extra_buttons 209 | 210 | def get_changelist_buttons(self, request): 211 | return self.extra_buttons 212 | 213 | def get_action_buttons(self, request): 214 | return [] 215 | -------------------------------------------------------------------------------- /src/admin_extra_urls/templates/admin_extra_urls/action_page.html: -------------------------------------------------------------------------------- 1 | {#{% extends "admin_extra_urls/change_form.html" %}{% load i18n static extra_urls admin_list admin_urls %}#} 2 | {% extends "admin/change_form.html" %}{% load i18n static extra_urls admin_list admin_urls %} 3 | {% block breadcrumbs %} 4 | 13 | {% endblock %} 14 | {% block content_title %}

{{ title|default_if_none:"" }} 

{% endblock %} 15 | {% block content %}
16 | {% block object-tools %} 17 |
    18 | {% block object-tools-items %} 19 | {% include "admin_extra_urls/includes/action_buttons.html" %} 20 | {% endblock %} 21 |
22 | {% endblock %} 23 |
24 | {% block action-content %}{% endblock %} 25 |
26 | {% block admin_change_form_document_ready %} 27 | {% block document_ready %} 28 | 29 | {% endblock %} 30 | {% endblock %} 31 |
32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /src/admin_extra_urls/templates/admin_extra_urls/change_form.html: -------------------------------------------------------------------------------- 1 | {#{% extends adminform.model_admin.original_change_form_template %}#} 2 | {% extends "admin/change_form.html" %} 3 | {% load i18n static admin_list admin_urls %} 4 | {% block extrastyle %}{{ block.super }} 5 | {% include "admin_extra_urls/includes/styles.html" %}{% endblock %} 6 | 7 | {% block object-tools-items %} 8 | {{ block.super }} 9 | {% include "admin_extra_urls/includes/change_form_buttons.html" %} 10 | {% endblock %} 11 | {% block admin_change_form_document_ready %}{{ block.super }} 12 | 37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /src/admin_extra_urls/templates/admin_extra_urls/change_list.html: -------------------------------------------------------------------------------- 1 | {#{% extends cl.model_admin.original_change_list_template %}#} 2 | {% extends "admin/change_list.html" %} 3 | 4 | {% load extra_urls %} 5 | {% block extrastyle %}{{ block.super }} 6 | {% include "admin_extra_urls/includes/styles.html" %}{% endblock %} 7 | 8 | {% block object-tools-items %} 9 | {% for n in cl.model_admin.names %}
{{ n }}
{% endfor %} 10 | {{ block.super }} 11 | {% include "admin_extra_urls/includes/change_list_buttons.html" %} 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /src/admin_extra_urls/templates/admin_extra_urls/confirm.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base_site.html' %} 2 | {% load i18n static admin_list admin_urls %} 3 | {% block breadcrumbs %} 4 | 11 | {% endblock %} 12 | 13 | {% block content %} 14 |

Warning:

15 |

{{ message }}

16 |
17 |

{{ description }}

18 |
19 |




20 |
21 | {% csrf_token %} 22 | 23 | 26 |
27 | 30 | {% endblock content %} 31 | -------------------------------------------------------------------------------- /src/admin_extra_urls/templates/admin_extra_urls/includes/action_buttons.html: -------------------------------------------------------------------------------- 1 | {% load extra_urls i18n static admin_list admin_urls %} 2 | {#{% for button in adminform.model_admin|get_action_buttons %}#} 3 | {% get_action_buttons adminform.model_admin as buttons %} 4 | {% for button in buttons %} 5 | {# {% bind button as button %}#} 6 | {% if button.change_form and button.visible and button.authorized %} 7 | {% include "admin_extra_urls/includes/button.html" with button=button %} 8 | {% endif %} 9 | {% endfor %} 10 | -------------------------------------------------------------------------------- /src/admin_extra_urls/templates/admin_extra_urls/includes/attrs.html: -------------------------------------------------------------------------------- 1 | {% for name, value in button.html_attrs.items %}{% if value is not False %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endfor %} 2 | -------------------------------------------------------------------------------- /src/admin_extra_urls/templates/admin_extra_urls/includes/button.html: -------------------------------------------------------------------------------- 1 | {% spaceless %}
  • 2 | 4 | {% if button.icon %} {% endif %}{{ button.label }} 5 |
  • {% endspaceless %} 6 | -------------------------------------------------------------------------------- /src/admin_extra_urls/templates/admin_extra_urls/includes/change_form_buttons.html: -------------------------------------------------------------------------------- 1 | {% load extra_urls i18n static admin_list admin_urls %} 2 | {#{% for button in adminform.model_admin.get_changeform_buttons %}#} 3 | {% get_changeform_buttons adminform.model_admin as buttons %} 4 | {% for button in buttons %} 5 | {# {% bind button as button %}#} 6 | {% if button.change_form and button.visible and button.authorized %} 7 | {% include "admin_extra_urls/includes/button.html" with button=button %} 8 | {% endif %} 9 | {% endfor %} 10 | -------------------------------------------------------------------------------- /src/admin_extra_urls/templates/admin_extra_urls/includes/change_list_buttons.html: -------------------------------------------------------------------------------- 1 | {% load extra_urls i18n static admin_list admin_urls %} 2 | {#{% for button in cl.model_admin.get_changelist_buttons %}#} 3 | {# {% bind button as button %}#} 4 | {% get_changelist_buttons cl.model_admin as buttons %} 5 | {% for button in buttons %} 6 | {% if button.change_list and button.visible and button.authorized %} 7 | {% include "admin_extra_urls/includes/button.html" with button=button %} 8 | {% endif %} 9 | {% endfor %} 10 | 21 | -------------------------------------------------------------------------------- /src/admin_extra_urls/templates/admin_extra_urls/includes/styles.html: -------------------------------------------------------------------------------- 1 | 23 | -------------------------------------------------------------------------------- /src/admin_extra_urls/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-admin-extra-urls/ab67f009bb8ae44d8f2300be846163aaf5e680f2/src/admin_extra_urls/templatetags/__init__.py -------------------------------------------------------------------------------- /src/admin_extra_urls/templatetags/extra_urls.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django import template 4 | 5 | register = template.Library() 6 | 7 | 8 | class NewlinelessNode(template.Node): 9 | def __init__(self, nodelist): 10 | self.nodelist = nodelist 11 | 12 | def render(self, context): 13 | return self.remove_newlines(self.nodelist.render(context).strip()) 14 | 15 | def remove_newlines(self, value): 16 | value = re.sub(r'\n', ' ', value) 17 | value = re.sub(r'\s+', ' ', value) 18 | return value 19 | 20 | 21 | @register.filter 22 | def active_group(button, group): 23 | return bool(button.group == group) 24 | 25 | 26 | @register.tag 27 | def nlless(parser, token): 28 | """ 29 | Remove all whitespace except for one space from content 30 | """ 31 | nodelist = parser.parse(('endnlless',)) 32 | parser.delete_first_token() 33 | return NewlinelessNode(nodelist) 34 | 35 | 36 | @register.filter 37 | def default_if_empty(v, default): 38 | if v and v.strip(): 39 | return v 40 | return default 41 | 42 | 43 | @register.simple_tag(takes_context=True) 44 | def get_action_buttons(context, model_admin): 45 | return [button.bind(context) for button in model_admin.get_action_buttons(context['request'])] 46 | 47 | 48 | @register.simple_tag(takes_context=True) 49 | def get_changeform_buttons(context, model_admin): 50 | original = context['original'] 51 | return [button.bind(context) for button in model_admin.get_changeform_buttons(context['request'], original)] 52 | 53 | 54 | @register.simple_tag(takes_context=True) 55 | def get_changelist_buttons(context, model_admin): 56 | return [button.bind(context) for button in model_admin.get_changelist_buttons(context['request'])] 57 | 58 | # 59 | # @register.simple_tag(takes_context=True) 60 | # def bind(context, button): 61 | # button.bind(context) 62 | # return button 63 | -------------------------------------------------------------------------------- /src/admin_extra_urls/utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import warnings 3 | from functools import wraps 4 | from urllib.parse import urlencode 5 | 6 | from django.contrib import messages 7 | from django.core.exceptions import PermissionDenied 8 | 9 | empty = object() 10 | 11 | 12 | def safe(func, *args, **kwargs): 13 | try: 14 | return func(*args, **kwargs) 15 | except Exception: 16 | return False 17 | 18 | 19 | def try_catch(f): 20 | @wraps(f) 21 | def _inner(modeladmin, request, *args, **kwargs): 22 | try: 23 | ret = f(modeladmin, request, *args, **kwargs) 24 | modeladmin.message_user(request, 'Success', messages.SUCCESS) 25 | return ret 26 | except Exception as e: 27 | modeladmin.message_user(request, str(e), messages.ERROR) 28 | 29 | return _inner 30 | 31 | 32 | def get_preserved_filters(request, **extras): 33 | filters = request.GET.get('_changelist_filters', '') 34 | if filters: 35 | preserved_filters = request.GET.get('_changelist_filters') 36 | else: 37 | preserved_filters = request.GET.urlencode() 38 | 39 | if preserved_filters: 40 | return urlencode({'_changelist_filters': preserved_filters}) 41 | return '' 42 | 43 | 44 | def labelize(label): 45 | return label.replace('_', ' ').strip().title() 46 | 47 | 48 | def encapsulate(func): 49 | def wrapper(*args, **kwargs): 50 | return func(*args, **kwargs) 51 | 52 | return wrapper 53 | 54 | 55 | def check_permission(permission, request, obj=None): 56 | if callable(permission): 57 | if not permission(request, obj): 58 | raise PermissionDenied 59 | elif not request.user.has_perm(permission): 60 | raise PermissionDenied 61 | return True 62 | 63 | 64 | def deprecated(updated, message='{name}() has been deprecated. Use {updated}() now'): 65 | if inspect.isfunction(updated): 66 | def decorator(func1): 67 | @wraps(func1) 68 | def new_func1(*args, **kwargs): 69 | warnings.simplefilter('always', DeprecationWarning) 70 | warnings.warn( 71 | message.format(name=func1.__name__, updated=updated.__name__), 72 | category=DeprecationWarning, 73 | stacklevel=2 74 | ) 75 | warnings.simplefilter('default', DeprecationWarning) 76 | return func1(*args, **kwargs) 77 | 78 | return new_func1 79 | 80 | return decorator 81 | else: 82 | raise TypeError('deprecated() first parameter must be a ' % repr(type(updated))) 83 | -------------------------------------------------------------------------------- /tests/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = admin_extra_urls 4 | include = 5 | 6 | omit = admin_extra_urls/__init__.py 7 | 8 | [report] 9 | # Regexes for lines to exclude from consideration 10 | exclude_lines = 11 | # Have to re-enable the standard pragma 12 | pragma: no cover 13 | # Don't complain about missing debug-only code: 14 | def __repr__ 15 | if self\.debug 16 | # Don't complain if tests don't hit defensive assertion code: 17 | raise AssertionError 18 | raise NotImplementedError 19 | except ImportError 20 | # Don't complain if non-runnable code isn't run: 21 | #if 0: 22 | if __name__ == .__main__.: 23 | if django.VERSION[1] < 6: 24 | 25 | ignore_errors = True 26 | 27 | [html] 28 | directory = ~build/coverage 29 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import django_webtest 2 | import pytest 3 | from demo.models import DemoModel2 4 | 5 | 6 | @pytest.fixture(scope='function') 7 | def app(request): 8 | wtm = django_webtest.WebTestMixin() 9 | wtm.csrf_checks = False 10 | wtm._patch_settings() 11 | request.addfinalizer(wtm._unpatch_settings) 12 | return django_webtest.DjangoTestApp() 13 | 14 | 15 | @pytest.fixture 16 | def demomodel2(): 17 | return DemoModel2.objects.get_or_create(name='name1')[0] 18 | 19 | 20 | @pytest.fixture(scope='function') 21 | def staff_user(request, django_user_model, django_username_field): 22 | 23 | user, _ = django_user_model._default_manager.get_or_create(**{django_username_field: 'username', 24 | 'is_staff': True}) 25 | 26 | return user 27 | -------------------------------------------------------------------------------- /tests/demoapp/demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-admin-extra-urls/ab67f009bb8ae44d8f2300be846163aaf5e680f2/tests/demoapp/demo/__init__.py -------------------------------------------------------------------------------- /tests/demoapp/demo/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.admin import SimpleListFilter 3 | from django.contrib.admin.templatetags.admin_urls import admin_urlname 4 | from django.http import HttpResponseRedirect 5 | from django.urls import reverse 6 | 7 | from admin_extra_urls.api import ExtraUrlMixin, UrlButton, confirm_action, url 8 | from admin_extra_urls.decorators import button 9 | 10 | from .models import DemoModel1, DemoModel2, DemoModel3, DemoModel4 11 | from .upload import UploadMixin 12 | 13 | 14 | class TestFilter(SimpleListFilter): 15 | parameter_name = 'filter' 16 | title = "Dummy filter for testing" 17 | 18 | def lookups(self, request, model_admin): 19 | return ( 20 | ('on', "On"), 21 | ('off', "Off"), 22 | ) 23 | 24 | def queryset(self, request, queryset): 25 | return queryset 26 | 27 | 28 | class Admin1(ExtraUrlMixin, admin.ModelAdmin): 29 | list_filter = [TestFilter] 30 | 31 | @url(permission='demo.add_demomodel1', button=True) 32 | def refresh(self, request): 33 | opts = self.model._meta 34 | self.message_user(request, 'refresh called') 35 | return HttpResponseRedirect(reverse(admin_urlname(opts, 'changelist'))) 36 | 37 | @url(button=UrlButton(label='Refresh'), permission=lambda request, object: False) 38 | def refresh_callable(self, request): 39 | opts = self.model._meta 40 | self.message_user(request, 'refresh called') 41 | return HttpResponseRedirect(reverse(admin_urlname(opts, 'changelist'))) 42 | 43 | @button(path='a/b/') 44 | def custom_path(self, request): 45 | opts = self.model._meta 46 | self.message_user(request, 'refresh called') 47 | return HttpResponseRedirect(reverse(admin_urlname(opts, 'changelist'))) 48 | 49 | @url(button=True) 50 | def no_response(self, request): 51 | self.message_user(request, 'No_response') 52 | 53 | @url(button=True) 54 | def confirm(self, request): 55 | def _action(request): 56 | pass 57 | 58 | return confirm_action(self, request, _action, "Confirm action", 59 | "Successfully executed", ) 60 | 61 | 62 | class Admin2(ExtraUrlMixin, admin.ModelAdmin): 63 | list_filter = [TestFilter] 64 | 65 | @url(permission='demo_delete_demomodel2', button=True, details=True) 66 | def update(self, request, pk): 67 | opts = self.model._meta 68 | self.message_user(request, 'action called') 69 | return HttpResponseRedirect(reverse(admin_urlname(opts, 'changelist'))) 70 | 71 | @url(button=True) 72 | def no_response(self, request, object_id): 73 | self.message_user(request, 'No_response') 74 | 75 | @url(permission=lambda request, obj: False) 76 | def update_callable_permission(self, request, object_id): 77 | opts = self.model._meta 78 | self.message_user(request, 'action called') 79 | return HttpResponseRedirect(reverse(admin_urlname(opts, 'changelist'))) 80 | 81 | @url(path='a/b/', button=True) 82 | def custom_update(self, request, object_id): 83 | opts = self.model._meta 84 | self.message_user(request, 'action called') 85 | return HttpResponseRedirect(reverse(admin_urlname(opts, 'changelist'))) 86 | 87 | 88 | class Admin3(admin.ModelAdmin): 89 | pass 90 | 91 | 92 | class Admin4(UploadMixin, admin.ModelAdmin): 93 | upload_handler = lambda *args: [1, 2, 3] # noqa 94 | 95 | 96 | admin.site.register(DemoModel1, Admin1) 97 | admin.site.register(DemoModel2, Admin2) 98 | admin.site.register(DemoModel3, Admin3) 99 | admin.site.register(DemoModel4, Admin4) 100 | -------------------------------------------------------------------------------- /tests/demoapp/demo/backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.backends import ModelBackend 2 | from django.contrib.auth.models import User 3 | 4 | 5 | class AnyUserBackend(ModelBackend): 6 | 7 | def user_can_authenticate(self, a): 8 | return True 9 | 10 | def has_perm(self, user_obj, perm, obj=None): 11 | return True 12 | 13 | def has_module_perms(self, user_obj, app_label): 14 | return True 15 | 16 | def authenticate(self, request, username=None, password=None, **kwargs): 17 | u, __ = User.objects.update_or_create( 18 | username=username, 19 | defaults={"email": username, "is_active": True, "is_staff": True, "is_superuser": True}, 20 | ) 21 | return u 22 | -------------------------------------------------------------------------------- /tests/demoapp/demo/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.10.3 on 2016-11-23 10:06 2 | from __future__ import unicode_literals 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='DemoModel1', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('name', models.CharField(max_length=255)), 19 | ], 20 | ), 21 | migrations.CreateModel( 22 | name='DemoModel2', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('name', models.CharField(max_length=255)), 26 | ], 27 | ), 28 | migrations.CreateModel( 29 | name='DemoModel3', 30 | fields=[ 31 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 32 | ('name', models.CharField(max_length=255)), 33 | ], 34 | ), 35 | migrations.CreateModel( 36 | name='DemoModel4', 37 | fields=[ 38 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 39 | ('name', models.CharField(max_length=255)), 40 | ], 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /tests/demoapp/demo/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-admin-extra-urls/ab67f009bb8ae44d8f2300be846163aaf5e680f2/tests/demoapp/demo/migrations/__init__.py -------------------------------------------------------------------------------- /tests/demoapp/demo/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class DemoModel1(models.Model): 5 | name = models.CharField(max_length=255) 6 | 7 | 8 | class DemoModel2(models.Model): 9 | name = models.CharField(max_length=255) 10 | 11 | def __unicode__(self): 12 | return "DemoModel2 #%s" % self.pk 13 | 14 | def __str__(self): 15 | return "DemoModel2 #%s" % self.pk 16 | 17 | 18 | class DemoModel3(models.Model): 19 | name = models.CharField(max_length=255) 20 | 21 | 22 | class DemoModel4(models.Model): 23 | name = models.CharField(max_length=255) 24 | -------------------------------------------------------------------------------- /tests/demoapp/demo/settings.py: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | STATIC_URL = '/static/' 3 | 4 | SITE_ID = 1 5 | ROOT_URLCONF = 'demo.urls' 6 | SECRET_KEY = 'abc' 7 | 8 | INSTALLED_APPS = ['django.contrib.auth', 9 | 'django.contrib.contenttypes', 10 | 'django.contrib.sessions', 11 | 'django.contrib.sites', 12 | 'django.contrib.messages', 13 | # 'django.contrib.staticfiles', 14 | 'django.contrib.admin', 15 | 'admin_extra_urls', 16 | 'demo'] 17 | 18 | MIDDLEWARE = ( 19 | 'django.contrib.sessions.middleware.SessionMiddleware', 20 | 'django.middleware.common.CommonMiddleware', 21 | 'django.middleware.csrf.CsrfViewMiddleware', 22 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 23 | 'django.contrib.messages.middleware.MessageMiddleware', 24 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 25 | ) 26 | 27 | DATABASES = { 28 | 'default': { 29 | 'ENGINE': 'django.db.backends.sqlite3', 30 | 'NAME': '.db.sqlite', 31 | 'HOST': '', 32 | 'PORT': ''}} 33 | 34 | MESSAGE_STORAGE = 'django.contrib.messages.storage.cookie.CookieStorage' 35 | 36 | TEMPLATES = [ 37 | { 38 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 39 | 'DIRS': [], 40 | 'APP_DIRS': True, 41 | 'OPTIONS': { 42 | 'context_processors': ['django.contrib.messages.context_processors.messages', 43 | 'django.contrib.auth.context_processors.auth', 44 | "django.template.context_processors.request", 45 | ] 46 | }, 47 | }, 48 | ] 49 | 50 | # 51 | # AUTHENTICATION_BACKENDS = [ 52 | # "demo.backends.AnyUserBackend", 53 | # ] 54 | -------------------------------------------------------------------------------- /tests/demoapp/demo/templates/admin_extra_urls/upload.html: -------------------------------------------------------------------------------- 1 | {% load i18n static admin_list admin_urls %} 2 | {% if not is_popup %} 3 | {% block breadcrumbs %} 4 | 10 | {% endblock %} 11 | {% endif %} 12 | {% block content %} 13 |
    14 | {{ help_text|safe }} 15 |
    16 |
    17 |
    18 |
    19 |
    20 | {% csrf_token %} 21 | 22 | 23 |
    24 | 25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /tests/demoapp/demo/upload.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.contrib.admin.templatetags.admin_urls import admin_urlname 3 | from django.http import HttpResponseRedirect 4 | from django.template.response import TemplateResponse 5 | from django.urls import reverse 6 | 7 | from admin_extra_urls.decorators import button 8 | from admin_extra_urls.mixins import ExtraUrlMixin 9 | 10 | 11 | class UploadMixin(ExtraUrlMixin): 12 | upload_handler = None 13 | upload_form_template = 'admin_extra_urls/upload.html' 14 | 15 | def get_upload_form_template(self, request): 16 | return self.upload_form_template 17 | 18 | @button(icon='icon-upload') 19 | def upload(self, request): 20 | opts = self.model._meta 21 | context = dict( 22 | self.admin_site.each_context(request), 23 | opts=opts, 24 | help_text=self.upload_handler.__doc__, 25 | app_label=opts.app_label, 26 | ) 27 | if request.method == 'POST': 28 | if 'file' in request.FILES: 29 | try: 30 | f = request.FILES['file'] 31 | rows, updated, created = self.upload_handler(f) 32 | msg = "Loaded {}. Parsed:{} " \ 33 | "updated:{} created:{}".format(f.name, 34 | rows, 35 | updated, 36 | created) 37 | self.message_user(request, msg, messages.SUCCESS) 38 | return HttpResponseRedirect(reverse(admin_urlname(opts, 39 | 'changelist'))) 40 | except Exception as e: 41 | self.message_user(request, str(e), messages.ERROR) 42 | 43 | return TemplateResponse(request, 44 | self.get_upload_form_template(request), 45 | context) 46 | -------------------------------------------------------------------------------- /tests/demoapp/demo/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 3 | from django.urls import path 4 | 5 | admin.autodiscover() 6 | 7 | urlpatterns = [ 8 | path(r'admin/', admin.site.urls), 9 | ] + staticfiles_urlpatterns() 10 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | here = os.path.dirname(__file__) 7 | 8 | sys.path.append(os.path.join(here, 'demoapp')) 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'demo.settings') 10 | 11 | from django.core.management import execute_from_command_line 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /tests/test_actions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo.models import DemoModel2 3 | from django.contrib.auth.models import Permission 4 | from django.urls import reverse 5 | from factory.django import DjangoModelFactory 6 | 7 | 8 | class DemoModel2Factory(DjangoModelFactory): 9 | class Meta: 10 | model = DemoModel2 11 | 12 | 13 | @pytest.mark.django_db 14 | def test_action(app, demomodel2, admin_user): 15 | url = reverse('admin:demo_demomodel2_change', args=[demomodel2.pk]) 16 | res = app.get(url, user=admin_user) 17 | res = res.click(r'Update', index=0).follow() 18 | assert str(res.context['messages']._loaded_messages[0].message) == 'action called' 19 | 20 | 21 | @pytest.mark.django_db 22 | def test_action_noresponse(app, demomodel2, admin_user): 23 | url = reverse('admin:demo_demomodel2_change', args=[demomodel2.pk]) 24 | res = app.get(url, user=admin_user) 25 | res = res.click(r'No Response').follow() 26 | assert str(res.context['messages']._loaded_messages[0].message) == 'No_response' 27 | 28 | 29 | def test_action_preserve_filters(django_app, admin_user): 30 | a, _, _ = DemoModel2Factory.create_batch(3) 31 | base_url = reverse('admin:demo_demomodel2_changelist') 32 | url = "%s?filter=on" % base_url 33 | res = django_app.get(url, user=admin_user) 34 | res = res.click('DemoModel2 #%s' % a.pk) 35 | link = res.pyquery('#btn-update')[0] 36 | assert link.get('href') == '/admin/demo/demomodel2/1/update/?_changelist_filters=filter%3Don' 37 | 38 | 39 | def test_action_permission(app, staff_user): 40 | obj = DemoModel2Factory() 41 | perms = Permission.objects.filter(codename__in=['change_demomodel2']) 42 | staff_user.user_permissions.add(*perms) 43 | 44 | url = reverse('admin:demo_demomodel2_change', args=[obj.pk]) 45 | res = app.get(url, user=staff_user) 46 | assert not res.pyquery('#btn-update') 47 | 48 | url = reverse('admin:demo_demomodel2_update', args=[obj.pk]) 49 | 50 | res = app.get(url, user=staff_user, expect_errors=True) 51 | assert res.status_code == 403 52 | 53 | 54 | def test_action_permission_callable(app, staff_user): 55 | obj = DemoModel2Factory() 56 | perms = Permission.objects.filter(codename__in=['change_demomodel2']) 57 | staff_user.user_permissions.add(*perms) 58 | 59 | url = reverse('admin:demo_demomodel2_change', args=[obj.pk]) 60 | res = app.get(url, user=staff_user) 61 | assert not res.pyquery('#btn-update-callable-permission') 62 | 63 | url = reverse('admin:demo_demomodel2_update_callable_permission', args=[obj.pk]) 64 | res = app.get(url, user=staff_user, expect_errors=True) 65 | assert res.status_code == 403 66 | -------------------------------------------------------------------------------- /tests/test_confirm.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from demo.models import DemoModel1 4 | from django.contrib.admin import site 5 | from django.urls import reverse 6 | 7 | from admin_extra_urls.api import confirm_action 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def test_confirm(django_app, admin_user): 13 | url = reverse('admin:demo_demomodel1_changelist') 14 | res = django_app.get(url, user=admin_user) 15 | res = res.click('Confirm') 16 | assert str(res.content).find("Confirm action") 17 | res = res.form.submit().follow() 18 | assert str(res.context['messages']._loaded_messages[0].message) == 'Successfully executed' 19 | 20 | 21 | def test_confirm_action(rf, staff_user): 22 | request = rf.get('/customer/details') 23 | request.user = staff_user 24 | confirm_action(site._registry[DemoModel1], request, 25 | lambda r: True, 26 | "Confirm action", 27 | "Successfully executed", 28 | description="", 29 | pk=None, 30 | extra_context={'a': 1}) 31 | -------------------------------------------------------------------------------- /tests/test_links.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.contrib.auth.models import Permission 4 | from django.urls import reverse 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def test_link(django_app, staff_user): 10 | perms = Permission.objects.filter(codename__in=['add_demomodel1', 'change_demomodel1']) 11 | staff_user.user_permissions.add(*perms) 12 | url = reverse('admin:demo_demomodel1_changelist') 13 | res = django_app.get(url, user=staff_user) 14 | res = res.click('Refresh').follow() 15 | assert str(res.context['messages']._loaded_messages[0].message) == 'refresh called' 16 | 17 | 18 | def test_link_preserve_filters(django_app, staff_user): 19 | perms = Permission.objects.filter(codename__in=['add_demomodel1', 'change_demomodel1']) 20 | staff_user.user_permissions.add(*perms) 21 | base_url = reverse('admin:demo_demomodel1_changelist') 22 | url = "%s?filter=on" % base_url 23 | res = django_app.get(url, user=staff_user) 24 | link = res.pyquery('#btn-refresh')[0] 25 | assert link.get('href') == '/admin/demo/demomodel1/refresh/?_changelist_filters=filter%3Don' 26 | 27 | 28 | def test_link_reverse(django_app, staff_user): 29 | perms = Permission.objects.filter(codename__in=['add_demomodel1', 'change_demomodel1']) 30 | staff_user.user_permissions.add(*perms) 31 | url = reverse('admin:demo_demomodel1_refresh') 32 | res = django_app.get(url, user=staff_user).follow() 33 | assert str(res.context['messages']._loaded_messages[0].message) == 'refresh called' 34 | 35 | 36 | def test_link_custom_path_reverse(django_app, admin_user): 37 | url = reverse('admin:demo_demomodel1_custom_path') 38 | assert url == '/admin/demo/demomodel1/a/b/' 39 | 40 | 41 | def test_default_httpresponseaction(app, admin_user): 42 | url = reverse('admin:demo_demomodel1_changelist') 43 | res = app.get(url, user=admin_user) 44 | res = res.click('No Response').follow().follow() 45 | assert res.status_code == 200 46 | assert str(res.context['messages']._loaded_messages[0].message) == 'No_response' 47 | 48 | 49 | def test_link_permission(app, staff_user): 50 | perms = Permission.objects.filter(codename__in=['change_demomodel1']) 51 | staff_user.user_permissions.add(*perms) 52 | 53 | url = reverse('admin:demo_demomodel1_changelist') 54 | res = app.get(url, user=staff_user) 55 | assert not res.pyquery('#btn-refresh') 56 | 57 | url = reverse('admin:demo_demomodel1_refresh') 58 | 59 | res = app.get(url, user=staff_user, expect_errors=True) 60 | assert res.status_code == 403 61 | 62 | 63 | def test_link_permission_callable(app, staff_user): 64 | perms = Permission.objects.filter(codename__in=['change_demomodel1']) 65 | staff_user.user_permissions.add(*perms) 66 | 67 | url = reverse('admin:demo_demomodel1_changelist') 68 | res = app.get(url, user=staff_user) 69 | assert not res.pyquery('#btn-refresh-callable') 70 | 71 | url = reverse('admin:demo_demomodel1_refresh_callable') 72 | res = app.get(url, user=staff_user, expect_errors=True) 73 | assert res.status_code == 403 74 | -------------------------------------------------------------------------------- /tests/test_upload.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | 3 | 4 | def test_upload(app, admin_user): 5 | url = reverse('admin:demo_demomodel4_changelist') 6 | res = app.get(url, user=admin_user) 7 | res = res.click('Upload') 8 | form = res.forms[0] 9 | form['file'] = ('file', "abc".encode('utf8')) 10 | res = form.submit() 11 | assert res.status_code == 302 12 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.exceptions import PermissionDenied 3 | 4 | from admin_extra_urls.utils import check_permission 5 | 6 | 7 | def test_check_permission(rf, staff_user, admin_user): 8 | request = rf.get('/') 9 | request.user = staff_user 10 | with pytest.raises(PermissionDenied): 11 | check_permission('demo_add_demomodel1', request) 12 | 13 | with pytest.raises(PermissionDenied): 14 | check_permission(lambda r, o: False, request) 15 | 16 | request.user = admin_user 17 | assert check_permission('demo_add_demomodel1', request) 18 | assert check_permission(lambda r, o: True, request) 19 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = d{22,32,40}-py{38,39,310} 3 | 4 | [pytest] 5 | python_paths=./tests/demoapp/ 6 | django_find_project = false 7 | DJANGO_SETTINGS_MODULE=demo.settings 8 | norecursedirs = .tox docs ./tests/demoapp/ 9 | python_files=tests/test_*.py 10 | addopts = 11 | -v 12 | --pyargs admin_extra_urls 13 | --doctest-modules 14 | --cov=admin_extra_urls 15 | --cov-report=html 16 | --cov-config=./tests/.coveragerc 17 | --reuse-db 18 | --tb=short 19 | --capture=no 20 | --echo-version django 21 | --echo-attr django.conf.settings.DATABASES.default.ENGINE 22 | 23 | markers = 24 | functional: mark a test as functional 25 | 26 | [testenv] 27 | passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH PYTHONDONTWRITEBYTECODE DISPLAY 28 | setenv = 29 | PYTHONDONTWRITEBYTECODE=true 30 | extras = test 31 | deps= 32 | d22: django==2.2.* 33 | d32: django==3.2.* 34 | d40: django==4.0.* 35 | dev: git+git://github.com/django/django.git#egg=django 36 | 37 | commands = 38 | {posargs:py.test tests --create-db} 39 | --------------------------------------------------------------------------------