├── .coveragerc ├── .github └── workflows │ └── tests.yaml ├── .gitignore ├── .gitmodules ├── AUTHORS ├── CHANGES.rst ├── LICENSE.rst ├── MANIFEST.in ├── README.rst ├── docs └── TODO.rst ├── example ├── example │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py └── tree │ ├── __init__.py │ ├── admin.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ ├── tests.py │ └── views.py ├── polymorphic_tree ├── __init__.py ├── admin │ ├── __init__.py │ ├── childadmin.py │ └── parentadmin.py ├── locale │ ├── es_AR │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── nl │ │ └── LC_MESSAGES │ │ └── django.po ├── managers.py ├── models.py ├── static │ └── polymorphic_tree │ │ ├── adminlist │ │ ├── arrow-down.gif │ │ ├── arrow-up.gif │ │ ├── nav-bg.gif │ │ ├── nodetree.css │ │ ├── nodetree_classic.css │ │ ├── nodetree_flat.css │ │ ├── nodetree_grappelli.css │ │ ├── zebra-flat.png │ │ ├── zebra-grappelli.png │ │ └── zebra.png │ │ ├── icons │ │ ├── blank.gif │ │ ├── page_new.gif │ │ └── world.gif │ │ └── jquery.cookie.js ├── templates │ └── admin │ │ └── polymorphic_tree │ │ ├── change_form.html │ │ ├── change_form_breadcrumbs.html │ │ ├── change_list.html │ │ ├── delete_confirmation.html │ │ ├── jstree_list_results.html │ │ ├── object_history.html │ │ ├── object_history_breadcrumbs.html │ │ └── stylable_change_list.html ├── templatetags │ ├── __init__.py │ ├── polymorphic_tree_admin_tags.py │ └── stylable_admin_list.py └── tests │ ├── __init__.py │ ├── admin.py │ ├── models.py │ ├── test_admin.py │ └── test_models.py ├── pyproject.toml ├── runtests.py ├── setup.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = polymorphic_tree/ 3 | omit = 4 | */migrations/* 5 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: CI Testing 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | push: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | test: 12 | name: "Python ${{ matrix.python }} Django ${{ matrix.django }}" 13 | runs-on: ubuntu-latest 14 | strategy: 15 | # max-parallel: 8 # default is max available 16 | fail-fast: false 17 | matrix: 18 | include: 19 | # Django 2.2 20 | - django: "2.2" 21 | python: "3.6" 22 | # Django 3.1 23 | - django: "3.1" 24 | python: "3.6" 25 | # Django 3.2 26 | - django: "3.2" 27 | python: "3.6" 28 | # Django 4.0 29 | - django: "4.0b1" 30 | python: "3.10" 31 | 32 | steps: 33 | - name: Install gettext 34 | run: sudo apt-get install -y gettext 35 | 36 | - name: Checkout code 37 | uses: actions/checkout@v2 38 | 39 | - name: Setup Python ${{ matrix.python }} 40 | uses: actions/setup-python@v2 41 | with: 42 | python-version: ${{ matrix.python }} 43 | 44 | - name: Install Packages 45 | run: | 46 | python -m pip install -U pip 47 | python -m pip install "Django~=${{ matrix.django }}" codecov -e .[tests] 48 | 49 | - name: Run Tests 50 | run: | 51 | echo "Python ${{ matrix.python }} / Django ${{ matrix.django }}" 52 | coverage run --rcfile=.coveragerc runtests.py 53 | codecov 54 | continue-on-error: ${{ contains(matrix.django, '4.0') }} 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.mo 4 | *.db 5 | *.egg-info/ 6 | .idea 7 | .project 8 | .pydevproject 9 | .idea/workspace.xml 10 | .DS_Store 11 | build/ 12 | dist/ 13 | docs/_build/ 14 | *.sqlite3 15 | .tox 16 | .python-version 17 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "polymorphic_tree/static/polymorphic_tree/jqtree"] 2 | path = polymorphic_tree/static/polymorphic_tree/jqtree 3 | url = https://github.com/mbraak/jqTree 4 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Original authors: 2 | 3 | * Diederik van der Boor (@vdboor) 4 | 5 | Contributions by: 6 | 7 | * Adam Wentz (@floppya) 8 | * Basil Shubin (@bashu) 9 | * Bertrand Bordage (@BertrandBordage) 10 | * Chad Shrock (@g3rd) 11 | * Evan Borgstrom (@borgstrom) 12 | * Mario Rosa (@vinnyrose) 13 | * Michael van de Waeter (@mvdwaeter) 14 | * Jakub Dorňák 15 | * Gonzalo Bustos 16 | * Jonathan Potter 17 | * Jorge Barata 18 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changes in 2.1 (2021-11-18) 2 | --------------------------- 3 | 4 | * Added Django 4.0 compatibility. 5 | * Fixed permission check for moving nodes. 6 | * Dropped Python 3.5 support. 7 | 8 | 9 | Changes in 2.0.0 (2021-04-01) 10 | ----------------------------- 11 | 12 | * Fixed Django 3.1 compatibility. 13 | * Fixed Django 4.x deprecation warnings. 14 | * Bumped third party requirements to meaningful versions. 15 | * Dropped Django 1.10, 1.11, 2.0 support. 16 | * Dropped Python 2.7 support. 17 | 18 | 19 | Changes in 1.5.1 (2020-01-04) 20 | ----------------------------- 21 | 22 | * Fixed Django 3.0 compatibility by removing ``django.utils.six`` dependency. 23 | 24 | 25 | Changes in 1.5 (2018-01-22) 26 | --------------------------- 27 | 28 | * Added Django 2.0 support. 29 | * Added ``base_manager_name`` setting in model ``Meta`` options, like django-polymorphic_ 2.0 does. 30 | * Fixed crash in ``get_child_types()`` when ``can_have_children`` is ``False``. 31 | * Fixed child type validation, this now happens before the database saves. 32 | * Fixed spelling grapelli -> grappelli 33 | * Dropped Django 1.8 support. 34 | 35 | 36 | Changes in 1.4.2 (2017-11-22) 37 | ----------------------------- 38 | 39 | * Fixed compatibility with recent django-polymorphic_ releases. 40 | * Dropped support for unmaintained Django versions: 1.6, 1.7, 1.9 41 | (this should have warranted a 1.5 release, but it was accidentally slipped in). 42 | 43 | 44 | Changes in 1.4.1 (2017-08-01) 45 | ----------------------------- 46 | 47 | * Fixed inheriting ``PolymorphicMPTTModel`` in an abstract model. 48 | * Fixed ``PolymorphicMPTTQuerySet.as_manager()`` usage to return the proper manager. 49 | 50 | 51 | Changes in 1.4 (2017-02-18) 52 | --------------------------- 53 | 54 | * Add ability to restrict children, via ``child_types`` list on the model. 55 | The value can be ``["self", "app.Model", "Model", Model]``. 56 | * Added ability to set ``can_be_root`` to enforce using a node as child. 57 | * Add ``validate_move_to()`` method to perform extra checks on moving children. 58 | * Fix support for UUID fields as primary key. 59 | * **Security notice:** fixed missing permission check on moving nodes, all staff members could move nodes. 60 | 61 | 62 | Changes in 1.3.1 (2016-11-29) 63 | ----------------------------- 64 | 65 | * Add Spanish (Argentina) language 66 | * Fix rendering of empty list items on Django 1.9+ that have non-existing attributes/objects. 67 | * Enforcing django-polymorphic_ >= 1.0.1 for Django 1.10.1 compatibility. 68 | (the previous version already required django-polymorphic_ 1.0) 69 | 70 | 71 | Changes in 1.3 72 | -------------- 73 | 74 | * Added Django 1.10 support 75 | * Added convenient `get_closest_ancestor_of_type()` and `get_ancestors_of_type()` methods 76 | * Support proxy model filtering by django-polymorphic_ 1.0 77 | * Dropped Django 1.5 support / django-mptt 0.6 support. 78 | 79 | .. note:: As of Django 1.10 managers of abstract classes are also inherited. 80 | Hence, the need to override ``_default_manager`` is removed. 81 | Use ``objects = YourPolymorphicMPTTManager()`` to override the manager now. 82 | 83 | Make sure django-polymorphic_ 1.0 is installed for Django 1.10 projects. 84 | 85 | 86 | Changes in 1.2.5 87 | ---------------- 88 | 89 | * Fix grappelli theme appearance for admin list. 90 | 91 | 92 | Changes in 1.2.4 93 | ---------------- 94 | 95 | * Fix ``admin/polymorphic_tree/object_history.html`` template typoo. 96 | 97 | 98 | Changes in 1.2.3 99 | ---------------- 100 | 101 | * Fix tree list appearance for Django 1.8 and below (classic theme). 102 | 103 | 104 | Changes in 1.2.2 105 | ---------------- 106 | 107 | * Fix tree list appearance for Django 1.9 and flat theme. 108 | 109 | 110 | Changes in 1.2.1 111 | ---------------- 112 | 113 | * Fix breadcrumbs in Django 1.7+, displaying the ``AppConfig`` name. 114 | * Fix breadcrumbs in ``object_history.html`` template. 115 | **NOTE:** This may require to redefine an ``admin/polymorphic_tree/object_history.html`` template 116 | when your project uses django-reversion_ or django-reversion-compare_. 117 | 118 | 119 | Changes in 1.2 120 | -------------- 121 | 122 | * Fix compatibility with Django 1.9. 123 | * Fix support for MPTT ``get_previous_sibling()`` / ``get_next_sibling()``. 124 | * Fix compatibility with django-polymorphic_ 0.8 final 125 | 126 | 127 | Changes in 1.1.2 128 | ---------------- 129 | 130 | * Fix compatibility with upcoming django-polymorphic_ 0.8 131 | 132 | 133 | Changes in 1.1.1 134 | ---------------- 135 | 136 | * Fixed URL resolving for for multi admin sites. 137 | * Fixed URL breadcrumbs for delete page, visible when using non-standard delete URLs (e.g. django-parler_'s delete translation page). 138 | * Fixed showing ``DateTimeField`` in local time. 139 | * Enforcing at least django-polymorphic_ 0.7.1 for Django 1.8 compatibility. 140 | 141 | 142 | Changes in version 1.1 143 | ---------------------- 144 | 145 | * Added Django 1.8 compatibility 146 | * Added django-mptt 0.7 support 147 | * Fixed Python 3 issue in the admin code 148 | * Fixed attempting to import south in Django 1.7/1.8 environments 149 | * Fixed default MPTT model ordering, using tree_id, lft now 150 | * Test ``polymorphic.__version__`` to determine the api of ``get_child_type_choice()``. 151 | 152 | 153 | Changes in version 1.0.1 154 | ------------------------ 155 | 156 | * Fixed Django 1.7 deprecation warnings 157 | * Fix support for future 0.14, which removed ``future.utils.six``. 158 | 159 | 160 | Changes in version 1.0 161 | ---------------------- 162 | 163 | * Added Python 3 support 164 | * Added Django 1.7 support 165 | 166 | 167 | Changes in version 0.9 168 | ---------------------- 169 | 170 | * Upgraded jqTree to latest version, and converted to a Git submodule 171 | * Fix Django 1.6 transaction support 172 | * Fix object ``.save()`` calls when moving items in the tree. 173 | There is no need to refetch the object, so the object ``.save()`` method can detect changes in it's parent. 174 | 175 | 176 | Changes in version 0.8.11 (beta release) 177 | ------------------------------------------- 178 | 179 | * Fix breadcrumbs, used `title`` attribute instead of ``__unicode__()``. 180 | 181 | 182 | Changes in version 0.8.10 (beta release) 183 | ------------------------------------------- 184 | 185 | * Hide "add" icon when there is no permission. 186 | * Fix Django 1.6 deprecation warnings for simplejson module. 187 | 188 | 189 | Changes in version 0.8.9 (beta release) 190 | ------------------------------------------- 191 | 192 | * Added workaround for large data sets, temporarily disabled pagination. 193 | NOTE: this issue needs to be looked at in more depth, and is a quick fix only. 194 | 195 | 196 | Changes in version 0.8.8 (beta release) 197 | ------------------------------------------- 198 | 199 | * Fix deprecation warning from django-polymorphic_. 200 | * Fix Django 1.3 support by 0.8.7 (will only bump app requirements on major releases, e.g. 0.9). 201 | 202 | 203 | Changes in version 0.8.7 (beta release) 204 | --------------------------------------- 205 | 206 | * Fix Django 1.5 support in the templates 207 | * Fix Django 1.6 support, use new ``django.conf.urls`` import path. 208 | Note you need to use django-polymorphic_ >= 0.5.1 as well with Django 1.6. 209 | 210 | 211 | Changes in version 0.8.6 (beta release) 212 | --------------------------------------- 213 | 214 | * Fixes for moving nodes in the admin: 215 | 216 | * Call ``model.save()`` so post-save updates are executed. 217 | * Update the preview URL in the "Actions" column. 218 | * Perform database updates in a single transaction. 219 | 220 | 221 | Changes in version 0.8.5 (beta release) 222 | --------------------------------------- 223 | 224 | * Depend on django-polymorphic_ 0.3.1, which contains our ``PolymorphicParentAdmin`` now. 225 | * Depend on django-tag-parser_, the tag parsing utilities have been migrated to that app. 226 | * Marked as beta release, as the API of the polymorphic admin is now finalized. 227 | 228 | 229 | Changes in version 0.8.4 (alpha release) 230 | ---------------------------------------- 231 | 232 | * Fix list appearance in combination with django-grappelli 233 | * Improve error messages on invalid movements 234 | 235 | 236 | Changes in version 0.8.3 (alpha release) 237 | ---------------------------------------- 238 | 239 | * Fix row alignment in the admin interface 240 | * Spelling and typoo fixes, print statement 241 | 242 | 243 | Changes in version 0.8.2 (alpha release) 244 | ---------------------------------------- 245 | 246 | * **BIC:** Changed changed the dynamic model registration in ``PolymorphicParentAdmin``. 247 | 248 | Instead of ``get_child_model_classes()`` + ``get_admin_for_model()`` 249 | there is a ``get_child_models()`` method that works like the static ``child_models`` registration. 250 | This also removes to need to provide a ``ModelAdmin`` instance somehow, only the class has to be provided. 251 | 252 | * Fixed ``raw_id_fields`` for child admins. 253 | * Fixed accidental late registration of models, fixes the "Save and Continue" button. 254 | * Improved protection of custom subclass views. 255 | * Generate ``django.mo`` files during ``setup.py sdist``. 256 | * Added Dutch translation 257 | 258 | 259 | Changes in version 0.8.1 (alpha release) 260 | ---------------------------------------- 261 | 262 | * Added ``type_label`` to ``NodeTypeChoiceForm``, for simple label switching. 263 | * Added API's to support django-fluent-pages_, and other systems: 264 | 265 | * Allow the model.``can_have_children`` to be a property 266 | * Allow to override the error message in PolymorphicTreeForeignKey 267 | * Added ``can_preview_object()`` code in the admin, used in the actions column. 268 | 269 | * Updated README examples 270 | 271 | 272 | Changes in version 0.8.0 (alpha release) 273 | ---------------------------------------- 274 | 275 | First alpha release, extracted from django-fluent-pages_. 276 | 277 | Simplified a lot of code to be tightly focused on the MPTT + Polymorphic code, 278 | and not bother with a plugin registration system. 279 | 280 | 281 | .. _django-fluent-pages: https://github.com/edoburu/django-fluent-pages 282 | .. _django-parler: https://github.com/edoburu/django-parler 283 | .. _django-polymorphic: https://github.com/django-polymorphic/django-polymorphic 284 | .. _django-reversion: https://github.com/etianen/django-reversion 285 | .. _django-reversion-compare: https://github.com/jedie/django-reversion-compare 286 | .. _django-tag-parser: https://github.com/edoburu/django-tag-parser 287 | 288 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | LICENSE 3 | ======= 4 | 5 | Copyright © 2011, Edoburu 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this software except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include README.rst 3 | include CHANGES.rst 4 | include LICENSE.rst 5 | recursive-include polymorphic_tree/locale *.mo *.po 6 | recursive-include polymorphic_tree/templates *.html 7 | recursive-include polymorphic_tree/static *.css *.js *.png *.gif LICENSE README.rst 8 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-polymorphic-tree 2 | ======================= 3 | 4 | .. image:: https://github.com/django-polymorphic/django-polymorphic-tree/actions/workflows/tests.yaml/badge.svg?branch=master 5 | :target: https://github.com/django-polymorphic/django-polymorphic-tree/actions/workflows/tests.yaml 6 | .. image:: https://img.shields.io/pypi/v/django-polymorphic-tree.svg 7 | :target: https://pypi.python.org/pypi/django-polymorphic-tree/ 8 | .. image:: https://img.shields.io/pypi/l/django-polymorphic-tree.svg 9 | :target: https://pypi.python.org/pypi/django-polymorphic-tree/ 10 | .. image:: https://img.shields.io/codecov/c/github/django-polymorphic/django-polymorphic-tree/master.svg 11 | :target: https://codecov.io/github/django-polymorphic/django-polymorphic-tree?branch=master 12 | 13 | 14 | This package combines django-mptt_ with django-polymorphic_. 15 | You can write Django models that form a tree structure where each node can be a different model type. 16 | 17 | Example uses: 18 | 19 | * Build a tree of organisation and company types (e.g. ``Partner``, ``Reseller``, ``Group`` and ``Customer``) 20 | * Build a tree of a root node, category nodes, leaf nodes, each with custom fields. 21 | * Build a todo list of projects, categories and items. 22 | * Build a book of chapters, sections, and pages. 23 | 24 | Origin 25 | ------ 26 | 27 | This code was created in django-fluent-pages_, and extracted to become a separate package. 28 | This was done during contract work at Leukeleu_ (known for django-fiber_). 29 | 30 | 31 | Installation 32 | ============ 33 | 34 | First install the module, preferably in a virtual environment:: 35 | 36 | pip install django-polymorphic-tree 37 | 38 | Or install the current repository:: 39 | 40 | pip install -e git+https://github.com/django-polymorphic/django-polymorphic-tree.git#egg=django-polymorphic-tree 41 | 42 | The main dependencies are django-mptt_ and django-polymorphic_, 43 | which will be automatically installed. 44 | 45 | Configuration 46 | ------------- 47 | 48 | Next, create a project which uses the application:: 49 | 50 | cd .. 51 | django-admin.py startproject demo 52 | 53 | Add the following to ``settings.py``: 54 | 55 | .. code:: python 56 | 57 | INSTALLED_APPS += ( 58 | 'polymorphic_tree', 59 | 'polymorphic', 60 | 'mptt', 61 | ) 62 | 63 | 64 | Usage 65 | ----- 66 | 67 | The main feature of this module is creating a tree of custom node types. 68 | It boils down to creating a application with 2 files: 69 | 70 | The ``models.py`` file should define the custom node type, and any fields it has: 71 | 72 | .. code:: python 73 | 74 | from django.db import models 75 | from django.utils.translation import gettext_lazy as _ 76 | from polymorphic_tree.models import PolymorphicMPTTModel, PolymorphicTreeForeignKey 77 | 78 | 79 | # A base model for the tree: 80 | 81 | class BaseTreeNode(PolymorphicMPTTModel): 82 | parent = PolymorphicTreeForeignKey('self', blank=True, null=True, related_name='children', verbose_name=_('parent')) 83 | title = models.CharField(_("Title"), max_length=200) 84 | 85 | class Meta(PolymorphicMPTTModel.Meta): 86 | verbose_name = _("Tree node") 87 | verbose_name_plural = _("Tree nodes") 88 | 89 | 90 | # Create 3 derived models for the tree nodes: 91 | 92 | class CategoryNode(BaseTreeNode): 93 | opening_title = models.CharField(_("Opening title"), max_length=200) 94 | opening_image = models.ImageField(_("Opening image"), upload_to='images') 95 | 96 | class Meta: 97 | verbose_name = _("Category node") 98 | verbose_name_plural = _("Category nodes") 99 | 100 | 101 | class TextNode(BaseTreeNode): 102 | extra_text = models.TextField() 103 | 104 | # Extra settings: 105 | can_have_children = False 106 | 107 | class Meta: 108 | verbose_name = _("Text node") 109 | verbose_name_plural = _("Text nodes") 110 | 111 | 112 | class ImageNode(BaseTreeNode): 113 | image = models.ImageField(_("Image"), upload_to='images') 114 | 115 | class Meta: 116 | verbose_name = _("Image node") 117 | verbose_name_plural = _("Image nodes") 118 | 119 | 120 | The ``admin.py`` file should define the admin, both for the child nodes and parent: 121 | 122 | .. code:: python 123 | 124 | from django.contrib import admin 125 | from django.utils.translation import gettext_lazy as _ 126 | from polymorphic_tree.admin import PolymorphicMPTTParentModelAdmin, PolymorphicMPTTChildModelAdmin 127 | from . import models 128 | 129 | 130 | # The common admin functionality for all derived models: 131 | 132 | class BaseChildAdmin(PolymorphicMPTTChildModelAdmin): 133 | GENERAL_FIELDSET = (None, { 134 | 'fields': ('parent', 'title'), 135 | }) 136 | 137 | base_model = models.BaseTreeNode 138 | base_fieldsets = ( 139 | GENERAL_FIELDSET, 140 | ) 141 | 142 | 143 | # Optionally some custom admin code 144 | 145 | class TextNodeAdmin(BaseChildAdmin): 146 | pass 147 | 148 | 149 | # Create the parent admin that combines it all: 150 | 151 | class TreeNodeParentAdmin(PolymorphicMPTTParentModelAdmin): 152 | base_model = models.BaseTreeNode 153 | child_models = ( 154 | models.CategoryNode, 155 | models.TextNode, # custom admin allows custom edit/delete view. 156 | models.ImageNode, 157 | ) 158 | 159 | list_display = ('title', 'actions_column',) 160 | 161 | class Media: 162 | css = { 163 | 'all': ('admin/treenode/admin.css',) 164 | } 165 | 166 | 167 | admin.site.register(models.CategoryNode, BaseChildAdmin) 168 | admin.site.register(models.TextNode, TextNodeAdmin) 169 | admin.site.register(models.ImageNode, BaseChildAdmin) 170 | admin.site.register(models.BaseTreeNode, TreeNodeParentAdmin) 171 | 172 | 173 | The ``child_models`` attribute defines which admin interface is loaded for the *edit* and *delete* page. 174 | The list view is still rendered by the parent admin. 175 | 176 | 177 | Tests 178 | ----- 179 | 180 | To run the included test suite, execute:: 181 | 182 | ./runtests.py 183 | 184 | To test support for multiple Python and Django versions, you need to follow steps below: 185 | 186 | * install project requirements in virtual environment 187 | * install python 2.7, 3.4, 3.5, 3.6 python versions through pyenv (See pyenv (Linux) or Homebrew (Mac OS X).) 188 | * create .python-version file and add full list of installed versions with which project have to be tested, example:: 189 | 190 | 2.6.9 191 | 2.7.13 192 | 3.4.5 193 | 3.5.2 194 | 3.6.0 195 | * run tox from the repository root:: 196 | 197 | pip install tox 198 | tox 199 | 200 | Python 2.7, 3.4, 3.5 and 3.6 and django 1.8, 1.10 and 1.11 are the currently supported versions. 201 | 202 | Todo 203 | ---- 204 | 205 | * Sphinx Documentation 206 | 207 | 208 | Contributing 209 | ------------ 210 | 211 | This module is designed to be generic. In case there is anything you didn't like about it, 212 | or think it's not flexible enough, please let us know. We'd love to improve it! 213 | 214 | If you have any other valuable contribution, suggestion or idea, 215 | please let us know as well because we will look into it. 216 | Pull requests are welcome too. :-) 217 | 218 | 219 | .. _Leukeleu: http://www.leukeleu.nl/ 220 | .. _django-fiber: https://github.com/ridethepony/django-fiber 221 | .. _django-fluent-pages: https://github.com/edoburu/django-fluent-pages 222 | .. _django-mptt: https://github.com/django-mptt/django-mptt 223 | .. _django-polymorphic: https://github.com/django-polymorphic/django-polymorphic 224 | 225 | -------------------------------------------------------------------------------- /docs/TODO.rst: -------------------------------------------------------------------------------- 1 | * django-reversion integration: https://github.com/edoburu/django-polymorphic-tree/issues/27 2 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-polymorphic/django-polymorphic-tree/99e92880bfcc7f0992f12e4b35633be2a5171435/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.7/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.7/ref/settings/ 9 | """ 10 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 11 | import os 12 | 13 | import django 14 | 15 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 16 | 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = "qr28ym=4y!9e!^owr&k$++8*v$1mvp=u!p7f)+hi2zb7&n6+39" 23 | 24 | # SECURITY WARNING: don't run with debug turned on in production! 25 | DEBUG = True 26 | 27 | TEMPLATE_DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = ( 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | # Translators: Install django-polymorphic-tree and dependent apps 42 | "polymorphic_tree", 43 | "polymorphic", 44 | "mptt", 45 | # Translators: Our local app 46 | "tree", 47 | ) 48 | 49 | MIDDLEWARE_CLASSES = ( 50 | "django.contrib.sessions.middleware.SessionMiddleware", 51 | "django.middleware.common.CommonMiddleware", 52 | "django.middleware.csrf.CsrfViewMiddleware", 53 | "django.contrib.auth.middleware.AuthenticationMiddleware", 54 | "django.contrib.auth.middleware.SessionAuthenticationMiddleware" 55 | "django.contrib.messages.middleware.MessageMiddleware", 56 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 57 | ) 58 | 59 | ROOT_URLCONF = "example.urls" 60 | 61 | WSGI_APPLICATION = "example.wsgi.application" 62 | 63 | 64 | # Database 65 | # https://docs.djangoproject.com/en/1.7/ref/settings/#databases 66 | 67 | DATABASES = { 68 | "default": { 69 | "ENGINE": "django.db.backends.sqlite3", 70 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 71 | } 72 | } 73 | 74 | # Internationalization 75 | # https://docs.djangoproject.com/en/1.7/topics/i18n/ 76 | 77 | LANGUAGE_CODE = "en-us" 78 | 79 | TIME_ZONE = "UTC" 80 | 81 | USE_I18N = True 82 | 83 | USE_L10N = True 84 | 85 | USE_TZ = True 86 | 87 | 88 | # Static files (CSS, JavaScript, Images) 89 | # https://docs.djangoproject.com/en/1.7/howto/static-files/ 90 | 91 | STATIC_URL = "/static/" 92 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | urlpatterns = [ 5 | # Examples: 6 | # url(r'^$', 'example.views.home', name='home'), 7 | # url(r'^blog/', include('blog.urls')), 8 | path("admin/", admin.site.urls), 9 | ] 10 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.7/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 13 | 14 | from django.core.wsgi import get_wsgi_application 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 7 | 8 | # Import polymorphic_tree from this folder. 9 | SRC_ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 10 | sys.path.insert(0, SRC_ROOT) 11 | 12 | from django.core.management import execute_from_command_line 13 | 14 | execute_from_command_line(sys.argv) 15 | -------------------------------------------------------------------------------- /example/tree/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-polymorphic/django-polymorphic-tree/99e92880bfcc7f0992f12e4b35633be2a5171435/example/tree/__init__.py -------------------------------------------------------------------------------- /example/tree/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from polymorphic_tree.admin import PolymorphicMPTTChildModelAdmin, PolymorphicMPTTParentModelAdmin 4 | 5 | from . import models 6 | 7 | # The common admin functionality for all derived models: 8 | 9 | 10 | class BaseChildAdmin(PolymorphicMPTTChildModelAdmin): 11 | GENERAL_FIELDSET = ( 12 | None, 13 | { 14 | "fields": ("parent", "title"), 15 | }, 16 | ) 17 | 18 | base_model = models.BaseTreeNode 19 | base_fieldsets = (GENERAL_FIELDSET,) 20 | 21 | 22 | # Optionally some custom admin code 23 | 24 | 25 | class TextNodeAdmin(BaseChildAdmin): 26 | pass 27 | 28 | 29 | # Create the parent admin that combines it all: 30 | 31 | 32 | class TreeNodeParentAdmin(PolymorphicMPTTParentModelAdmin): 33 | base_model = models.BaseTreeNode 34 | child_models = ( 35 | (models.CategoryNode, BaseChildAdmin), 36 | (models.TextNode, TextNodeAdmin), # custom admin allows custom edit/delete view. 37 | (models.ImageNode, BaseChildAdmin), 38 | ) 39 | 40 | list_display = ( 41 | "title", 42 | "actions_column", 43 | ) 44 | 45 | 46 | admin.site.register(models.BaseTreeNode, TreeNodeParentAdmin) 47 | -------------------------------------------------------------------------------- /example/tree/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | import polymorphic_tree.models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("contenttypes", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="BaseTreeNode", 15 | fields=[ 16 | ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), 17 | ("lft", models.PositiveIntegerField(editable=False, db_index=True)), 18 | ("rght", models.PositiveIntegerField(editable=False, db_index=True)), 19 | ("tree_id", models.PositiveIntegerField(editable=False, db_index=True)), 20 | ("level", models.PositiveIntegerField(editable=False, db_index=True)), 21 | ("title", models.CharField(max_length=200, verbose_name="Title")), 22 | ], 23 | options={ 24 | "verbose_name": "Tree node", 25 | "verbose_name_plural": "Tree nodes", 26 | }, 27 | bases=(models.Model,), 28 | ), 29 | migrations.CreateModel( 30 | name="CategoryNode", 31 | fields=[ 32 | ( 33 | "basetreenode_ptr", 34 | models.OneToOneField( 35 | on_delete=models.CASCADE, 36 | parent_link=True, 37 | auto_created=True, 38 | primary_key=True, 39 | serialize=False, 40 | to="tree.BaseTreeNode", 41 | ), 42 | ), 43 | ("opening_title", models.CharField(max_length=200, verbose_name="Opening title")), 44 | ( 45 | "opening_image", 46 | models.ImageField(upload_to="images", null=True, verbose_name="Opening image", blank=True), 47 | ), 48 | ], 49 | options={ 50 | "verbose_name": "Category node", 51 | "verbose_name_plural": "Category nodes", 52 | }, 53 | bases=("tree.basetreenode",), 54 | ), 55 | migrations.CreateModel( 56 | name="ImageNode", 57 | fields=[ 58 | ( 59 | "basetreenode_ptr", 60 | models.OneToOneField( 61 | on_delete=models.CASCADE, 62 | parent_link=True, 63 | auto_created=True, 64 | primary_key=True, 65 | serialize=False, 66 | to="tree.BaseTreeNode", 67 | ), 68 | ), 69 | ("image", models.ImageField(upload_to="images", verbose_name="Image")), 70 | ], 71 | options={ 72 | "verbose_name": "Image node", 73 | "verbose_name_plural": "Image nodes", 74 | }, 75 | bases=("tree.basetreenode",), 76 | ), 77 | migrations.CreateModel( 78 | name="TextNode", 79 | fields=[ 80 | ( 81 | "basetreenode_ptr", 82 | models.OneToOneField( 83 | on_delete=models.CASCADE, 84 | parent_link=True, 85 | auto_created=True, 86 | primary_key=True, 87 | serialize=False, 88 | to="tree.BaseTreeNode", 89 | ), 90 | ), 91 | ("extra_text", models.TextField(verbose_name="Extra text")), 92 | ], 93 | options={ 94 | "verbose_name": "Text node", 95 | "verbose_name_plural": "Text nodes", 96 | }, 97 | bases=("tree.basetreenode",), 98 | ), 99 | migrations.AddField( 100 | model_name="basetreenode", 101 | name="parent", 102 | field=polymorphic_tree.models.PolymorphicTreeForeignKey( 103 | related_name="children", verbose_name="parent", blank=True, to="tree.BaseTreeNode", null=True 104 | ), 105 | preserve_default=True, 106 | ), 107 | migrations.AddField( 108 | model_name="basetreenode", 109 | name="polymorphic_ctype", 110 | field=models.ForeignKey( 111 | on_delete=models.CASCADE, 112 | related_name=b"polymorphic_tree.basetreenode_set+", 113 | editable=False, 114 | to="contenttypes.ContentType", 115 | null=True, 116 | ), 117 | preserve_default=True, 118 | ), 119 | ] 120 | -------------------------------------------------------------------------------- /example/tree/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-polymorphic/django-polymorphic-tree/99e92880bfcc7f0992f12e4b35633be2a5171435/example/tree/migrations/__init__.py -------------------------------------------------------------------------------- /example/tree/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from polymorphic_tree.models import PolymorphicMPTTModel, PolymorphicTreeForeignKey 5 | 6 | 7 | # A base model for the tree: 8 | class BaseTreeNode(PolymorphicMPTTModel): 9 | parent = PolymorphicTreeForeignKey("self", blank=True, null=True, related_name="children", verbose_name=_("parent")) 10 | title = models.CharField(_("Title"), max_length=200) 11 | 12 | def __str__(self): 13 | return self.title 14 | 15 | class Meta: 16 | verbose_name = _("Tree node") 17 | verbose_name_plural = _("Tree nodes") 18 | 19 | 20 | # Create 3 derived models for the tree nodes: 21 | 22 | 23 | class CategoryNode(BaseTreeNode): 24 | opening_title = models.CharField(_("Opening title"), max_length=200) 25 | opening_image = models.ImageField(_("Opening image"), upload_to="images", blank=True, null=True) 26 | 27 | class Meta: 28 | verbose_name = _("Category node") 29 | verbose_name_plural = _("Category nodes") 30 | 31 | 32 | class TextNode(BaseTreeNode): 33 | extra_text = models.TextField(_("Extra text")) 34 | 35 | # Extra settings: 36 | can_have_children = False 37 | 38 | class Meta: 39 | verbose_name = _("Text node") 40 | verbose_name_plural = _("Text nodes") 41 | 42 | 43 | class ImageNode(BaseTreeNode): 44 | image = models.ImageField(_("Image"), upload_to="images") 45 | 46 | class Meta: 47 | verbose_name = _("Image node") 48 | verbose_name_plural = _("Image nodes") 49 | -------------------------------------------------------------------------------- /example/tree/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /example/tree/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /polymorphic_tree/__init__.py: -------------------------------------------------------------------------------- 1 | # following PEP 440 2 | __version__ = "2.1" 3 | -------------------------------------------------------------------------------- /polymorphic_tree/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from polymorphic_tree.admin.childadmin import PolymorphicMPTTChildModelAdmin, PolymorpicMPTTAdminForm 2 | from polymorphic_tree.admin.parentadmin import NodeTypeChoiceForm, PolymorphicMPTTParentModelAdmin 3 | 4 | __all__ = ( 5 | "PolymorphicMPTTChildModelAdmin", 6 | "PolymorpicMPTTAdminForm", 7 | "PolymorphicMPTTParentModelAdmin", 8 | "NodeTypeChoiceForm", 9 | ) 10 | -------------------------------------------------------------------------------- /polymorphic_tree/admin/childadmin.py: -------------------------------------------------------------------------------- 1 | from mptt.admin import MPTTModelAdmin 2 | from mptt.forms import MPTTAdminForm 3 | from polymorphic.admin import PolymorphicChildModelAdmin 4 | 5 | 6 | class PolymorpicMPTTAdminForm(MPTTAdminForm): 7 | pass 8 | 9 | 10 | class PolymorphicMPTTChildModelAdmin(PolymorphicChildModelAdmin, MPTTModelAdmin): 11 | """ 12 | The internal machinery 13 | The admin screen for the ``PolymorphicMPTTModel`` objects. 14 | """ 15 | 16 | base_model = None 17 | base_form = PolymorpicMPTTAdminForm 18 | base_fieldsets = None 19 | 20 | # NOTE: list page is configured in PolymorphicMPTTParentModelAdmin 21 | # as that class is used for the real admin screen in the edit/delete view. 22 | # This class is only a base class for the custom node type plugins. 23 | 24 | @property 25 | def change_form_template(self): 26 | # Insert template before default admin/polymorphic to have the tree in the breadcrumb 27 | templates = super().change_form_template 28 | templates.insert(-2, "admin/polymorphic_tree/change_form.html") 29 | return templates 30 | 31 | @property 32 | def delete_confirmation_template(self): 33 | # Insert template before default admin/polymorphic to have the tree in the breadcrumb 34 | templates = super().delete_confirmation_template 35 | templates.insert(-2, "admin/polymorphic_tree/delete_confirmation.html") 36 | return templates 37 | 38 | @property 39 | def object_history_template(self): 40 | # Insert template before default admin/polymorphic to have the tree in the breadcrumb 41 | templates = super().object_history_template 42 | templates.insert(-2, "admin/polymorphic_tree/object_history.html") 43 | return templates 44 | -------------------------------------------------------------------------------- /polymorphic_tree/admin/parentadmin.py: -------------------------------------------------------------------------------- 1 | import json 2 | from distutils.version import StrictVersion 3 | 4 | from django.conf import settings 5 | from django.contrib.admin import SimpleListFilter 6 | from django.contrib.auth import get_permission_codename 7 | from django.core.exceptions import ValidationError 8 | from django.db import transaction 9 | from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound, HttpResponseRedirect 10 | from django.urls import path, re_path, reverse 11 | from django.utils.translation import gettext 12 | from django.utils.translation import gettext_lazy as _ 13 | from mptt.admin import MPTTModelAdmin 14 | from mptt.exceptions import InvalidMove 15 | from polymorphic.admin import PolymorphicModelChoiceForm, PolymorphicParentModelAdmin 16 | 17 | from polymorphic_tree.models import PolymorphicMPTTModel 18 | 19 | 20 | class NodeTypeChoiceForm(PolymorphicModelChoiceForm): 21 | type_label = _("Node type") 22 | 23 | 24 | class NodeTypeListFilter(SimpleListFilter): 25 | parameter_name = "ct_id" 26 | title = _("node type") 27 | 28 | def lookups(self, request, model_admin): 29 | return model_admin.get_child_type_choices(request, "change") 30 | 31 | # Whoops: Django 1.6 didn't rename this one! 32 | def queryset(self, request, queryset): 33 | if self.value(): 34 | queryset = queryset.filter(polymorphic_ctype_id=self.value()) 35 | return queryset 36 | 37 | 38 | class PolymorphicMPTTParentModelAdmin(PolymorphicParentModelAdmin, MPTTModelAdmin): 39 | """ 40 | The parent admin, this renders the "list" page. 41 | It forwards the "edit" and "delete" views to the admin interface of the polymorphic models. 42 | 43 | The :func:`get_child_models` function or :attr:`child_models` attribute of the base class should still be implemented. 44 | """ 45 | 46 | base_model = PolymorphicMPTTModel 47 | add_type_form = NodeTypeChoiceForm 48 | 49 | # Config list page: 50 | list_filter = (NodeTypeListFilter,) 51 | 52 | # TODO: disable the pagination in the admin, because it doesn't work with the current template code. 53 | # This is a workaround for https://github.com/edoburu/django-polymorphic-tree/issues/2 until 54 | # proper pagination code (or a different JavaScript frontend) is included to deal with the interrupted tree levels. 55 | list_per_page = 10000 56 | 57 | EMPTY_ACTION_ICON = '' 58 | 59 | # ---- List code ---- 60 | 61 | @property 62 | def change_list_template(self): 63 | templates = super().change_list_template 64 | templates.insert(-1, "admin/polymorphic_tree/change_list.html") # Just before admin/change_list.html 65 | return templates 66 | 67 | # NOTE: the regular results table is replaced client-side with a jqTree list. 68 | # When making changes to the list, test both the JavaScript and non-JavaScript variant. 69 | # The jqTree variant still uses the server-side rendering for the columns. 70 | 71 | def actions_column(self, node): 72 | """ 73 | An extra column to display action icons. 74 | Can be included in the :attr:`~django.contrib.admin.ModelAdmin.list_display` attribute. 75 | """ 76 | return " ".join(self.get_action_icons(node)) 77 | 78 | actions_column.allow_tags = True 79 | actions_column.short_description = _("Actions") 80 | 81 | def get_action_icons(self, node): 82 | """ 83 | Return a list of all action icons in the :func:`actions_column`. 84 | """ 85 | actions = [] 86 | if node.can_have_children: 87 | actions.append( 88 | '' 89 | '{title}'.format( 90 | parent_attr=self.model._mptt_meta.parent_attr, 91 | id=node.pk, 92 | title=_("Add sub node"), 93 | static=settings.STATIC_URL, 94 | ) 95 | ) 96 | else: 97 | actions.append(self.EMPTY_ACTION_ICON.format(STATIC_URL=settings.STATIC_URL, css_class="add-child-object")) 98 | 99 | if self.can_preview_object(node): 100 | actions.append( 101 | '' 102 | '{title}'.format( 103 | url=node.get_absolute_url(), title=_("View on site"), static=settings.STATIC_URL 104 | ) 105 | ) 106 | 107 | # The is_first_sibling and is_last_sibling is quite heavy. Instead rely on CSS to hide the arrows. 108 | move_up = f'\u2191' 109 | move_down = f'\u2193' 110 | actions.append(f'{move_up}{move_down}') 111 | return actions 112 | 113 | def can_preview_object(self, node): 114 | """ 115 | Define whether a node can be previewed. 116 | """ 117 | return hasattr(node, "get_absolute_url") 118 | 119 | # ---- Custom views ---- 120 | 121 | def get_urls(self): 122 | """ 123 | Add custom URLs for moving nodes. 124 | """ 125 | base_urls = super().get_urls() 126 | info = _get_opt(self.model) 127 | extra_urls = [ 128 | path( 129 | "api/node-moved/", 130 | self.admin_site.admin_view(self.api_node_moved_view), 131 | name="{}_{}_moved".format(*info), 132 | ), 133 | re_path(r"^(\d+)/move_up/$", self.admin_site.admin_view(self.move_up_view)), 134 | re_path(r"^(\d+)/move_down/$", self.admin_site.admin_view(self.move_down_view)), 135 | ] 136 | return extra_urls + base_urls 137 | 138 | @property 139 | def api_node_moved_view_url(self): 140 | # Provided for result list template 141 | info = _get_opt(self.model) 142 | return reverse("admin:{}_{}_moved".format(*info), current_app=self.admin_site.name) 143 | 144 | @transaction.atomic 145 | def api_node_moved_view(self, request): 146 | """ 147 | Update the position of a node, from a API request. 148 | """ 149 | try: 150 | moved_id = _get_pk_value(request.POST["moved_id"]) 151 | target_id = _get_pk_value(request.POST["target_id"]) 152 | position = request.POST["position"] 153 | 154 | if request.POST.get("previous_parent_id"): 155 | previous_parent_id = _get_pk_value(request.POST["previous_parent_id"]) 156 | else: 157 | previous_parent_id = None 158 | 159 | # Not using .non_polymorphic() so all models are downcasted to the derived model. 160 | # This causes the signal below to be emitted from the proper class as well. 161 | moved = self.model.objects.get(pk=moved_id) 162 | target = self.model.objects.get(pk=target_id) 163 | except (ValueError, KeyError) as e: 164 | return HttpResponseBadRequest( 165 | json.dumps({"action": "foundbug", "error": str(e[0])}), content_type="application/json" 166 | ) 167 | except self.model.DoesNotExist as e: 168 | return HttpResponseNotFound( 169 | json.dumps({"action": "reload", "error": str(e[0])}), content_type="application/json" 170 | ) 171 | 172 | if not request.user.has_perm( 173 | "{}.{}".format(moved._meta.app_label, get_permission_codename("change", moved._meta)) 174 | ): 175 | return HttpResponse( 176 | json.dumps( 177 | { 178 | "action": "reject", 179 | "moved_id": moved_id, 180 | "error": gettext("You do not have permission to move this node."), 181 | } 182 | ), 183 | content_type="application/json", 184 | status=409, 185 | ) 186 | 187 | # Compare on strings to support UUID fields. 188 | parent_attr_id = f"{moved._mptt_meta.parent_attr}_id" 189 | if str(getattr(moved, parent_attr_id)) != str(previous_parent_id): 190 | return HttpResponse( 191 | json.dumps({"action": "reload", "error": "Client seems to be out-of-sync, please reload!"}), 192 | content_type="application/json", 193 | status=409, 194 | ) 195 | 196 | mptt_position = { 197 | "inside": "first-child", 198 | "before": "left", 199 | "after": "right", 200 | }[position] 201 | try: 202 | moved.move_to(target, mptt_position) 203 | except ValidationError as e: 204 | return HttpResponse( 205 | json.dumps({"action": "reject", "moved_id": moved_id, "error": "\n".join(e.messages)}), 206 | content_type="application/json", 207 | status=409, 208 | ) # Conflict 209 | except InvalidMove as e: 210 | return HttpResponse( 211 | json.dumps({"action": "reject", "moved_id": moved_id, "error": str(e)}), 212 | content_type="application/json", 213 | status=409, 214 | ) 215 | 216 | # Some packages depend on calling .save() or post_save signal after updating a model. 217 | # This is required by django-fluent-pages for example to update the URL caches. 218 | moved.save() 219 | 220 | # Report back to client. 221 | return HttpResponse( 222 | json.dumps( 223 | { 224 | "action": "success", 225 | "error": None, 226 | "moved_id": moved_id, 227 | "action_column": self.actions_column(moved), 228 | } 229 | ), 230 | content_type="application/json", 231 | ) 232 | 233 | def move_up_view(self, request, object_id): 234 | node = self.model.objects.get(pk=object_id) 235 | 236 | if node is not None: 237 | previous_sibling_category = node.get_previous_sibling() 238 | if previous_sibling_category is not None: 239 | node.move_to(previous_sibling_category, position="left") 240 | 241 | return HttpResponseRedirect("../../") 242 | 243 | def move_down_view(self, request, object_id): 244 | node = self.model.objects.get(pk=object_id) 245 | 246 | if node is not None: 247 | next_sibling_category = node.get_next_sibling() 248 | if next_sibling_category is not None: 249 | node.move_to(next_sibling_category, position="right") 250 | 251 | return HttpResponseRedirect("../../") 252 | 253 | 254 | def _get_opt(model): 255 | try: 256 | return model._meta.app_label, model._meta.model_name # Django 1.7 format 257 | except AttributeError: 258 | return model._meta.app_label, model._meta.module_name 259 | 260 | 261 | def _get_pk_value(text): 262 | try: 263 | return int(text) 264 | except ValueError: 265 | return text # Allow uuid fields 266 | -------------------------------------------------------------------------------- /polymorphic_tree/locale/es_AR/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2010-2012 2 | # This file is distributed under the same license as the PACKAGE package. 3 | # 4 | # Gonzalo Bustos, 2016. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: \n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2017-02-18 19:29+0100\n" 10 | "PO-Revision-Date: 2011-01-10 23:54+0100\n" 11 | "Last-Translator: Gonzalo Bustos\n" 12 | "Language-Team: Spanish (Argentina) \n" 13 | "Language: es_AR\n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 18 | 19 | #: admin/parentadmin.py:45 20 | msgid "Node type" 21 | msgstr "Tipo de nodo" 22 | 23 | #: admin/parentadmin.py:56 24 | msgid "node type" 25 | msgstr "tipo de nodo" 26 | 27 | #: admin/parentadmin.py:112 28 | msgid "Actions" 29 | msgstr "Acciones" 30 | 31 | #: admin/parentadmin.py:123 32 | msgid "Add sub node" 33 | msgstr "Agregar sub nodo" 34 | 35 | #: admin/parentadmin.py:132 36 | msgid "View on site" 37 | msgstr "Ver en sitio" 38 | 39 | #: admin/parentadmin.py:196 40 | msgid "You do not have permission to move this node." 41 | msgstr "" 42 | 43 | #: models.py:46 models.py:193 44 | msgid "This node type should have a parent." 45 | msgstr "" 46 | 47 | #: models.py:47 48 | #, fuzzy 49 | #| msgid "The selected node cannot have child nodes." 50 | msgid "The selected parent cannot have child nodes." 51 | msgstr "El nodo seleccionado no puede tener nodos hijos." 52 | 53 | #: models.py:48 54 | #, fuzzy 55 | #| msgid "The selected node cannot have child nodes." 56 | msgid "The selected parent cannot have this node type as a child!" 57 | msgstr "El nodo seleccionado no puede tener nodos hijos." 58 | 59 | #: models.py:197 60 | #, python-brace-format 61 | msgid "Cannot place ‘{0}’ below ‘{1}’; a {2} does not allow children!" 62 | msgstr "No se puede ubicar ‘{0}’ bajo ‘{1}’; un {2} no permite hijos!" 63 | 64 | #: models.py:203 65 | #, fuzzy, python-brace-format 66 | #| msgid "Cannot place ‘{0}’ below ‘{1}’; a {2} does not allow children!" 67 | msgid "Cannot place ‘{0}’ below ‘{1}’; a {2} does not allow {3} as a child!" 68 | msgstr "No se puede ubicar ‘{0}’ bajo ‘{1}’; un {2} no permite hijos!" 69 | 70 | #: templates/admin/polymorphic_tree/change_form_breadcrumbs.html:5 71 | #: templates/admin/polymorphic_tree/delete_confirmation.html:7 72 | #: templates/admin/polymorphic_tree/object_history_breadcrumbs.html:5 73 | msgid "Home" 74 | msgstr "Inicio" 75 | 76 | #: templates/admin/polymorphic_tree/change_form_breadcrumbs.html:20 77 | msgid "Add" 78 | msgstr "Agregar" 79 | 80 | #: templates/admin/polymorphic_tree/delete_confirmation.html:20 81 | msgid "Delete" 82 | msgstr "Eliminar" 83 | 84 | #: templates/admin/polymorphic_tree/jstree_list_results.html:115 85 | msgid "" 86 | "Unable to move the node, the current display is out-of-date.\\nThe current " 87 | "page now reloaded." 88 | msgstr "" 89 | "No se puede mover el nodo, el display actual está desactualizado.\\nSe " 90 | "recargó la página actual." 91 | 92 | #: templates/admin/polymorphic_tree/jstree_list_results.html:123 93 | msgid "" 94 | "There was an error while moving the node, please reload the current page." 95 | msgstr "" 96 | "Hubo un error cuando se movía el nodo, por favor recargá la página actual." 97 | 98 | #: templates/admin/polymorphic_tree/object_history_breadcrumbs.html:20 99 | msgid "History" 100 | msgstr "Historia" 101 | -------------------------------------------------------------------------------- /polymorphic_tree/locale/nl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2010-2012 2 | # This file is distributed under the same license as the PACKAGE package. 3 | # 4 | # Diederik van der Boor , 2010-2011 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: \n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2017-02-18 19:29+0100\n" 10 | "PO-Revision-Date: 2011-01-10 23:54+0100\n" 11 | "Last-Translator: Diederik van der Boor \n" 12 | "Language-Team: Dutch \n" 13 | "Language: \n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 18 | "X-Generator: Lokalize 1.1\n" 19 | 20 | #: admin/parentadmin.py:45 21 | msgid "Node type" 22 | msgstr "Nodetype" 23 | 24 | #: admin/parentadmin.py:56 25 | msgid "node type" 26 | msgstr "nodetype" 27 | 28 | #: admin/parentadmin.py:112 29 | msgid "Actions" 30 | msgstr "Acties" 31 | 32 | #: admin/parentadmin.py:123 33 | msgid "Add sub node" 34 | msgstr "Sub node toevoegen" 35 | 36 | #: admin/parentadmin.py:132 37 | msgid "View on site" 38 | msgstr "" 39 | 40 | #: admin/parentadmin.py:196 41 | msgid "You do not have permission to move this node." 42 | msgstr "Je hebt geen toegang om deze node te verplaatsen." 43 | 44 | #: models.py:46 models.py:193 45 | msgid "This node type should have a parent." 46 | msgstr "Deze node moet een subnode zijn." 47 | 48 | #: models.py:47 49 | msgid "The selected parent cannot have child nodes." 50 | msgstr "De gekozen node kan geen subnodes bevatten." 51 | 52 | #: models.py:48 53 | msgid "The selected parent cannot have this node type as a child!" 54 | msgstr "De gekozen node kan geen subnodes van dit type bevatten." 55 | 56 | #: models.py:197 57 | #, python-brace-format 58 | msgid "Cannot place ‘{0}’ below ‘{1}’; a {2} does not allow children!" 59 | msgstr "" 60 | "Kan ‘{0}’ niet onder ‘{1}’ plaatsen; een {2} staat geen " 61 | "onderliggende nodes toe!" 62 | 63 | #: models.py:203 64 | #, fuzzy, python-brace-format 65 | #| msgid "Cannot place ‘{0}’ below ‘{1}’; a {2} does not allow children!" 66 | msgid "Cannot place ‘{0}’ below ‘{1}’; a {2} does not allow {3} as a child!" 67 | msgstr "" 68 | "Kan ‘{0}’ niet onder ‘{1}’ plaatsen; een {2} node staat {3} niet toe als onderliggende node!" 69 | 70 | #: templates/admin/polymorphic_tree/change_form_breadcrumbs.html:5 71 | #: templates/admin/polymorphic_tree/delete_confirmation.html:7 72 | #: templates/admin/polymorphic_tree/object_history_breadcrumbs.html:5 73 | msgid "Home" 74 | msgstr "" 75 | 76 | #: templates/admin/polymorphic_tree/change_form_breadcrumbs.html:20 77 | #| msgid "Add %s" 78 | msgid "Add" 79 | msgstr "" 80 | 81 | #: templates/admin/polymorphic_tree/delete_confirmation.html:20 82 | msgid "Delete" 83 | msgstr "" 84 | 85 | #: templates/admin/polymorphic_tree/jstree_list_results.html:115 86 | msgid "" 87 | "Unable to move the node, the current display is out-of-date.\\nThe current " 88 | "page now reloaded." 89 | msgstr "" 90 | 91 | #: templates/admin/polymorphic_tree/jstree_list_results.html:123 92 | msgid "" 93 | "There was an error while moving the node, please reload the current page." 94 | msgstr "" 95 | "Er was een fout opgetreden bij het verplaatsen van de node, herlaad de " 96 | "huidige pagina a.u.b." 97 | 98 | #: templates/admin/polymorphic_tree/object_history_breadcrumbs.html:20 99 | msgid "History" 100 | msgstr "" 101 | -------------------------------------------------------------------------------- /polymorphic_tree/managers.py: -------------------------------------------------------------------------------- 1 | """ 2 | The manager class for the CMS models 3 | """ 4 | from mptt.managers import TreeManager 5 | from mptt.querysets import TreeQuerySet 6 | from polymorphic.managers import PolymorphicManager 7 | from polymorphic.query import PolymorphicQuerySet 8 | 9 | 10 | class PolymorphicMPTTQuerySet(TreeQuerySet, PolymorphicQuerySet): 11 | """ 12 | Base class for querysets 13 | """ 14 | 15 | def toplevel(self): 16 | """ 17 | Return all nodes which have no parent. 18 | """ 19 | return self.filter(parent__isnull=True) 20 | 21 | def as_manager(cls): 22 | # Make sure this way of creating managers works. 23 | manager = PolymorphicMPTTModelManager.from_queryset(cls)() 24 | manager._built_with_as_manager = True 25 | return manager 26 | 27 | as_manager.queryset_only = True 28 | as_manager = classmethod(as_manager) 29 | 30 | 31 | class PolymorphicMPTTModelManager(TreeManager, PolymorphicManager): 32 | """ 33 | Base class for a model manager. 34 | """ 35 | 36 | #: The queryset class to use. 37 | queryset_class = PolymorphicMPTTQuerySet 38 | 39 | def toplevel(self): 40 | """ 41 | Return all nodes which have no parent. 42 | """ 43 | # Calling .all() is equivalent to .get_queryset() 44 | return self.all().toplevel() 45 | 46 | def _mptt_filter(self, qs=None, **filters): 47 | if self._base_manager and qs is not None: 48 | # This is a little hack to fix get_previous_sibling() / get_next_sibling(). 49 | # When the queryset is defined (meaning: a call was made from model._tree_manager._mptt_filter(qs)), 50 | # there is a call to find related objects.# The current model might be a derived model however, 51 | # due to out polymorphic layout. Enforce seeking from the base model that holds the entire MPTT structure, 52 | # and the polymorphic queryset will upgrade the models again. 53 | if issubclass(qs.model, self.model): 54 | qs.model = self._base_manager.model 55 | qs.query.model = self._base_manager.model 56 | 57 | return super()._mptt_filter(qs, **filters) 58 | 59 | def move_node(self, node, target, position="last-child"): 60 | """ 61 | Move a node to a new location. 62 | This also performs checks whether the target allows this node to reside there. 63 | """ 64 | node.validate_move(target, position=position) 65 | return super().move_node(node, target, position=position) 66 | -------------------------------------------------------------------------------- /polymorphic_tree/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Model that inherits from both Polymorphic and MPTT. 3 | """ 4 | import uuid 5 | 6 | from django.contrib.contenttypes.models import ContentType 7 | from django.core.exceptions import ValidationError 8 | from django.utils.encoding import force_str 9 | from django.utils.translation import gettext 10 | from django.utils.translation import gettext_lazy as _ 11 | from mptt.exceptions import InvalidMove 12 | from mptt.models import MPTTModel, MPTTModelBase, TreeForeignKey, raise_if_unsaved 13 | from polymorphic.base import PolymorphicModelBase 14 | from polymorphic.models import PolymorphicModel 15 | 16 | from polymorphic_tree.managers import PolymorphicMPTTModelManager 17 | 18 | 19 | def _get_base_polymorphic_model(ChildModel): 20 | """ 21 | First model in the inheritance chain that inherited from the PolymorphicMPTTModel 22 | """ 23 | for Model in reversed(ChildModel.mro()): 24 | if ( 25 | isinstance(Model, PolymorphicMPTTModelBase) 26 | and Model is not PolymorphicMPTTModel 27 | and not Model._meta.abstract 28 | ): 29 | return Model 30 | return None 31 | 32 | 33 | class PolymorphicMPTTModelBase(MPTTModelBase, PolymorphicModelBase): 34 | """ 35 | Metaclass for all polymorphic models. 36 | Needed to support both MPTT and Polymorphic metaclasses. 37 | """ 38 | 39 | 40 | class PolymorphicTreeForeignKey(TreeForeignKey): 41 | """ 42 | A foreignkey that limits the node types the parent can be. 43 | """ 44 | 45 | default_error_messages = { 46 | "required": _("This node type should have a parent."), 47 | "no_children_allowed": _("The selected parent cannot have child nodes."), 48 | "child_not_allowed": _("The selected parent cannot have this node type as a child!"), 49 | } 50 | 51 | def clean(self, value, model_instance): 52 | value = super().clean(value, model_instance) 53 | self._validate_parent(value, model_instance) 54 | return value 55 | 56 | def _validate_parent(self, parent, model_instance): 57 | if not parent: 58 | # This can't test for model_instance.can_be_root, 59 | # because clean() is not called for empty values. 60 | return 61 | elif isinstance(parent, int) or isinstance(parent, uuid.UUID): 62 | # TODO: Improve this code, it's a bit of a hack now because the base model is not known in the NodeTypePool. 63 | base_model = _get_base_polymorphic_model(model_instance.__class__) 64 | parent = base_model.objects.get(pk=parent) 65 | elif not isinstance(parent, PolymorphicMPTTModel): 66 | raise ValueError("Unknown parent value") 67 | 68 | if not parent.can_have_children: 69 | raise ValidationError(self.error_messages["no_children_allowed"]) 70 | 71 | if not parent.is_child_allowed(model_instance): 72 | raise ValidationError(self.error_messages["child_not_allowed"]) 73 | 74 | 75 | class PolymorphicMPTTModel(MPTTModel, PolymorphicModel, metaclass=PolymorphicMPTTModelBase): 76 | """ 77 | The base class for all nodes; a mapping of an URL to content (e.g. a HTML page, text file, blog, etc..) 78 | """ 79 | 80 | #: Whether the node type allows to have children. 81 | can_have_children = True 82 | 83 | #: Whether the node type can be a root node. 84 | can_be_root = True 85 | 86 | #: Allowed child types for this page. 87 | child_types = [] 88 | 89 | # Cache child types using a class variable to ensure that get_child_types 90 | # is run once per page class, per django initiation. 91 | __child_types = {} 92 | 93 | # Django fields 94 | objects = PolymorphicMPTTModelManager() 95 | 96 | class Meta: 97 | abstract = True 98 | ordering = ( 99 | "tree_id", 100 | "lft", 101 | ) 102 | base_manager_name = "objects" 103 | 104 | @property 105 | def page_key(self): 106 | """ 107 | A unique key for this page to ensure get_child_types is run once per 108 | page. 109 | """ 110 | return repr(self) 111 | 112 | def get_child_types(self): 113 | """ 114 | Get the allowed child types and convert them into content type ids. 115 | This allows for the lookup of allowed children in the admin tree. 116 | """ 117 | key = self.page_key 118 | child_types = self._PolymorphicMPTTModel__child_types 119 | if not self.can_have_children: 120 | return [] 121 | 122 | if child_types.get(key, None) is None: 123 | new_children = [] 124 | iterator = iter(self.child_types) 125 | for child in iterator: 126 | if isinstance(child, str): 127 | child = str(child).lower() 128 | # write self to refer to self 129 | if child == "self": 130 | ct_id = self.polymorphic_ctype_id 131 | else: 132 | # either the name of a model in this app 133 | # or the full app.model dot string 134 | # just like a foreign key 135 | try: 136 | app_label, model = child.rsplit(".", 1) 137 | except ValueError: 138 | app_label = self._meta.app_label 139 | model = child 140 | ct_id = ContentType.objects.get_by_natural_key(app_label, model).id 141 | else: 142 | # pass in a model class 143 | ct_id = ContentType.objects.get_for_model(child).id 144 | new_children.append(ct_id) 145 | child_types[key] = new_children 146 | 147 | return child_types[key] 148 | 149 | # Define: 150 | # parent = PolymorphicTreeForeignKey('self', blank=True, null=True, related_name='children', verbose_name=_('parent'), 151 | # help_text=_('You can also change the parent by dragging the item in the list.')) 152 | # class MPTTMeta: 153 | # order_insertion_by = 'title' 154 | 155 | @raise_if_unsaved 156 | def get_closest_ancestor_of_type(self, model, include_self=False): 157 | """ 158 | Find the first parent of a specific model type. 159 | """ 160 | if include_self and isinstance(self, model): 161 | return self 162 | else: 163 | try: 164 | return self.get_ancestors_of_type(model, ascending=False)[0] 165 | except IndexError: 166 | return None 167 | 168 | @raise_if_unsaved 169 | def get_ancestors_of_type(self, model, ascending=False, include_self=False): 170 | """ 171 | Find a parent of a specific type. 172 | """ 173 | return self.get_ancestors(ascending=ascending, include_self=include_self).instance_of(model) 174 | 175 | def is_child_allowed(self, child): 176 | """ 177 | Tell whether this node allows the given node as child. 178 | """ 179 | if not self.can_have_children: 180 | return False 181 | 182 | child_types = self.get_child_types() 183 | 184 | # this allows tree validation to occur in the event the child model is not 185 | # yet created in db (ie. when django admin tries to validate) 186 | child.pre_save_polymorphic() 187 | 188 | return not child_types or child.polymorphic_ctype_id in child_types 189 | 190 | def validate_move(self, target, position="first-child"): 191 | """ 192 | Validate whether the move to a new location is permitted. 193 | 194 | :param target: The node to move to 195 | :type target: PolymorphicMPTTModel 196 | :param position: The relative position to the target. This can be ``'first-child'``, 197 | ``'last-child'``, ``'left'`` or ``'right'``. 198 | """ 199 | new_parent = _get_new_parent(self, target, position) 200 | 201 | if new_parent is None: 202 | if not self.can_be_root: 203 | raise InvalidMove(gettext("This node type should have a parent.")) 204 | else: 205 | if not new_parent.can_have_children: 206 | raise InvalidMove( 207 | gettext( 208 | "Cannot place \u2018{0}\u2019 below \u2018{1}\u2019; a {2} does not allow children!" 209 | ).format(self, new_parent, new_parent._meta.verbose_name) 210 | ) 211 | 212 | if not new_parent.is_child_allowed(self): 213 | raise InvalidMove( 214 | gettext( 215 | "Cannot place \u2018{0}\u2019 below \u2018{1}\u2019; a {2} does not allow {3} as a child!" 216 | ).format(self, target, target._meta.verbose_name, self._meta.verbose_name) 217 | ) 218 | 219 | # Allow custom validation 220 | self.validate_move_to(new_parent) 221 | 222 | def validate_move_to(self, new_parent): 223 | """ 224 | Can move be finished 225 | 226 | Method have to be redefined in inherited model to define cases when 227 | node can be moved. If method is not redefined moving is always allowed. 228 | The ``new_parent`` can be ``None`` when the node is moved to the root. 229 | 230 | To deny move, this method have to be raised ``ValidationError`` or 231 | ``InvalidMove`` from ``mptt.exceptions`` 232 | """ 233 | 234 | def clean(self): 235 | super().clean() 236 | 237 | try: 238 | # Make sure form validation also reports choosing a wrong parent. 239 | # This also updates what PolymorphicTreeForeignKey already does. 240 | parent_id = getattr(self, self._mptt_meta.parent_attr + "_id") 241 | parent = getattr(self, self._mptt_meta.parent_attr) if parent_id else None 242 | self.validate_move(parent) 243 | except InvalidMove as e: 244 | raise ValidationError({self._mptt_meta.parent_attr: force_str(e)}) 245 | 246 | 247 | def _get_new_parent(moved, target, position="first-child"): 248 | """ 249 | Find out which parent the node will reside under. 250 | """ 251 | if position in ("first-child", "last-child"): 252 | return target 253 | elif position in ("left", "right"): 254 | # left/right of an other node 255 | parent_attr_id = f"{moved._mptt_meta.parent_attr}_id" 256 | if getattr(target, parent_attr_id) == getattr(moved, parent_attr_id): 257 | # kept inside the same parent, hopefully use the cache we already have. 258 | return getattr(moved, moved._mptt_meta.parent_attr) 259 | 260 | return getattr(target, target._mptt_meta.parent_attr) 261 | else: 262 | raise ValueError("invalid mptt position argument") 263 | -------------------------------------------------------------------------------- /polymorphic_tree/static/polymorphic_tree/adminlist/arrow-down.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-polymorphic/django-polymorphic-tree/99e92880bfcc7f0992f12e4b35633be2a5171435/polymorphic_tree/static/polymorphic_tree/adminlist/arrow-down.gif -------------------------------------------------------------------------------- /polymorphic_tree/static/polymorphic_tree/adminlist/arrow-up.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-polymorphic/django-polymorphic-tree/99e92880bfcc7f0992f12e4b35633be2a5171435/polymorphic_tree/static/polymorphic_tree/adminlist/arrow-up.gif -------------------------------------------------------------------------------- /polymorphic_tree/static/polymorphic_tree/adminlist/nav-bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-polymorphic/django-polymorphic-tree/99e92880bfcc7f0992f12e4b35633be2a5171435/polymorphic_tree/static/polymorphic_tree/adminlist/nav-bg.gif -------------------------------------------------------------------------------- /polymorphic_tree/static/polymorphic_tree/adminlist/nodetree.css: -------------------------------------------------------------------------------- 1 | /* 2 | * jsTree django theme 1.0 3 | */ 4 | 5 | 6 | /* column layout */ 7 | 8 | .jqtree-django { 9 | clear: both; 10 | } 11 | 12 | .jqtree-django .col-primary { 13 | float: left; 14 | width: auto; 15 | z-index: 0; 16 | } 17 | 18 | .jqtree-django .col-metadata { 19 | float: right; 20 | width: auto; /* all 3 columns combined is 393px */ 21 | z-index: 1; 22 | overflow: hidden; 23 | } 24 | 25 | .jqtree-django .js-tree-header .action-checkbox-column { display: none; } 26 | .jqtree-django .js-tree-header .first-column { border-left: 0; padding-left: 15px; } 27 | 28 | 29 | /* Base setup */ 30 | 31 | .jqtree-django li { 32 | /* The "tr" */ 33 | clear: both; 34 | margin: 0; 35 | padding: 0; 36 | list-style: none; 37 | } 38 | 39 | .jqtree-django li > div:after { 40 | /* needed for toggler icon */ 41 | content: "."; 42 | font-size: 0; 43 | line-height: 0; 44 | display: block; 45 | height: 0; 46 | clear: both; 47 | visibility: hidden; 48 | } 49 | 50 | * html .jqtree-django li div { height: 1%; } /* 1% of nothing makes IE 6 do the right thing. */ 51 | *+html .jqtree-django li > div { min-height: 0; } /* and IE7 has new tricks to trigger hasLayout. */ 52 | 53 | .jqtree-django ul { 54 | clear: both; 55 | /* reset django admin styles */ 56 | padding-left: 0; 57 | padding-right: 0; 58 | margin-top: 0; 59 | margin-bottom: 0; 60 | } 61 | 62 | .jqtree-django ul.tree { 63 | margin: 0; 64 | padding: 0 0 0 12px; 65 | } 66 | 67 | 68 | /* column layout */ 69 | 70 | .jqtree-django ul.tree a.toggler { 71 | position: absolute; 72 | left: -6px; 73 | margin-top: -4px; 74 | top: 50%; 75 | } 76 | 77 | .jqtree-django div.col { 78 | /* the "td" cells */ 79 | float: left; 80 | 81 | line-height: 16px; /* increase if a column is larger */ 82 | font-size: 11px; 83 | } 84 | 85 | .jqtree-django div.first-column { 86 | border-left: 0; 87 | } 88 | 89 | .jqtree-django div.first-column a { 90 | font-size: 12px; 91 | font-weight: bold; 92 | } 93 | 94 | ul.tree li img { vertical-align: baseline; } /* keeps 3px space below, use 'bottom' to remove it */ 95 | 96 | 97 | /* drag and drop */ 98 | 99 | #js-result-list .tree-dragging { 100 | color: #333; 101 | border: 1px solid #ddd; 102 | background: #eee; 103 | opacity: 0.9; 104 | cursor: pointer; 105 | padding: 2px 8px; 106 | } 107 | 108 | #js-result-list .tree-dragging .first-column { min-width: 500px; } 109 | #js-result-list .tree-dragging .col-metadata { display: none; } 110 | 111 | 112 | /* resets */ 113 | 114 | .jqtree-django ul.tree li.folder { margin-bottom: 0; } 115 | 116 | 117 | /* non-javascript buttons */ 118 | 119 | ul.tree .no-js { display: none; } 120 | ul.tree li:first-child .move-up { visibility: hidden; } /* Hiding in CSS is easy, the server-side urlnode.is_first_sibling call is expensive. */ 121 | ul.tree li:last-child .move-down { visibility: hidden; } 122 | -------------------------------------------------------------------------------- /polymorphic_tree/static/polymorphic_tree/adminlist/nodetree_classic.css: -------------------------------------------------------------------------------- 1 | #changelist table.js-tree-header thead th { 2 | padding: 2px 5px; 3 | } 4 | 5 | .jqtree-django ul.tree { 6 | background-image: url(zebra.png); 7 | } 8 | 9 | .jqtree-django div.col { 10 | padding: 5px; 11 | } 12 | 13 | .jqtree-django .col-primary, 14 | .jqtree-django .col-metadata { 15 | /* Fix height of columns, for zebra background. 16 | Also masks subtle differences within various cells if they are there */ 17 | height: 27px; 18 | } 19 | 20 | .jqtree-django .col-metadata { 21 | border-right: 1px solid #ddd; 22 | } 23 | 24 | .jqtree-django .col-metadata .col { 25 | border-left: 1px solid #ddd; 26 | } 27 | -------------------------------------------------------------------------------- /polymorphic_tree/static/polymorphic_tree/adminlist/nodetree_flat.css: -------------------------------------------------------------------------------- 1 | #changelist table.js-tree-header thead th { 2 | padding: 8px; /* align with fake div columns */ 3 | } 4 | 5 | .jqtree-django ul.tree { 6 | background-image: url(zebra-flat.png); 7 | } 8 | 9 | .jqtree-django div.col { 10 | padding: 8px; 11 | } 12 | 13 | .jqtree-django .col-primary, 14 | .jqtree-django .col-metadata { 15 | height: 35px; 16 | } 17 | -------------------------------------------------------------------------------- /polymorphic_tree/static/polymorphic_tree/adminlist/nodetree_grappelli.css: -------------------------------------------------------------------------------- 1 | /* grappelli styling and workarounds */ 2 | 3 | #grp-container .results { 4 | margin: 0 0 5px 0; 5 | } 6 | 7 | #grp-container .jqtree-django table { 8 | width: 100%; /* for grappelli */ 9 | } 10 | 11 | #grp-container #js-result-list { 12 | border-left: 1px solid #ccc; 13 | border-bottom: 1px solid #ccc; 14 | border-right: 1px solid #ccc; 15 | border-bottom-left-radius: 3px; 16 | border-bottom-right-radius: 3px; 17 | margin-left: -1px; 18 | margin-right: 1px; 19 | } 20 | 21 | #grp-container .jqtree-django ul.tree { 22 | margin: 0; 23 | padding: 0 0 0 12px; 24 | background-image: url(zebra-grappelli.png); 25 | } 26 | 27 | #grp-container .jqtree-django .col-primary, 28 | #grp-container .jqtree-django .col-metadata { 29 | height: 36px; 30 | } 31 | 32 | #grp-container table thead th.col-title { 33 | border-top-left-radius: 2px; 34 | border-left: 0 none; 35 | } 36 | 37 | #grp-container .jqtree-django .col-metadata { 38 | border-right: 0; 39 | } 40 | 41 | #grp-container .jqtree-django div.col { 42 | padding: 10px; 43 | } 44 | -------------------------------------------------------------------------------- /polymorphic_tree/static/polymorphic_tree/adminlist/zebra-flat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-polymorphic/django-polymorphic-tree/99e92880bfcc7f0992f12e4b35633be2a5171435/polymorphic_tree/static/polymorphic_tree/adminlist/zebra-flat.png -------------------------------------------------------------------------------- /polymorphic_tree/static/polymorphic_tree/adminlist/zebra-grappelli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-polymorphic/django-polymorphic-tree/99e92880bfcc7f0992f12e4b35633be2a5171435/polymorphic_tree/static/polymorphic_tree/adminlist/zebra-grappelli.png -------------------------------------------------------------------------------- /polymorphic_tree/static/polymorphic_tree/adminlist/zebra.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-polymorphic/django-polymorphic-tree/99e92880bfcc7f0992f12e4b35633be2a5171435/polymorphic_tree/static/polymorphic_tree/adminlist/zebra.png -------------------------------------------------------------------------------- /polymorphic_tree/static/polymorphic_tree/icons/blank.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-polymorphic/django-polymorphic-tree/99e92880bfcc7f0992f12e4b35633be2a5171435/polymorphic_tree/static/polymorphic_tree/icons/blank.gif -------------------------------------------------------------------------------- /polymorphic_tree/static/polymorphic_tree/icons/page_new.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-polymorphic/django-polymorphic-tree/99e92880bfcc7f0992f12e4b35633be2a5171435/polymorphic_tree/static/polymorphic_tree/icons/page_new.gif -------------------------------------------------------------------------------- /polymorphic_tree/static/polymorphic_tree/icons/world.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-polymorphic/django-polymorphic-tree/99e92880bfcc7f0992f12e4b35633be2a5171435/polymorphic_tree/static/polymorphic_tree/icons/world.gif -------------------------------------------------------------------------------- /polymorphic_tree/static/polymorphic_tree/jquery.cookie.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Cookie plugin 3 | * 4 | * Copyright (c) 2006 Klaus Hartl (stilbuero.de) 5 | * Dual licensed under the MIT and GPL licenses: 6 | * http://www.opensource.org/licenses/mit-license.php 7 | * http://www.gnu.org/licenses/gpl.html 8 | * 9 | */ 10 | 11 | /** 12 | * Create a cookie with the given name and value and other optional parameters. 13 | * 14 | * @example $.cookie('the_cookie', 'the_value'); 15 | * @desc Set the value of a cookie. 16 | * @example $.cookie('the_cookie', 'the_value', { expires: 7, path: '/', domain: 'jquery.com', secure: true }); 17 | * @desc Create a cookie with all available options. 18 | * @example $.cookie('the_cookie', 'the_value'); 19 | * @desc Create a session cookie. 20 | * @example $.cookie('the_cookie', null); 21 | * @desc Delete a cookie by passing null as value. Keep in mind that you have to use the same path and domain 22 | * used when the cookie was set. 23 | * 24 | * @param String name The name of the cookie. 25 | * @param String value The value of the cookie. 26 | * @param Object options An object literal containing key/value pairs to provide optional cookie attributes. 27 | * @option Number|Date expires Either an integer specifying the expiration date from now on in days or a Date object. 28 | * If a negative value is specified (e.g. a date in the past), the cookie will be deleted. 29 | * If set to null or omitted, the cookie will be a session cookie and will not be retained 30 | * when the the browser exits. 31 | * @option String path The value of the path atribute of the cookie (default: path of page that created the cookie). 32 | * @option String domain The value of the domain attribute of the cookie (default: domain of page that created the cookie). 33 | * @option Boolean secure If true, the secure attribute of the cookie will be set and the cookie transmission will 34 | * require a secure protocol (like HTTPS). 35 | * @type undefined 36 | * 37 | * @name $.cookie 38 | * @cat Plugins/Cookie 39 | * @author Klaus Hartl/klaus.hartl@stilbuero.de 40 | */ 41 | 42 | /** 43 | * Get the value of a cookie with the given name. 44 | * 45 | * @example $.cookie('the_cookie'); 46 | * @desc Get the value of a cookie. 47 | * 48 | * @param String name The name of the cookie. 49 | * @return The value of the cookie. 50 | * @type String 51 | * 52 | * @name $.cookie 53 | * @cat Plugins/Cookie 54 | * @author Klaus Hartl/klaus.hartl@stilbuero.de 55 | */ 56 | jQuery.cookie = function(name, value, options) { 57 | if (typeof value != 'undefined') { // name and value given, set cookie 58 | options = options || {}; 59 | if (value === null) { 60 | value = ''; 61 | options.expires = -1; 62 | } 63 | var expires = ''; 64 | if (options.expires && (typeof options.expires == 'number' || options.expires.toUTCString)) { 65 | var date; 66 | if (typeof options.expires == 'number') { 67 | date = new Date(); 68 | date.setTime(date.getTime() + (options.expires * 24 * 60 * 60 * 1000)); 69 | } else { 70 | date = options.expires; 71 | } 72 | expires = '; expires=' + date.toUTCString(); // use expires attribute, max-age is not supported by IE 73 | } 74 | // CAUTION: Needed to parenthesize options.path and options.domain 75 | // in the following expressions, otherwise they evaluate to undefined 76 | // in the packed version for some reason... 77 | var path = options.path ? '; path=' + (options.path) : ''; 78 | var domain = options.domain ? '; domain=' + (options.domain) : ''; 79 | var secure = options.secure ? '; secure' : ''; 80 | document.cookie = [name, '=', encodeURIComponent(value), expires, path, domain, secure].join(''); 81 | } else { // only name given, get cookie 82 | var cookieValue = null; 83 | if (document.cookie && document.cookie != '') { 84 | var cookies = document.cookie.split(';'); 85 | for (var i = 0; i < cookies.length; i++) { 86 | var cookie = jQuery.trim(cookies[i]); 87 | // Does this cookie string begin with the name we want? 88 | if (cookie.substring(0, name.length + 1) == (name + '=')) { 89 | cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); 90 | break; 91 | } 92 | } 93 | } 94 | return cookieValue; 95 | } 96 | }; -------------------------------------------------------------------------------- /polymorphic_tree/templates/admin/polymorphic_tree/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | 3 | {% block breadcrumbs %}{% if not is_popup %}{% include "admin/polymorphic_tree/change_form_breadcrumbs.html" %}{% endif %}{% endblock %} 4 | -------------------------------------------------------------------------------- /polymorphic_tree/templates/admin/polymorphic_tree/change_form_breadcrumbs.html: -------------------------------------------------------------------------------- 1 | {% load i18n admin_urls polymorphic_admin_tags polymorphic_tree_admin_tags %} 2 | 3 | {% breadcrumb_scope base_opts %} 4 | 18 | {% endbreadcrumb_scope %} 19 | -------------------------------------------------------------------------------- /polymorphic_tree/templates/admin/polymorphic_tree/change_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/polymorphic_tree/stylable_change_list.html" %} 2 | {% load admin_list stylable_admin_list %} 3 | 4 | {% block result_list %} 5 | {% if action_form and actions_on_top and cl.full_result_count %}{% admin_actions %}{% endif %} 6 | {% block result_list_content %}{% stylable_result_list cl template="admin/polymorphic_tree/jstree_list_results.html" %}{% endblock %} 7 | {% if action_form and actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %} 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /polymorphic_tree/templates/admin/polymorphic_tree/delete_confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/delete_confirmation.html" %} 2 | {% load i18n admin_urls polymorphic_admin_tags polymorphic_tree_admin_tags %} 3 | 4 | {# Add tree levels to polymorphic breadcrumb #} 5 | {% block breadcrumbs %}{% breadcrumb_scope base_opts %} 6 | 18 | {% endbreadcrumb_scope %}{% endblock %} 19 | -------------------------------------------------------------------------------- /polymorphic_tree/templates/admin/polymorphic_tree/jstree_list_results.html: -------------------------------------------------------------------------------- 1 | {# variation of admin/change_list_results.html #} 2 | {% load static i18n polymorphic_tree_admin_tags %}{% get_static_prefix as STATIC_URL %} 3 | 4 | {% if result_hidden_fields %} 5 |
{# DIV for HTML validation #} 6 | {% for item in result_hidden_fields %}{{ item }}{% endfor %} 7 |
8 | {% endif %} 9 | {% if results %} 10 |
11 | 12 | 13 | {# This is the Django 1.3 style for columns, so it works with both v1.3 and 1.4. #} 14 | {% for header in result_headers %} 18 | 19 | 20 | {% for result in results %} 21 | {% if result.form.non_field_errors %} 22 | 23 | {% endif %} 24 | {% for item in result %}{{ item }}{% endfor %} 25 | {% endfor %} 26 | 27 |
15 |
{{ header.text|capfirst }}
16 | {% endfor %} 17 |
{{ result.form.non_field_errors }}
28 |
29 | {% endif %} 30 | 31 | 32 | 33 | 34 | {% if not has_add_permission %}{% endif %} 37 | 42 | 43 | 44 | 127 | -------------------------------------------------------------------------------- /polymorphic_tree/templates/admin/polymorphic_tree/object_history.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/object_history.html' %}{% comment %} 2 | 3 | This file is selected by default to display polymorphic mptt object history. 4 | When using reversion or reversion-compare, this template could be replaced 5 | with the proper {% extends "reversion/object_history.html" %} template. 6 | Hence, the breadcrumb code is in a separate include file. 7 | 8 | {% endcomment %} 9 | 10 | {% block breadcrumbs %}{% include "admin/polymorphic_tree/object_history_breadcrumbs.html" %}{% endblock %} 11 | -------------------------------------------------------------------------------- /polymorphic_tree/templates/admin/polymorphic_tree/object_history_breadcrumbs.html: -------------------------------------------------------------------------------- 1 | {% load i18n admin_urls polymorphic_admin_tags polymorphic_tree_admin_tags %} 2 | 3 | {% breadcrumb_scope base_opts %} 4 | 18 | {% endbreadcrumb_scope %} 19 | -------------------------------------------------------------------------------- /polymorphic_tree/templates/admin/polymorphic_tree/stylable_change_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load admin_list stylable_admin_list %} 3 | 4 | {% block result_list %} 5 | {% if action_form and actions_on_top and cl.full_result_count %}{% admin_actions %}{% endif %} 6 | {% block result_list_content %}{% stylable_result_list cl %}{% endblock %} 7 | {% if action_form and actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %} 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /polymorphic_tree/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-polymorphic/django-polymorphic-tree/99e92880bfcc7f0992f12e4b35633be2a5171435/polymorphic_tree/templatetags/__init__.py -------------------------------------------------------------------------------- /polymorphic_tree/templatetags/polymorphic_tree_admin_tags.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.views.main import ChangeList 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.template import Library, Node, TemplateSyntaxError, Variable 4 | from django.utils.safestring import mark_safe 5 | from mptt.templatetags.mptt_tags import cache_tree_children 6 | 7 | from polymorphic_tree.templatetags.stylable_admin_list import stylable_column_repr 8 | 9 | register = Library() 10 | 11 | 12 | @register.filter 13 | def real_model_name(node): 14 | # Allow upcasted model to work. 15 | # node.get_real_instance_class().__name__ would also work 16 | return ContentType.objects.get_for_id(node.polymorphic_ctype_id).model 17 | 18 | 19 | @register.filter 20 | def mptt_breadcrumb(node): 21 | """ 22 | Return a breadcrumb of nodes, for the admin breadcrumb 23 | """ 24 | if node is None: 25 | return [] 26 | else: 27 | return list(node.get_ancestors()) 28 | 29 | 30 | class AdminListRecurseTreeNode(Node): 31 | def __init__(self, template_nodes, cl_var): 32 | self.template_nodes = template_nodes 33 | self.cl_var = cl_var 34 | 35 | @classmethod 36 | def parse(cls, parser, token): 37 | bits = token.contents.split() 38 | if len(bits) != 2: 39 | raise TemplateSyntaxError("%s tag requires an admin ChangeList" % bits[0]) 40 | 41 | cl_var = Variable(bits[1]) 42 | 43 | template_nodes = parser.parse(("endadminlist_recursetree",)) 44 | parser.delete_first_token() 45 | return cls(template_nodes, cl_var) 46 | 47 | def _render_node(self, context, cl, node): 48 | bits = [] 49 | context.push() 50 | 51 | # Render children to add to parent later 52 | for child in node.get_children(): 53 | bits.append(self._render_node(context, cl, child)) 54 | 55 | columns = self._get_column_repr(cl, node) # list(tuple(name, html), ..) 56 | first_real_column = next(col for col in columns if col[0] != "action_checkbox") 57 | 58 | context["columns"] = columns 59 | context["other_columns"] = [col for col in columns if col[0] not in ("action_checkbox", first_real_column[0])] 60 | context["first_column"] = first_real_column[1] 61 | context["named_columns"] = dict(columns) 62 | context["node"] = node 63 | context["change_url"] = cl.url_for_result(node) 64 | context["children"] = mark_safe("".join(bits)) 65 | 66 | # Render 67 | rendered = self.template_nodes.render(context) 68 | context.pop() 69 | return rendered 70 | 71 | def render(self, context): 72 | cl = self.cl_var.resolve(context) 73 | assert isinstance(cl, ChangeList), "cl variable should be an admin ChangeList" # Also assists PyCharm 74 | roots = cache_tree_children(cl.result_list) 75 | bits = [self._render_node(context, cl, node) for node in roots] 76 | return "".join(bits) 77 | 78 | def _get_column_repr(self, cl, node): 79 | columns = [] 80 | for field_name in cl.list_display: 81 | html, row_class_ = stylable_column_repr(cl, node, field_name) 82 | columns.append((field_name, html)) 83 | return columns 84 | 85 | 86 | @register.tag 87 | def adminlist_recursetree(parser, token): 88 | """ 89 | Very similar to the mptt recursetree, except that it also returns the styled admin code. 90 | """ 91 | return AdminListRecurseTreeNode.parse(parser, token) 92 | -------------------------------------------------------------------------------- /polymorphic_tree/templatetags/stylable_admin_list.py: -------------------------------------------------------------------------------- 1 | """ 2 | A stylable admin_list 3 | 4 | This is a rewritten version of ``mptt/templatetags/mptt_admin.py`` which allows 5 | more styling of the admin list throughout the ModelAdmin class. 6 | By default, each column header will get a ``col-FIELD_NAME`` class, 7 | allowing to set the widths of the column from CSS. 8 | 9 | Furthermore, the ModelAdmin can add the property ``list_column_classes`` 10 | to the class, to define custom classes for a column. 11 | 12 | This feature can be activated by simply extending the template stylable/admin/change_list.html 13 | """ 14 | import django 15 | from django.conf import settings 16 | from django.contrib.admin.templatetags.admin_list import _boolean_icon, result_headers 17 | from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist 18 | from django.db import models 19 | from django.template import Library 20 | from django.utils import formats, timezone 21 | from django.utils.encoding import force_str, smart_str 22 | from django.utils.html import conditional_escape, escape 23 | from django.utils.safestring import SafeData, mark_safe 24 | from tag_parser.basetags import BaseInclusionNode 25 | 26 | # While this is based on mptt/templatetags/mptt_admin.py, 27 | # and django/contrib/admin/templatetags/admin_list.py, 28 | # much has been changed, simplified, and refactored for clarity. 29 | # What used to be one big method, is now split into several. 30 | 31 | 32 | # Expose template tags 33 | register = Library() 34 | 35 | # Get app settings 36 | MPTT_ADMIN_LEVEL_INDENT = getattr(settings, "MPTT_ADMIN_LEVEL_INDENT", 10) 37 | 38 | 39 | # Ideally the template name should be configurable too, provide a function instead of filename. 40 | # For now, just reuse the existing admin template for the list contents. 41 | class StylableResultList(BaseInclusionNode): 42 | min_args = 1 43 | max_args = 1 44 | template_name = "admin/change_list_results.html" 45 | 46 | def get_context_data(self, parent_context, *tag_args, **tag_kwargs): 47 | cl = tag_args[0] 48 | 49 | if "grappelli" in settings.INSTALLED_APPS: 50 | theme_css = "polymorphic_tree/adminlist/nodetree_grappelli.css" 51 | elif "classic_theme" in settings.INSTALLED_APPS: 52 | theme_css = "polymorphic_tree/adminlist/nodetree_classic.css" 53 | else: 54 | # flat theme aka Django 1.9 default 55 | theme_css = "polymorphic_tree/adminlist/nodetree_flat.css" 56 | 57 | return { 58 | "cl": cl, 59 | "result_headers": list(stylable_result_headers(cl)), 60 | "results": list(stylable_results(cl)), 61 | "nodetree_theme_css": theme_css, 62 | # added for frontend 63 | "has_add_permission": parent_context["has_add_permission"], 64 | } 65 | 66 | 67 | @register.tag 68 | def stylable_result_list(parser, token): 69 | """ 70 | Displays the headers and data list together 71 | """ 72 | return StylableResultList.parse(parser, token) 73 | 74 | 75 | def stylable_result_headers(cl): 76 | """ 77 | Reuse the existing result_headers() iterator, 78 | and add a `col-FIELD_NAME` class to the header, and fieldname to assist JavaScript 79 | cl = The django ChangeList object 80 | """ 81 | for field_name, header in zip(cl.list_display, result_headers(cl)): 82 | header["field_name"] = field_name # For JavaScript 83 | 84 | if header.get("class_attrib"): 85 | # Remove any sorting marker for mptt tables, because they are not sortable. 86 | if hasattr(cl.model, "_mptt_meta"): 87 | header["class_attrib"] = ( 88 | header["class_attrib"].replace("sortable", "").replace("sorted", "").replace("ascending", "") 89 | ) 90 | 91 | header["class_attrib"] = mark_safe(header["class_attrib"].replace('class="', 'class="col-%s ' % field_name)) 92 | else: 93 | header["class_attrib"] = mark_safe(' class="col-%s"' % field_name) 94 | 95 | if "url_primary" in header and "url" not in header: 96 | header["url"] = header["url_primary"] # Django 1.3 template compatibility. 97 | 98 | yield header 99 | 100 | 101 | class ResultListRow(list): 102 | def __init__(self, seq, object): 103 | super().__init__(seq) 104 | self.object = object 105 | 106 | 107 | def stylable_results(cl): 108 | """ 109 | Collect all rows to display 110 | """ 111 | # yield was used for convenience, and kept as is. 112 | if cl.formset: 113 | for res, form in zip(cl.result_list, cl.formset.forms): 114 | yield ResultListRow(stylable_items_for_result(cl, res, form), res) 115 | else: 116 | for res in cl.result_list: 117 | yield ResultListRow(stylable_items_for_result(cl, res, None), res) 118 | 119 | 120 | def stylable_items_for_result(cl, result, form): 121 | """ 122 | Return an iterator which returns all columns to display in the list. 123 | This method is based on items_for_result(), yet completely refactored. 124 | """ 125 | first = True 126 | pk = cl.lookup_opts.pk.attname 127 | 128 | # Read any custom properties 129 | list_column_classes = getattr(cl.model_admin, "list_column_classes", {}) 130 | 131 | # figure out which field to indent 132 | mptt_indent_field = _get_mptt_indent_field(cl, result) 133 | 134 | # Parse all fields to display 135 | for field_name in cl.list_display: 136 | row_attr = "" 137 | 138 | # This is all standard stuff, refactored to separate methods. 139 | result_repr, row_classes = stylable_column_repr(cl, result, field_name) 140 | if force_str(result_repr) == "": 141 | result_repr = mark_safe(" ") 142 | 143 | # Custom stuff, select row classes 144 | if field_name == mptt_indent_field: 145 | level = getattr(result, result._mptt_meta.level_attr) 146 | row_attr += ' style="padding-left:%spx"' % (5 + MPTT_ADMIN_LEVEL_INDENT * level) 147 | 148 | column_class = list_column_classes.get(field_name) 149 | if column_class: 150 | row_classes.append(column_class) 151 | 152 | if row_classes: 153 | row_attr += ' class="%s"' % " ".join(row_classes) 154 | 155 | # Add the link tag to the first field, or use list_display_links if it's defined. 156 | if (first and not cl.list_display_links) or field_name in cl.list_display_links: 157 | table_tag = "th" if first else "td" 158 | first = False 159 | url = cl.url_for_result(result) 160 | 161 | link_attr = "" 162 | if cl.is_popup: 163 | # Convert the pk to something that can be used in Javascript. 164 | # Problem cases are long ints (23L) and non-ASCII strings. 165 | if cl.to_field: 166 | attr = str(cl.to_field) 167 | else: 168 | attr = pk 169 | value = result.serializable_value(attr) 170 | result_id = repr(force_str(value))[1:] 171 | link_attr += ' onclick="opener.dismissRelatedLookupPopup(window, %s); return false;"' % result_id 172 | 173 | yield mark_safe( 174 | '<%s%s>%s' 175 | % (table_tag, row_attr, url, link_attr, conditional_escape(result_repr), table_tag) 176 | ) 177 | else: 178 | # By default the fields come from ModelAdmin.list_editable, 179 | # but if we pull the fields out of the form instead, 180 | # custom ModelAdmin instances can provide fields on a per request basis 181 | if form and field_name in form.fields: 182 | bf = form[field_name] 183 | result_repr = mark_safe(force_str(bf.errors) + force_str(bf)) 184 | else: 185 | result_repr = conditional_escape(result_repr) 186 | 187 | yield mark_safe(f"{result_repr}") 188 | 189 | if form: 190 | yield mark_safe("%s" % force_str(form[cl.model._meta.pk.name])) 191 | 192 | 193 | def _get_mptt_indent_field(cl, result): 194 | """ 195 | Find the first field of the list, it will be indented visually. 196 | Allow working with normal models too. 197 | """ 198 | if not hasattr(result, "_mptt_meta"): 199 | return None 200 | 201 | # Taken from mptt_items_for_result() in mptt/templatetags/mptt_admin.py 202 | mptt_indent_field = None 203 | for field_name in cl.list_display: 204 | try: 205 | cl.lookup_opts.get_field(field_name) 206 | except FieldDoesNotExist: 207 | if mptt_indent_field is None: 208 | attr = getattr(result, field_name, None) 209 | if callable(attr): 210 | # first callable field, use this if we can't find any model fields 211 | mptt_indent_field = field_name 212 | else: 213 | # first model field, use this one 214 | mptt_indent_field = field_name 215 | break 216 | return mptt_indent_field 217 | 218 | 219 | def stylable_column_repr(cl, result, field_name): 220 | """ 221 | Get the string representation for a column item. 222 | This can be a model field, callable or property. 223 | """ 224 | try: 225 | f = cl.lookup_opts.get_field(field_name) 226 | except FieldDoesNotExist: 227 | return _get_non_field_repr(cl, result, field_name) # Field not found (maybe a function) 228 | else: 229 | row_classes = None 230 | value = display_for_field( 231 | getattr(result, f.attname), f, cl.model_admin.get_empty_value_display() 232 | ) # Standard field 233 | if isinstance(f, models.DateField) or isinstance(f, models.TimeField): 234 | row_classes = ["nowrap"] 235 | return value, row_classes 236 | 237 | 238 | def _get_non_field_repr(cl, result, field_name): 239 | """ 240 | Render the visual representation of a column 241 | which does not refer to a field in the model 242 | """ 243 | # For non-field list_display values, the value is either: 244 | # - a method 245 | # - a attribute of the ModelAdmin 246 | # - a property or method of the model. 247 | try: 248 | if callable(field_name): 249 | attr = field_name 250 | value = attr(result) 251 | elif hasattr(cl.model_admin, field_name) and field_name not in ("__str__", "__unicode__"): 252 | attr = getattr(cl.model_admin, field_name) 253 | value = attr(result) 254 | else: 255 | attr = getattr(result, field_name) 256 | if callable(attr): 257 | value = attr() 258 | else: 259 | value = attr 260 | 261 | # Parse special attributes of the item 262 | allow_tags = getattr(attr, "allow_tags", False) 263 | boolean = getattr(attr, "boolean", False) 264 | if boolean: 265 | allow_tags = True 266 | result_repr = _boolean_icon(value) 267 | elif isinstance(value, SafeData): 268 | allow_tags = True 269 | result_repr = value 270 | else: 271 | result_repr = smart_str(value) 272 | 273 | except (AttributeError, ObjectDoesNotExist): 274 | result_repr = cl.model_admin.get_empty_value_display() 275 | else: 276 | # Strip HTML tags in the resulting text, except if the 277 | # function has an "allow_tags" attribute set to True. 278 | if not allow_tags: 279 | result_repr = escape(result_repr) 280 | else: 281 | result_repr = mark_safe(result_repr) 282 | 283 | return result_repr, None 284 | 285 | 286 | # from Django 1.4: 287 | def display_for_field(value, field, empty_value_display): 288 | from django.contrib.admin.templatetags.admin_list import _boolean_icon 289 | 290 | if field.flatchoices: 291 | return dict(field.flatchoices).get(value, empty_value_display) 292 | # NullBooleanField needs special-case null-handling, so it comes 293 | # before the general null test. 294 | elif isinstance(field, models.BooleanField) or isinstance(field, models.NullBooleanField): 295 | return _boolean_icon(value) 296 | elif value is None: 297 | return empty_value_display 298 | elif isinstance(field, models.DateField) or isinstance(field, models.TimeField): 299 | if isinstance(field, models.DateTimeField): 300 | value = timezone.localtime(value) 301 | return formats.localize(value) 302 | elif isinstance(field, models.DecimalField): 303 | return formats.number_format(value, field.decimal_places) 304 | elif isinstance(field, models.FloatField): 305 | return formats.number_format(value) 306 | else: 307 | return smart_str(value) 308 | -------------------------------------------------------------------------------- /polymorphic_tree/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-polymorphic/django-polymorphic-tree/99e92880bfcc7f0992f12e4b35633be2a5171435/polymorphic_tree/tests/__init__.py -------------------------------------------------------------------------------- /polymorphic_tree/tests/admin.py: -------------------------------------------------------------------------------- 1 | from polymorphic_tree.admin import PolymorphicMPTTChildModelAdmin, PolymorphicMPTTParentModelAdmin 2 | from polymorphic_tree.tests.models import ModelWithCustomParentName 3 | 4 | 5 | class BaseChildAdmin(PolymorphicMPTTChildModelAdmin): 6 | """Test child admin""" 7 | 8 | GENERAL_FIELDSET = ( 9 | None, 10 | { 11 | "fields": ("chief", "field5"), 12 | }, 13 | ) 14 | 15 | base_model = ModelWithCustomParentName 16 | base_fieldsets = (GENERAL_FIELDSET,) 17 | 18 | 19 | class TreeNodeParentAdmin(PolymorphicMPTTParentModelAdmin): 20 | """Test parent admin""" 21 | 22 | base_model = ModelWithCustomParentName 23 | child_models = ((ModelWithCustomParentName, BaseChildAdmin),) 24 | 25 | list_display = ( 26 | "field5", 27 | "actions_column", 28 | ) 29 | -------------------------------------------------------------------------------- /polymorphic_tree/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.db import models 3 | from mptt.exceptions import InvalidMove 4 | from polymorphic.showfields import ShowFieldContent 5 | 6 | from polymorphic_tree.models import PolymorphicMPTTModel, PolymorphicTreeForeignKey 7 | 8 | 9 | class PlainA(models.Model): 10 | field1 = models.CharField(max_length=10) 11 | 12 | 13 | class PlainB(PlainA): 14 | field2 = models.CharField(max_length=10) 15 | 16 | 17 | class PlainC(PlainB): 18 | field3 = models.CharField(max_length=10) 19 | 20 | 21 | class Model2A(ShowFieldContent, PolymorphicMPTTModel): 22 | parent = PolymorphicTreeForeignKey( 23 | "self", blank=True, null=True, related_name="children", verbose_name="parent", on_delete=models.CASCADE 24 | ) 25 | field1 = models.CharField(max_length=10) 26 | 27 | 28 | class Model2B(Model2A): 29 | field2 = models.CharField(max_length=10) 30 | 31 | 32 | class Model2C(Model2B): 33 | field3 = models.CharField(max_length=10) 34 | 35 | 36 | class Model2D(Model2C): 37 | field4 = models.CharField(max_length=10) 38 | 39 | 40 | class One2OneRelatingModel(PolymorphicMPTTModel): 41 | parent = PolymorphicTreeForeignKey( 42 | "self", blank=True, null=True, related_name="children", verbose_name="parent", on_delete=models.CASCADE 43 | ) 44 | one2one = models.OneToOneField(Model2A, on_delete=models.CASCADE) 45 | field1 = models.CharField(max_length=10) 46 | 47 | 48 | class One2OneRelatingModelDerived(One2OneRelatingModel): 49 | field2 = models.CharField(max_length=10) 50 | 51 | 52 | class Base(ShowFieldContent, PolymorphicMPTTModel): 53 | parent = PolymorphicTreeForeignKey( 54 | "self", blank=True, null=True, related_name="children", verbose_name="parent", on_delete=models.CASCADE 55 | ) 56 | field_b = models.CharField(max_length=10) 57 | 58 | 59 | class ModelX(Base): 60 | field_x = models.CharField(max_length=10) 61 | 62 | 63 | class ModelY(Base): 64 | field_y = models.CharField(max_length=10) 65 | 66 | 67 | class ModelWithCustomParentName(PolymorphicMPTTModel): 68 | """Model with custom parent name 69 | 70 | A model where ``PolymorphicTreeForeignKey`` attribute has not ``parent`` 71 | name, but ``chief`` 72 | 73 | Attributes: 74 | chief (ModelWithCustomParentName): parent 75 | field5 (str): test field 76 | """ 77 | 78 | chief = PolymorphicTreeForeignKey( 79 | "self", blank=True, null=True, related_name="subordinate", verbose_name="Chief", on_delete=models.CASCADE 80 | ) 81 | field5 = models.CharField(max_length=10) 82 | 83 | class MPTTMeta: 84 | parent_attr = "chief" 85 | 86 | def __str__(self): 87 | return self.field5 88 | 89 | 90 | class ModelWithValidation(PolymorphicMPTTModel): 91 | """Model with custom validation 92 | 93 | A model with redefined ``clean`` and ``validate_move_to`` methods 94 | 95 | ``clean`` method always raises ``ValidationError`` 96 | ``validate_move_to`` always calls ``clean`` 97 | 98 | Attributes: 99 | parent (ModelWithValidation): parent 100 | field6 (str): test field 101 | """ 102 | 103 | parent = PolymorphicTreeForeignKey("self", blank=True, null=True, related_name="children", on_delete=models.CASCADE) 104 | 105 | field6 = models.CharField(max_length=10) 106 | 107 | def clean(self): 108 | """Raise validation error""" 109 | raise ValidationError({"parent": "There is something with parent field"}) 110 | 111 | def validate_move_to(self, target): 112 | """Execute ``clean``""" 113 | self.clean() 114 | 115 | 116 | class ModelWithInvalidMove(PolymorphicMPTTModel): 117 | """Model with custom validation 118 | 119 | A model with redefined only ``validate_move_to`` method which always raises 120 | ``InvalidMove`` 121 | 122 | Attributes: 123 | parent (ModelWithValidation): parent 124 | field7 (str): test field 125 | """ 126 | 127 | parent = PolymorphicTreeForeignKey("self", blank=True, null=True, related_name="children", on_delete=models.CASCADE) 128 | 129 | field7 = models.CharField(max_length=10) 130 | 131 | def validate_move_to(self, target): 132 | """Raise ``InvalidMove``""" 133 | raise InvalidMove("Invalid move") 134 | 135 | 136 | class ModelMustBeChildRoot(PolymorphicMPTTModel): 137 | """Model that must be a child""" 138 | 139 | can_be_root = True 140 | 141 | parent = PolymorphicTreeForeignKey("self", blank=True, null=True, related_name="children", on_delete=models.CASCADE) 142 | field8 = models.CharField(max_length=10) 143 | 144 | 145 | class ModelMustBeChild(ModelMustBeChildRoot): 146 | can_be_root = False 147 | 148 | 149 | class ModelRestrictedChildren(Base): 150 | child_types = [ 151 | ModelX, 152 | ] 153 | -------------------------------------------------------------------------------- /polymorphic_tree/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest import TestCase 3 | from unittest.mock import MagicMock 4 | 5 | from django.contrib.admin import AdminSite 6 | 7 | import polymorphic_tree.templatetags.stylable_admin_list # noqa (only for import testing) 8 | from polymorphic_tree.admin.parentadmin import get_permission_codename 9 | from polymorphic_tree.tests.admin import TreeNodeParentAdmin 10 | from polymorphic_tree.tests.models import Model2A, ModelWithCustomParentName, ModelWithInvalidMove, ModelWithValidation 11 | 12 | 13 | class PolymorphicAdminTests(TestCase): 14 | """Tests for admin""" 15 | 16 | def setUp(self): 17 | self.parent = ModelWithCustomParentName.objects.create(field5="parent") 18 | self.child1 = ModelWithCustomParentName.objects.create(field5="child1", chief=self.parent) 19 | self.child2 = ModelWithCustomParentName.objects.create(field5="child2", chief=self.parent) 20 | self.parent_admin = TreeNodeParentAdmin(ModelWithCustomParentName, AdminSite()) 21 | 22 | self.parent_with_validation = ModelWithValidation.objects.create(field6="parent") 23 | self.child1_with_validation = ModelWithValidation.objects.create( 24 | field6="child1", parent=self.parent_with_validation 25 | ) 26 | self.child2_with_validation = ModelWithValidation.objects.create( 27 | field6="child2", parent=self.parent_with_validation 28 | ) 29 | 30 | self.parent_admin_with_validation = TreeNodeParentAdmin(ModelWithValidation, AdminSite()) 31 | 32 | self.parent_invalid_move = ModelWithInvalidMove.objects.create(field7="parent") 33 | self.child1_invalid_move = ModelWithInvalidMove.objects.create(field7="child1", parent=self.parent_invalid_move) 34 | self.child2_invalid_move = ModelWithInvalidMove.objects.create(field7="child2", parent=self.parent_invalid_move) 35 | 36 | self.parent_admin_invalid_move = TreeNodeParentAdmin(ModelWithInvalidMove, AdminSite()) 37 | 38 | def test_make_child2_child_child1(self): 39 | """Make ``self.child2`` child of ``self.child1``""" 40 | request = MagicMock() 41 | request.POST = { 42 | "moved_id": self.child2.id, 43 | "target_id": self.child1.id, 44 | "previous_parent_id": self.parent.id, 45 | "position": "inside", 46 | } 47 | self.parent_admin.api_node_moved_view(request) 48 | # Analog of self.child2.refresh_from_db() 49 | # This hack used for django 1.7 support 50 | self.child2 = ModelWithCustomParentName.objects.get(pk=self.child2.pk) 51 | self.assertEqual(self.child2.chief, self.child1) 52 | 53 | def test_validation_error(self): 54 | """Ensure that if move can't be performed due validation error, move 55 | can't be performed and json error returned 56 | """ 57 | request = MagicMock() 58 | request.POST = { 59 | "moved_id": self.child2_with_validation.id, 60 | "target_id": self.child1_with_validation.id, 61 | "previous_parent_id": self.parent_with_validation.id, 62 | "position": "inside", 63 | } 64 | 65 | resp = self.parent_admin_with_validation.api_node_moved_view(request) 66 | 67 | self.assertEqual(resp.status_code, 409) 68 | 69 | def test_invalid_move(self): 70 | """Ensure that if move can't be performed due validation error, move 71 | can't be performed and json error returned 72 | """ 73 | request = MagicMock() 74 | request.POST = { 75 | "moved_id": self.child2_invalid_move.id, 76 | "target_id": self.child1_invalid_move.id, 77 | "previous_parent_id": self.parent_invalid_move.id, 78 | "position": "inside", 79 | } 80 | 81 | resp = self.parent_admin_invalid_move.api_node_moved_view(request) 82 | 83 | self.assertEqual(resp.status_code, 409) 84 | 85 | def test_get_permission_codename(self): 86 | # This is to test whether our function works in older Django versions. 87 | self.assertEqual(get_permission_codename("change", Model2A._meta), "change_model2a") 88 | -------------------------------------------------------------------------------- /polymorphic_tree/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from django.test import TestCase 3 | 4 | from polymorphic_tree.managers import PolymorphicMPTTModelManager 5 | 6 | from .models import * 7 | 8 | 9 | class PolymorphicTests(TestCase): 10 | """ 11 | Test Suite, largely derived from django-polymorphic tests 12 | 13 | TODO: potentially port these tests from django_polymorphic.tests 14 | 15 | test_foreignkey_field() 16 | test_onetoone_field() 17 | test_manytomany_field() 18 | test_extra_method() 19 | test_instance_of_filter() 20 | test_polymorphic___filter() 21 | test_delete() 22 | test_combine_querysets() 23 | test_multiple_inheritance() 24 | test_relation_base() 25 | test_user_defined_manager() 26 | test_manager_inheritance() 27 | test_queryset_assignment() 28 | test_proxy_models() 29 | test_proxy_get_real_instance_class() 30 | test_content_types_for_proxy_models() 31 | test_proxy_model_inheritance() 32 | test_custom_pk() 33 | test_fix_getattribute() 34 | test_parent_link_and_related_name() 35 | test_child_type_validation_in_memory() 36 | 37 | """ 38 | 39 | def create_model2abcd(self): 40 | """ 41 | Create the chain of objects of Model2, 42 | this is reused in various tests. 43 | """ 44 | Model2A.objects.create(field1="A1") 45 | Model2B.objects.create(field1="B1", field2="B2") 46 | Model2C.objects.create(field1="C1", field2="C2", field3="C3") 47 | Model2D.objects.create(field1="D1", field2="D2", field3="D3", field4="D4") 48 | 49 | def test_simple_inheritance(self): 50 | self.create_model2abcd() 51 | 52 | objects = list(Model2A.objects.all()) 53 | self.assertEqual( 54 | repr(objects[0]), '' 55 | ) 56 | self.assertEqual( 57 | repr(objects[1]), 58 | '', 59 | ) 60 | self.assertEqual( 61 | repr(objects[2]), 62 | '', 63 | ) 64 | self.assertEqual( 65 | repr(objects[3]), 66 | '', 67 | ) 68 | 69 | def test_manual_get_real_instance(self): 70 | self.create_model2abcd() 71 | 72 | o = Model2A.objects.non_polymorphic().get(field1="C1") 73 | self.assertEqual( 74 | repr(o.get_real_instance()), 75 | '', 76 | ) 77 | 78 | def test_non_polymorphic(self): 79 | self.create_model2abcd() 80 | 81 | objects = list(Model2A.objects.all().non_polymorphic()) 82 | self.assertEqual( 83 | repr(objects[0]), '' 84 | ) 85 | self.assertEqual( 86 | repr(objects[1]), '' 87 | ) 88 | self.assertEqual( 89 | repr(objects[2]), '' 90 | ) 91 | self.assertEqual( 92 | repr(objects[3]), '' 93 | ) 94 | 95 | def test_get_real_instances(self): 96 | self.create_model2abcd() 97 | qs = Model2A.objects.all().non_polymorphic() 98 | 99 | # from queryset 100 | objects = qs.get_real_instances() 101 | self.assertEqual( 102 | repr(objects[0]), '' 103 | ) 104 | self.assertEqual( 105 | repr(objects[1]), 106 | '', 107 | ) 108 | self.assertEqual( 109 | repr(objects[2]), 110 | '', 111 | ) 112 | self.assertEqual( 113 | repr(objects[3]), 114 | '', 115 | ) 116 | 117 | # from a manual list 118 | objects = Model2A.objects.get_real_instances(list(qs)) 119 | self.assertEqual( 120 | repr(objects[0]), '' 121 | ) 122 | self.assertEqual( 123 | repr(objects[1]), 124 | '', 125 | ) 126 | self.assertEqual( 127 | repr(objects[2]), 128 | '', 129 | ) 130 | self.assertEqual( 131 | repr(objects[3]), 132 | '', 133 | ) 134 | 135 | def test_translate_polymorphic_q_object(self): 136 | self.create_model2abcd() 137 | 138 | q = Model2A.translate_polymorphic_Q_object(Q(instance_of=Model2C)) 139 | objects = Model2A.objects.filter(q) 140 | self.assertEqual( 141 | repr(objects[0]), 142 | '', 143 | ) 144 | self.assertEqual( 145 | repr(objects[1]), 146 | '', 147 | ) 148 | 149 | def test_base_manager(self): 150 | def base_manager(model): 151 | return (type(model._base_manager), model._base_manager.model) 152 | 153 | self.assertEqual(base_manager(PlainA), (models.Manager, PlainA)) 154 | self.assertEqual(base_manager(PlainB), (models.Manager, PlainB)) 155 | self.assertEqual(base_manager(PlainC), (models.Manager, PlainC)) 156 | 157 | # Unlike standard polymorphic, the manager persists everywhere. 158 | # This makes sure that the features of MPTT are also available everywhere. 159 | self.assertEqual(base_manager(Model2A), (PolymorphicMPTTModelManager, Model2A)) 160 | self.assertEqual(base_manager(Model2B), (PolymorphicMPTTModelManager, Model2B)) 161 | self.assertEqual(base_manager(Model2C), (PolymorphicMPTTModelManager, Model2C)) 162 | 163 | self.assertEqual(base_manager(One2OneRelatingModel), (PolymorphicMPTTModelManager, One2OneRelatingModel)) 164 | self.assertEqual( 165 | base_manager(One2OneRelatingModelDerived), (PolymorphicMPTTModelManager, One2OneRelatingModelDerived) 166 | ) 167 | 168 | def test_instance_default_manager(self): 169 | def show_default_manager(instance): 170 | return "{} {}".format(repr(type(instance.__class__.objects)), repr(instance.__class__.objects.model)) 171 | 172 | plain_a = PlainA(field1="C1") 173 | plain_b = PlainB(field2="C1") 174 | plain_c = PlainC(field3="C1") 175 | 176 | model_2a = Model2A(field1="C1") 177 | model_2b = Model2B(field2="C1") 178 | model_2c = Model2C(field3="C1") 179 | 180 | self.assertEqual( 181 | show_default_manager(plain_a), 182 | " ", 183 | ) 184 | self.assertEqual( 185 | show_default_manager(plain_b), 186 | " ", 187 | ) 188 | self.assertEqual( 189 | show_default_manager(plain_c), 190 | " ", 191 | ) 192 | 193 | self.assertEqual( 194 | show_default_manager(model_2a), 195 | " ", 196 | ) 197 | self.assertEqual( 198 | show_default_manager(model_2b), 199 | " ", 200 | ) 201 | self.assertEqual( 202 | show_default_manager(model_2c), 203 | " ", 204 | ) 205 | 206 | 207 | class MPTTTests(TestCase): 208 | """ 209 | Tests relating to tree structure of polymorphic objects 210 | 211 | TODO: port some tests from https://github.com/django-mptt/django-mptt/blob/master/tests/myapp/tests.py 212 | """ 213 | 214 | def test_sibling_methods(self): 215 | """https://github.com/edoburu/django-polymorphic-tree/issues/37""" 216 | root_node = Base.objects.create(field_b="root") 217 | sibling_a = Base.objects.create(field_b="first", parent=root_node) 218 | sibling_b = ModelX.objects.create(field_b="second", field_x="ModelX", parent=root_node) 219 | sibling_c = ModelY.objects.create(field_b="third", field_y="ModelY", parent=root_node) 220 | 221 | # sanity checks 222 | self.assertEqual(list(root_node.get_descendants()), [sibling_a, sibling_b, sibling_c]) 223 | self.assertEqual(list(sibling_a.get_siblings()), [sibling_b, sibling_c]) 224 | self.assertEqual(list(sibling_b.get_siblings()), [sibling_a, sibling_c]) 225 | self.assertEqual(list(sibling_c.get_siblings()), [sibling_a, sibling_b]) 226 | 227 | # When looking for siblings, it should be done from the base model, 228 | # not and not the child model type (which may not find all instances) 229 | self.assertEqual(sibling_a.get_previous_sibling(), None) 230 | self.assertEqual(sibling_a.get_next_sibling(), sibling_b) 231 | 232 | self.assertEqual(sibling_b.get_previous_sibling(), sibling_a) 233 | self.assertEqual(sibling_b.get_next_sibling(), sibling_c) 234 | 235 | self.assertEqual(sibling_c.get_previous_sibling(), sibling_b) 236 | self.assertEqual(sibling_c.get_next_sibling(), None) 237 | 238 | def test_get_ancestors(self): 239 | """https://github.com/edoburu/django-polymorphic-tree/issues/32""" 240 | root_node = Base.objects.create(field_b="root") 241 | child = ModelX.objects.create(field_b="child", field_x="ModelX", parent=root_node) 242 | grandchild = ModelY.objects.create(field_b="grandchild", field_y="ModelY", parent=child) 243 | 244 | self.assertEqual(list(root_node.get_ancestors()), []) 245 | self.assertEqual(list(child.get_ancestors()), [root_node]) 246 | self.assertEqual(list(grandchild.get_ancestors()), [root_node, child]) 247 | 248 | self.assertEqual(list(root_node.get_ancestors(include_self=True)), [root_node]) 249 | self.assertEqual(list(child.get_ancestors(include_self=True)), [root_node, child]) 250 | self.assertEqual(list(grandchild.get_ancestors(include_self=True)), [root_node, child, grandchild]) 251 | 252 | self.assertEqual(list(root_node.get_ancestors(ascending=True)), []) 253 | self.assertEqual(list(child.get_ancestors(ascending=True)), [root_node]) 254 | self.assertEqual(list(grandchild.get_ancestors(ascending=True)), [child, root_node]) 255 | 256 | def test_is_ancestor_of(self): 257 | root_node = Base.objects.create(field_b="root") 258 | child = ModelX.objects.create(field_b="child", field_x="ModelX", parent=root_node) 259 | grandchild = ModelY.objects.create(field_b="grandchild", field_y="ModelY", parent=child) 260 | 261 | self.assertTrue(root_node.is_ancestor_of(child)) 262 | self.assertTrue(root_node.is_ancestor_of(grandchild)) 263 | self.assertFalse(child.is_ancestor_of(root_node)) 264 | self.assertTrue(child.is_ancestor_of(grandchild)) 265 | self.assertFalse(grandchild.is_ancestor_of(child)) 266 | self.assertFalse(grandchild.is_ancestor_of(root_node)) 267 | 268 | def test_node_type_checking(self): 269 | root_node = Base.objects.create(field_b="root") 270 | child = ModelX.objects.create(field_b="child", field_x="ModelX", parent=root_node) 271 | grandchild = ModelY.objects.create(field_b="grandchild", field_y="ModelY", parent=child) 272 | 273 | self.assertFalse(root_node.is_child_node()) 274 | self.assertFalse(root_node.is_leaf_node()) 275 | self.assertTrue(root_node.is_root_node()) 276 | 277 | self.assertTrue(child.is_child_node()) 278 | self.assertFalse(child.is_leaf_node()) 279 | self.assertFalse(child.is_root_node()) 280 | 281 | self.assertTrue(grandchild.is_child_node()) 282 | self.assertTrue(grandchild.is_leaf_node()) 283 | self.assertFalse(grandchild.is_root_node()) 284 | 285 | def test_child_type_validation_in_memory(self): 286 | root_node = ModelRestrictedChildren.objects.create(field_b="root") 287 | 288 | valid_child = ModelX(field_b="valid_child", field_x="ModelX", parent=root_node) 289 | valid_child.clean() 290 | 291 | with self.assertRaises(ValidationError) as context: 292 | invalid_child = ModelY(field_b="invalid_child", field_y="ModelY", parent=root_node) 293 | invalid_child.clean() 294 | 295 | msg = context.exception.args[0]["parent"] 296 | self.assertIn("a model restricted children does not allow model y as a child!", msg) 297 | 298 | def test_tree_manager(self): 299 | # Having the tree manager correct is absolutely essential, 300 | # so our move validation is also triggered. 301 | self.assertIsInstance(Model2A()._tree_manager, PolymorphicMPTTModelManager) 302 | self.assertIsInstance(Model2B()._tree_manager, PolymorphicMPTTModelManager) 303 | self.assertIsInstance(Model2C()._tree_manager, PolymorphicMPTTModelManager) 304 | 305 | def test_can_be_root(self): 306 | node = ModelMustBeChild(field8="foo") 307 | self.assertRaisesMessage(ValidationError, "This node type should have a parent", lambda: node.clean()) 308 | 309 | parent = ModelMustBeChildRoot(field8="test") 310 | parent.clean() 311 | parent.save() 312 | node.parent = parent 313 | node.clean() 314 | node.save() 315 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | line_length = 120 4 | 5 | [tool.black] 6 | line-length = 120 7 | exclude = ''' 8 | /( 9 | \.git 10 | | \.tox 11 | | \.venv 12 | | dist 13 | )/ 14 | ''' 15 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python -Wd 2 | import sys 3 | import warnings 4 | from os import path 5 | 6 | import django 7 | from django.conf import global_settings as default_settings 8 | from django.conf import settings 9 | from django.core.management import execute_from_command_line 10 | 11 | # python -Wd, or run via coverage: 12 | warnings.simplefilter("always", DeprecationWarning) 13 | 14 | # Give feedback on used versions 15 | sys.stderr.write(f"Using Python version {sys.version[:5]} from {sys.executable}\n") 16 | sys.stderr.write( 17 | "Using Django version {} from {}\n".format(django.get_version(), path.dirname(path.abspath(django.__file__))) 18 | ) 19 | 20 | if not settings.configured: 21 | module_root = path.dirname(path.realpath(__file__)) 22 | 23 | sys.path.insert(0, path.join(module_root, "example")) 24 | 25 | settings.configure( 26 | DEBUG=False, # will be False anyway by DjangoTestRunner. 27 | DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}, 28 | DEFAULT_AUTO_FIELD="django.db.models.AutoField", 29 | CACHES={ 30 | # By explicit since many tests also need the caching support 31 | "default": { 32 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 33 | "LOCATION": "unique-snowflake", 34 | } 35 | }, 36 | SECRET_KEY="testtest", 37 | TEMPLATES=[ 38 | { 39 | "BACKEND": "django.template.backends.django.DjangoTemplates", 40 | "DIRS": (), 41 | "OPTIONS": { 42 | "debug": True, 43 | "loaders": ( 44 | "django.template.loaders.filesystem.Loader", 45 | "django.template.loaders.app_directories.Loader", 46 | ), 47 | "context_processors": ( 48 | "django.template.context_processors.debug", 49 | "django.template.context_processors.i18n", 50 | "django.template.context_processors.media", 51 | "django.template.context_processors.request", 52 | "django.template.context_processors.static", 53 | "django.contrib.auth.context_processors.auth", 54 | "django.contrib.messages.context_processors.messages", 55 | ), 56 | }, 57 | }, 58 | ], 59 | INSTALLED_APPS=( 60 | "django.contrib.admin", 61 | "django.contrib.auth", 62 | "django.contrib.contenttypes", 63 | "django.contrib.messages", 64 | "django.contrib.sites", 65 | "mptt", 66 | "polymorphic", 67 | "polymorphic_tree", 68 | "polymorphic_tree.tests", 69 | ), 70 | # we define MIDDLEWARE_CLASSES explicitly, the default were changed in django 1.7 71 | MIDDLEWARE=( 72 | "django.contrib.sessions.middleware.SessionMiddleware", 73 | "django.contrib.auth.middleware.AuthenticationMiddleware", 74 | "django.contrib.messages.middleware.MessageMiddleware", 75 | "django.middleware.locale.LocaleMiddleware", # / will be redirected to // 76 | ), 77 | ROOT_URLCONF="example.urls", 78 | TEST_RUNNER="django.test.runner.DiscoverRunner", 79 | ) 80 | 81 | 82 | DEFAULT_TEST_APPS = [ 83 | "polymorphic_tree", 84 | ] 85 | 86 | 87 | def runtests(): 88 | other_args = list(filter(lambda arg: arg.startswith("-"), sys.argv[1:])) 89 | test_apps = list(filter(lambda arg: not arg.startswith("-"), sys.argv[1:])) or DEFAULT_TEST_APPS 90 | argv = sys.argv[:1] + ["test", "--traceback"] + other_args + test_apps 91 | execute_from_command_line(argv) 92 | 93 | 94 | if __name__ == "__main__": 95 | runtests() 96 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import codecs 3 | import os 4 | import re 5 | import sys 6 | from os import path 7 | 8 | from setuptools import find_packages, setup 9 | 10 | # When creating the sdist, make sure the django.mo file also exists: 11 | if "sdist" in sys.argv or "develop" in sys.argv: 12 | os.chdir("polymorphic_tree") 13 | try: 14 | from django.core import management 15 | 16 | management.call_command("compilemessages", stdout=sys.stderr, verbosity=1) 17 | except ImportError: 18 | if "sdist" in sys.argv: 19 | raise 20 | finally: 21 | os.chdir("..") 22 | 23 | 24 | def read(*parts): 25 | file_path = path.join(path.dirname(__file__), *parts) 26 | return codecs.open(file_path, encoding="utf-8").read() 27 | 28 | 29 | def find_version(*parts): 30 | version_file = read(*parts) 31 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) 32 | if version_match: 33 | return str(version_match.group(1)) 34 | raise RuntimeError("Unable to find version string.") 35 | 36 | 37 | setup( 38 | name="django-polymorphic-tree", 39 | version=find_version("polymorphic_tree", "__init__.py"), 40 | license="Apache 2.0", 41 | install_requires=[ 42 | "django-polymorphic>=3", 43 | "django-mptt>=0.9.1", 44 | "django-tag-parser>=2.1", 45 | ], 46 | requires=[ 47 | "Django (>=2.1)", 48 | ], 49 | description="A polymorphic mptt structure to display content in a tree.", 50 | long_description=read("README.rst"), 51 | author="Diederik van der Boor", 52 | author_email="opensource@edoburu.nl", 53 | url="https://github.com/django-polymorphic/django-polymorphic-tree", 54 | download_url="https://github.com/django-polymorphic/django-polymorphic-tree/zipball/master", 55 | packages=find_packages(), 56 | include_package_data=True, 57 | zip_safe=False, 58 | classifiers=[ 59 | "Development Status :: 5 - Production/Stable", 60 | "Environment :: Web Environment", 61 | "Framework :: Django", 62 | "Intended Audience :: Developers", 63 | "License :: OSI Approved :: Apache Software License", 64 | "Operating System :: OS Independent", 65 | "Programming Language :: Python", 66 | "Programming Language :: Python :: 3.7", 67 | "Programming Language :: Python :: 3.8", 68 | "Programming Language :: Python :: 3.9", 69 | "Framework :: Django", 70 | "Framework :: Django :: 2.2", 71 | "Framework :: Django :: 3.0", 72 | "Framework :: Django :: 3.1", 73 | "Topic :: Internet :: WWW/HTTP", 74 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 75 | "Topic :: Software Development :: Libraries :: Python Modules", 76 | ], 77 | ) 78 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist= 3 | py37-django{21,22,30,31}, 4 | ; docs, 5 | 6 | [testenv] 7 | deps = 8 | django-polymorphic >= 3.0 9 | django-mptt >= 0.9.0 10 | django21: Django ~= 2.1 11 | django22: Django ~= 2.2 12 | django30: Django ~= 3.0 13 | django31: Django ~= 3.1 14 | ; django-dev: https://github.com/django/django/tarball/master 15 | commands= 16 | python runtests.py 17 | 18 | ; Have no configuration for sphinx in project repository 19 | ;[testenv:docs] 20 | ;deps=Sphinx 21 | ;changedir = docs 22 | ;commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 23 | --------------------------------------------------------------------------------