././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9066186 django-treebeard-4.7.1/0000755000175100001770000000000014556473276014350 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/LICENSE0000644000175100001770000002613714556473264015363 0ustar00runnerdocker Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/MANIFEST.in0000644000175100001770000000042614556473264016105 0ustar00runnerdockerinclude CHANGES LICENSE NOTICE README.rst UPDATING MANIFEST.in recursive-include treebeard *.py recursive-include treebeard/static * recursive-include treebeard/templates * recursive-include treebeard/locale * recursive-include docs Makefile README.md make.bat *.py *.png *.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/NOTICE0000644000175100001770000000000014556473264015237 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9066186 django-treebeard-4.7.1/PKG-INFO0000644000175100001770000000523114556473276015446 0ustar00runnerdockerMetadata-Version: 2.1 Name: django-treebeard Version: 4.7.1 Summary: Efficient tree implementations for Django Home-page: https://github.com/django-treebeard/django-treebeard/ Author: Gustavo Picon Author-email: tabo@tabo.pe License: Apache License 2.0 Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: Apache Software License Classifier: Environment :: Web Environment Classifier: Framework :: Django Classifier: Framework :: Django :: 3.2 Classifier: Framework :: Django :: 4.1 Classifier: Framework :: Django :: 4.2 Classifier: Framework :: Django :: 5.0 Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Operating System :: OS Independent Classifier: Topic :: Software Development :: Libraries Classifier: Topic :: Utilities Requires-Python: >=3.8 Description-Content-Type: text/markdown License-File: LICENSE Requires-Dist: Django>=3.2 # django-treebeard **django-treebeard** is a library that implements efficient tree implementations for the Django Web Framework 2.2 and later. It is written by Gustavo Picón and licensed under the Apache License 2.0. ## Status [![Documentation Status](https://readthedocs.org/projects/django-treebeard/badge/?version=latest)](https://django-treebeard.readthedocs.io/en/latest/?badge=latest) [![Tests](https://github.com/django-treebeard/django-treebeard/workflows/Tests/badge.svg)]() [![App Veyor](https://ci.appveyor.com/api/projects/status/mwbf062v68lhw05c?svg=true)](https://ci.appveyor.com/project/mvantellingen/django-treebeard) [![PyPI](https://img.shields.io/pypi/pyversions/django-treebeard.svg)]() [![PyPI version](https://img.shields.io/pypi/v/django-treebeard.svg)](https://pypi.org/project/django-treebeard/) ## Features django-treebeard is: - **Flexible**: Includes 3 different tree implementations with the same API: 1. Adjacency List 2. Materialized Path 3. Nested Sets - **Fast**: Optimized non-naive tree operations - **Easy**: Uses Django Model Inheritance with abstract classes to define your own models. - **Clean**: Testable and well tested code base. Code/branch test coverage is above 96%. You can find the documentation in > ### Supported versions **django-treebeard** officially supports - Django 3.2, 4.1, 4.2, 5.0 - Python 3.8 - 3.12 - PostgreSQL, MySQL, MSSQL, SQLite database back-ends. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/README.md0000644000175100001770000000276414556473264015635 0ustar00runnerdocker# django-treebeard **django-treebeard** is a library that implements efficient tree implementations for the Django Web Framework 2.2 and later. It is written by Gustavo Picón and licensed under the Apache License 2.0. ## Status [![Documentation Status](https://readthedocs.org/projects/django-treebeard/badge/?version=latest)](https://django-treebeard.readthedocs.io/en/latest/?badge=latest) [![Tests](https://github.com/django-treebeard/django-treebeard/workflows/Tests/badge.svg)]() [![App Veyor](https://ci.appveyor.com/api/projects/status/mwbf062v68lhw05c?svg=true)](https://ci.appveyor.com/project/mvantellingen/django-treebeard) [![PyPI](https://img.shields.io/pypi/pyversions/django-treebeard.svg)]() [![PyPI version](https://img.shields.io/pypi/v/django-treebeard.svg)](https://pypi.org/project/django-treebeard/) ## Features django-treebeard is: - **Flexible**: Includes 3 different tree implementations with the same API: 1. Adjacency List 2. Materialized Path 3. Nested Sets - **Fast**: Optimized non-naive tree operations - **Easy**: Uses Django Model Inheritance with abstract classes to define your own models. - **Clean**: Testable and well tested code base. Code/branch test coverage is above 96%. You can find the documentation in > ### Supported versions **django-treebeard** officially supports - Django 3.2, 4.1, 4.2, 5.0 - Python 3.8 - 3.12 - PostgreSQL, MySQL, MSSQL, SQLite database back-ends. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/UPDATING0000644000175100001770000000074214556473264015506 0ustar00runnerdockerThis file documents problems you may encounter when upgrading django-treebeard (potential backward incompatible changes). 20081117: Cleaned __init__.py, if you need Node you'll have to call it from it's original location (treebeard.models.Node instead of treebeard.Node). Also exceptions have been moved to treebeard.exceptions. 20100316: Queryset ordering in NS/MP trees is now enforced by the library. Previous ordering settings in META no longer work. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9066186 django-treebeard-4.7.1/django_treebeard.egg-info/0000755000175100001770000000000014556473276021321 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718909.0 django-treebeard-4.7.1/django_treebeard.egg-info/PKG-INFO0000644000175100001770000000523114556473275022416 0ustar00runnerdockerMetadata-Version: 2.1 Name: django-treebeard Version: 4.7.1 Summary: Efficient tree implementations for Django Home-page: https://github.com/django-treebeard/django-treebeard/ Author: Gustavo Picon Author-email: tabo@tabo.pe License: Apache License 2.0 Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: Apache Software License Classifier: Environment :: Web Environment Classifier: Framework :: Django Classifier: Framework :: Django :: 3.2 Classifier: Framework :: Django :: 4.1 Classifier: Framework :: Django :: 4.2 Classifier: Framework :: Django :: 5.0 Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Operating System :: OS Independent Classifier: Topic :: Software Development :: Libraries Classifier: Topic :: Utilities Requires-Python: >=3.8 Description-Content-Type: text/markdown License-File: LICENSE Requires-Dist: Django>=3.2 # django-treebeard **django-treebeard** is a library that implements efficient tree implementations for the Django Web Framework 2.2 and later. It is written by Gustavo Picón and licensed under the Apache License 2.0. ## Status [![Documentation Status](https://readthedocs.org/projects/django-treebeard/badge/?version=latest)](https://django-treebeard.readthedocs.io/en/latest/?badge=latest) [![Tests](https://github.com/django-treebeard/django-treebeard/workflows/Tests/badge.svg)]() [![App Veyor](https://ci.appveyor.com/api/projects/status/mwbf062v68lhw05c?svg=true)](https://ci.appveyor.com/project/mvantellingen/django-treebeard) [![PyPI](https://img.shields.io/pypi/pyversions/django-treebeard.svg)]() [![PyPI version](https://img.shields.io/pypi/v/django-treebeard.svg)](https://pypi.org/project/django-treebeard/) ## Features django-treebeard is: - **Flexible**: Includes 3 different tree implementations with the same API: 1. Adjacency List 2. Materialized Path 3. Nested Sets - **Fast**: Optimized non-naive tree operations - **Easy**: Uses Django Model Inheritance with abstract classes to define your own models. - **Clean**: Testable and well tested code base. Code/branch test coverage is above 96%. You can find the documentation in > ### Supported versions **django-treebeard** officially supports - Django 3.2, 4.1, 4.2, 5.0 - Python 3.8 - 3.12 - PostgreSQL, MySQL, MSSQL, SQLite database back-ends. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718909.0 django-treebeard-4.7.1/django_treebeard.egg-info/SOURCES.txt0000644000175100001770000000543414556473275023212 0ustar00runnerdockerLICENSE MANIFEST.in NOTICE README.md UPDATING setup.cfg setup.py django_treebeard.egg-info/PKG-INFO django_treebeard.egg-info/SOURCES.txt django_treebeard.egg-info/dependency_links.txt django_treebeard.egg-info/requires.txt django_treebeard.egg-info/top_level.txt docs/Makefile docs/README.md docs/make.bat docs/source/admin.rst docs/source/al_tree.rst docs/source/api.rst docs/source/caveats.rst docs/source/changes.rst docs/source/conf.py docs/source/exceptions.rst docs/source/forms.rst docs/source/index.rst docs/source/install.rst docs/source/mp_tree.rst docs/source/ns_tree.rst docs/source/tests.rst docs/source/tutorial.rst docs/source/_ext/djangodocs.py docs/source/_static/treebeard-admin-advanced.png docs/source/_static/treebeard-admin-basic.png treebeard/__init__.py treebeard/admin.py treebeard/al_tree.py treebeard/exceptions.py treebeard/forms.py treebeard/models.py treebeard/mp_tree.py treebeard/ns_tree.py treebeard/numconv.py treebeard/locale/de/LC_MESSAGES/django.mo treebeard/locale/de/LC_MESSAGES/django.po treebeard/locale/de/LC_MESSAGES/djangojs.mo treebeard/locale/de/LC_MESSAGES/djangojs.po treebeard/locale/es/LC_MESSAGES/django.mo treebeard/locale/es/LC_MESSAGES/django.po treebeard/locale/es/LC_MESSAGES/djangojs.mo treebeard/locale/es/LC_MESSAGES/djangojs.po treebeard/locale/fr/LC_MESSAGES/django.mo treebeard/locale/fr/LC_MESSAGES/django.po treebeard/locale/fr/LC_MESSAGES/djangojs.mo treebeard/locale/fr/LC_MESSAGES/djangojs.po treebeard/locale/hu/LC_MESSAGES/django.mo treebeard/locale/hu/LC_MESSAGES/django.po treebeard/locale/hu/LC_MESSAGES/djangojs.mo treebeard/locale/hu/LC_MESSAGES/djangojs.po treebeard/locale/nl/LC_MESSAGES/django.mo treebeard/locale/nl/LC_MESSAGES/django.po treebeard/locale/nl/LC_MESSAGES/djangojs.mo treebeard/locale/nl/LC_MESSAGES/djangojs.po treebeard/locale/pl/LC_MESSAGES/django.mo treebeard/locale/pl/LC_MESSAGES/django.po treebeard/locale/ru/LC_MESSAGES/django.mo treebeard/locale/ru/LC_MESSAGES/django.po treebeard/locale/ru/LC_MESSAGES/djangojs.mo treebeard/locale/ru/LC_MESSAGES/djangojs.po treebeard/static/treebeard/expand-collapse.png treebeard/static/treebeard/treebeard-admin.css treebeard/static/treebeard/treebeard-admin.js treebeard/templates/admin/tree_change_list.html treebeard/templates/admin/tree_change_list_results.html treebeard/templates/admin/tree_list.html treebeard/templates/admin/tree_list_results.html treebeard/templatetags/__init__.py treebeard/templatetags/admin_tree.py treebeard/templatetags/admin_tree_list.py treebeard/tests/__init__.py treebeard/tests/admin.py treebeard/tests/conftest.py treebeard/tests/manage.py treebeard/tests/models.py treebeard/tests/settings.py treebeard/tests/test_migrations.py treebeard/tests/test_treebeard.py treebeard/tests/urls.py treebeard/tests/migrations/0001_initial.py treebeard/tests/migrations/__init__.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718909.0 django-treebeard-4.7.1/django_treebeard.egg-info/dependency_links.txt0000644000175100001770000000000114556473275025366 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718909.0 django-treebeard-4.7.1/django_treebeard.egg-info/requires.txt0000644000175100001770000000001414556473275023713 0ustar00runnerdockerDjango>=3.2 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718909.0 django-treebeard-4.7.1/django_treebeard.egg-info/top_level.txt0000644000175100001770000000001214556473275024043 0ustar00runnerdockertreebeard ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8946185 django-treebeard-4.7.1/docs/0000755000175100001770000000000014556473276015300 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/Makefile0000644000175100001770000001644214556473264016744 0ustar00runnerdocker# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " applehelp to make an Apple Help Book" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" @echo " coverage to run coverage check of the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-treebeard.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-treebeard.qhc" applehelp: $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp @echo @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." @echo "N.B. You won't be able to view it unless you put it in" \ "~/Library/Documentation/Help or install it in your application" \ "bundle." devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/django-treebeard" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-treebeard" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." coverage: $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage @echo "Testing of coverage in the sources finished, look at the " \ "results in $(BUILDDIR)/coverage/python.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/README.md0000644000175100001770000000100614556473264016551 0ustar00runnerdockerThis is the documentation source for django-treebeard. You can read the documentation on: http://django-treebeard.readthedocs.io/en/latest/ Or create the documentation yourself by compiling the ReStructured Text files: If you want to build the docs you'll need the graphviz tool, if you are using a Mac and Brew you can install it like this: $ brew install graphviz Then you'll need at least Django and Sphinx: $ pip install Django $ pip install Sphinx To build the docs run: ```bash $ cd docs/ $ make html ```././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/make.bat0000644000175100001770000001615114556473264016706 0ustar00runnerdocker@ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source set I18NSPHINXOPTS=%SPHINXOPTS% source if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. texinfo to make Texinfo files echo. gettext to make PO message catalogs echo. changes to make an overview over all changed/added/deprecated items echo. xml to make Docutils-native XML files echo. pseudoxml to make pseudoxml-XML files for display purposes echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled echo. coverage to run coverage check of the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* goto end ) REM Check if sphinx-build is available and fallback to Python version if any %SPHINXBUILD% 2> nul if errorlevel 9009 goto sphinx_python goto sphinx_ok :sphinx_python set SPHINXBUILD=python -m sphinx.__init__ %SPHINXBUILD% 2> nul if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) :sphinx_ok if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-treebeard.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-treebeard.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdf" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf cd %~dp0 echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdfja" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf-ja cd %~dp0 echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "texinfo" ( %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo if errorlevel 1 exit /b 1 echo. echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. goto end ) if "%1" == "gettext" ( %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale if errorlevel 1 exit /b 1 echo. echo.Build finished. The message catalogs are in %BUILDDIR%/locale. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) if "%1" == "coverage" ( %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage if errorlevel 1 exit /b 1 echo. echo.Testing of coverage in the sources finished, look at the ^ results in %BUILDDIR%/coverage/python.txt. goto end ) if "%1" == "xml" ( %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml if errorlevel 1 exit /b 1 echo. echo.Build finished. The XML files are in %BUILDDIR%/xml. goto end ) if "%1" == "pseudoxml" ( %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml if errorlevel 1 exit /b 1 echo. echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. goto end ) :end ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8986187 django-treebeard-4.7.1/docs/source/0000755000175100001770000000000014556473276016600 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8986187 django-treebeard-4.7.1/docs/source/_ext/0000755000175100001770000000000014556473276017537 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/_ext/djangodocs.py0000644000175100001770000000036014556473264022220 0ustar00runnerdocker# taken from: # http://reinout.vanrees.org/weblog/2012/12/01/django-intersphinx.html def setup(app): app.add_crossref_type( directivename="setting", rolename="setting", indextemplate="pair: %s; setting", ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8986187 django-treebeard-4.7.1/docs/source/_static/0000755000175100001770000000000014556473276020226 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/_static/treebeard-admin-advanced.png0000644000175100001770000034346314556473264025534 0ustar00runnerdockerPNG  IHDRa AiCCPICC ProfileH wTSϽ7" %z ;HQIP&vDF)VdTG"cE b PQDE݌k 5ޚYg}׺PtX4X\XffGD=HƳ.d,P&s"7C$ E6<~&S2)212 "įl+ɘ&Y4Pޚ%ᣌ\%g|eTI(L0_&l2E9r9hxgIbטifSb1+MxL 0oE%YmhYh~S=zU&ϞAYl/$ZUm@O ޜl^ ' lsk.+7oʿ9V;?#I3eE妧KD d9i,UQ h A1vjpԁzN6p\W p G@ K0ށiABZyCAP8C@&*CP=#t] 4}a ٰ;GDxJ>,_“@FXDBX$!k"EHqaYbVabJ0՘cVL6f3bձX'?v 6-V``[a;p~\2n5׌ &x*sb|! ߏƿ' Zk! $l$T4QOt"y\b)AI&NI$R$)TIj"]&=&!:dGrY@^O$ _%?P(&OJEBN9J@y@yCR nXZOD}J}/G3ɭk{%Oחw_.'_!JQ@SVF=IEbbbb5Q%O@%!BӥyҸM:e0G7ӓ e%e[(R0`3R46i^)*n*|"fLUo՝mO0j&jajj.ϧwϝ_4갺zj=U45nɚ4ǴhZ ZZ^0Tf%9->ݫ=cXgN].[7A\SwBOK/X/_Q>QG[ `Aaac#*Z;8cq>[&IIMST`ϴ kh&45ǢYYF֠9<|y+ =X_,,S-,Y)YXmĚk]c}džjcΦ浭-v};]N"&1=xtv(}'{'IߝY) Σ -rqr.d._xpUەZM׍vm=+KGǔ ^WWbj>:>>>v}/avO8 FV> 2 u/_$\BCv< 5 ]s.,4&yUx~xw-bEDCĻHGKwFGEGME{EEKX,YFZ ={$vrK .3\rϮ_Yq*©L_wד+]eD]cIIIOAu_䩔)3ѩiB%a+]3='/40CiU@ёL(sYfLH$%Y jgGeQn~5f5wugv5k֮\۹Nw]m mHFˍenQQ`hBBQ-[lllfjۗ"^bO%ܒY}WwvwXbY^Ю]WVa[q`id2JjGէ{׿m>PkAma꺿g_DHGGu;776ƱqoC{P38!9 ҝˁ^r۽Ug9];}}_~imp㭎}]/}.{^=}^?z8hc' O*?f`ϳgC/Oϩ+FFGGόzˌㅿ)ѫ~wgbk?Jި9mdwi獵ޫ?cǑOO?w| x&mf2:Y~ pHYs  0iTXtXML:com.adobe.xmp Acorn version 4.2.2 5 72 72 6@IDATx]xG~C !H@pwS8(RԿ_T(Rݝwޛ~<9sٙsΜ+K_G#p8G#pG #Zp8G#pG#p8WMp8G#pG#p8WMp8G#pG#p8WMp8G#p8m[zWWS nUs8G#7Q&Fju)mJlOv2u,^eJ [$5G&GU)n+k/cԑ{o]K@I*E`xVG#p PkJf%f Ia2i\t2#2(WѭhLQ& =ˠt"F-kZ@*ztفJlJXgp8G#p jUUe}l:큤\Yyҧ Z_[(E1͸λJ*$dn.O2Qu썮Mtc(~~ Zu#G#p8;8dͷ 1eH_orgQw1INhA#1'W#Eŀ`D p%`@7GK1BjG#@&_rm\nwSB\Ƹ˷z `旕t~%TzN}knA)v )zC)۪AY{Axl3чV抧{#JXr4҂db _{ 0ϴ//` ߸LһK3e[Ը  bgl [Нow!so[m:{dD+AYH:cV:QgҿCp7\6 0g9~&>ͭnR}4ߴ>xsB heH9;,kF##0kj|Ӽ^?E&}O;,oO > J|$3Ͽ&X=0E$KM7BNPp+d0[R6 EW,;^v*6X gOy> nBA["u nB~FX`7-IohꩅZ*檢_|:̪o \ݫS( Z&@rߗkXWu,C FSc&t1_By6YrmTE¿erEޣs𫇱"{0'^ՔXCDbyLvOEX\laYmqL|`Ysh42(Aq&៥Da*!e^PȌ|Ah(*wR×"?ST[R1tē3sŋd=C=ҙ {mAU*C|eZDwCň72G5r##b1އ[Rݔ |pET ȶQPz1c sA_,Q;X(3#惾#%?~$/N۞ @![~XRȫ;xC{6/؉.0aK-n k)Э .>j $^0쬫{)ơ+ SGTaiʠyIG(JKG2䐻^̻(ǯc$?gV`_5LC_jM@-D 'tW*X5+IpC v,}!kN8i#HtM P"4+sAfxko6E1~ZG3X|&z;1w/ޮT9h-ѣq#>6`WD?q%{IYlB7hcK/ £H[!WwBM|fm F_62%^LJ%+{<4I+! @ BEXѧ,j늸nǚ@p8wtlKkn7t-dTɑ!PZl!iI1o@h@BY1XkbDcm^>24kj;|\}!Hk]^ѡ`F}gx-YY!'f"B}p }iEINyձAhBʯ/ӄY/xXBTr&=%4GqdK6J+*'ƨµ"ZEL=ve!h!#hgѩ1) ;ܢkAn6ˆJH_q/sk 3i(?X_[8]=#[zSŕ8f%%ȞuZlh#Ν|˨`qA_nYKpp^1'i8_OU(wbB)ɡ+&ǔGI`/M@K$Ģɕ|_?ODB*hOe ޘ%% e,awnZ:C3[סkBiCGb~'-J+plƌ%{4WkbyXU0j'ƅv<Ц.`Qg5С,Q4Drީ ?{ӭ^Smg`Qy ȟE2,%Ŋ"rco3Trd0pU`A$3ib9*b#&A 'N(cn  V[rJ`+"n )M:x}c+K ϞwOyxkgq`%Rs_G#p P]%3/KurMHO4CWl"Y j|>HV"QVc-jHwJ;?r2ر8a/G +l2/a(ˣ}I0?h! Gi WVV0lY<1YA*Wo!kL$( _D^-S _ST}Z!US$^żKE2ʰ8 .?Bkƶᔕ? K`h*ټM2p\FaLz[D9U鎍I ]Ѵf+1#R ݓc- Hv #\k⾥2t4 rlgz뒗bWe4"?i®EaL{[ܿ#qhRxWsw@7| 5RF It6i]Pz(!N{?ǽ+j69oNA}ɱ<K&1 gL9ظ2 deo)+,&S(5b#6-2]o呒D hEDl8W_BOxHcg+j8i!(ij)'H\‚ FQ R3I*%aNg!2aguT7oFj~BHLOJא\\gvZEW^Xƿ粱ާԗe>5 &mO_S֬9tɎ2b: j lfrް- 'oag *HS @g=dea5";r1*gLstyК ؋wD2^yۊeI)w6e- ήDB0#RaY|ofzs75U;G#?PC䁳k+Hxl%1M𗾏B"L6fcӆV0JK#Af@XnE{))}a$_PVo,xTh~cA[W^l3\D(-O YmLku,y8f4ck#Xn]N!}ݛS0T"' n7䢂HkNb9/0:㲡r!CQ`}4Fĭ;DNn[y:c&" GxSG|v8^[SPַ ?wa> R6 wr64/ߏ*1.9bcOSCУ( c"HZk*#0O:؃`kG4 yM4 ͤ5F}Mn|bD ~mc{@ޢ{}!G!wFP*MIE ZEZNl DLy`-2zFKq8ҍtx15n㯍/FmdVME^{[U{$xm'vz٨g~ o2nz<0#}DMys;*7^^#+$qΜ. >B\fM&k+}#kPzKS^7L De5~J!a ۗ\k$[;?"ڴP,*/D?3}s.dc9@0w8h=Z^.ÖNK{>twrQ<}u-q[*tod,j* #OG\V0"Vˏjy2nZTbأCCN8\ozҊr `&aJ9# {@, Bh^M=.O=`;C[3Rl!/N~ oZW(Z0x/`+}#2'`ͱ( 7Xw6L#vܾǛ+9lSoBWP&UH$? m7|Ly΄b){ П\F9?W$H32JJԡ'S5I'.Sp|5+H'F6&JAxp@Cp{njQƝ ~6[QPH]Nbu:СoG`Z(J20ƁTKJL0żP0)\'"k` M,a)<z^n#J%w`׷{>W ]:cF`iР0!!7 {MP)aO/+Þqو&5;a/Hp8w"tSvmȦʱ;_hB%՝+xNGWGK3LNsr=)`Ն(\b>=F`8F/S4|@[mZ@4>ij Qb'.EorbOgJAɠs i躃K%Pҧ=?cHIaJKȤ#~+/tœl?;E8G6aNhdm؏Y N t#buyMYAd=pan,?=v\V;95CJ]E)&ȑj]> */6¦- #CM?$j EJl1+J]lW}pkN?KPvchGEcG4XZMR=V*oEr_5#/gVT§PC"tl+jp<4?jz&6 Ц%)s9+kp8H/cj+VXyLe27DH;i#j<"HAg:,$YF9S"ƫ]\7fA7𶺀|ZSSYa'R^ ~)0֥'N]o2kח^ZB}/2%EcD5 6ĘW"Sw156f23CT_L.u"6sjUR?Y&4 eS0vvL7&,Knaa6XxR_cZS k~y!Ùx*F1cG¨d8N0&JEJKI <+h'A:sh4CbXz!o}'aܜpr#>V"rj\0pܒO.LO * fc_9戧r8G f2egK9 dvGDvޙٮȀ +Ըr1C ۢ zEHZ#(u} mju\R}<,ZL\$XYY ''p8G#" 6"ejЭ}WgUɵKU:.xck9G#p4d1[胙Jt&_j tv?;UO`~F܇i^UR:p g3}G#p8mq!p8G#su'mmq8G#p8G#p8GB+wPgr8G#pG#p8GB+wPgr8G#pG#p8GB+wPgr8G#p$z G#p8G@@^RRrg#p8G#w⃀#p8G#p! dwPsyS9G#p8w6roC#p8GNBE9s_6*=of芑r47Kvy38zv-o{G#p8Թ SN"hJmvi"+/:5 g#C3tǠvai>ms qysŝ:?fsRuf^=?ڦ1XX3erh/5|t,gL ]v&.A0m Z3@Є\YUgRDLWDU 7A9x P1c CtMl(㌦"Ciq37J3Z[t@QnxG0mSp;87j2tXˈ6iz)dnW&uƤ6h\ H6 Â8ul=5hnv4G#p}[SYJTlpqJ2^^q W( oLM@oc-F}P)$yV"?jW"StѨkk+jr rxU_j'<@gLӛ \U,vD/ Rk8DL] ĺiPxOlxemzyFGj9ᝪ *.kPE]dIMϻ:^\TYkc2 ʈgF튇הRb)KSϡP" j" H.}q\ZĘ΍n#~k8[~5 UzRFAx1{L$ڢ kQ_죇pP Tda[)CGS552lĖ̽G>;ܝ0PWCMv \hھp J+~x#]?+kC;F%y3$"RM!o;PGbhb%+*qr 0KҬDy p$ ' &挊uPѣN2g:auԕ@t2=I [ %%nodlؚ%R"U9~~ kB9]YtcP>'v`K7+-JoduԬ <xiVjxHHZF5.ս>xd)Az[ \*(I< gz= imHִjES 7>cT66}c;U׃U! Humɣ,=u&& )Zl3ɯ_.e9Y0)<hbS}"L yMapf@%i#+e< u{3&'CgGo[=f6b ľ? c"FZCHHybaeTC;18vo\wc6! SDhh!6?]H&XHhC;ywxkZ%:t- o62ánֲLb4;DSU*2Jjo}P89#<< >.$Z'"gOJXcaLB , tpOghiH , eAT? 0|*5`S2Czt+s"UsQ!e$K+);L4#>jǦtc>ӕ!!ba𑒆귃#̍W~I7>wf4Rp4R/>svGxUd#- FF~)^iRqc>$,'ʞ-H@.nH{1Z`, @iNT< 0,4tDYۄXהAx=R1\1gaBͪNKIaN WjӔxg<~@Ľ 6nE{rx {M_{a ^ڼ3iॵ1h ="qm8;jkmlc[<{?z>_臞V[q]J e}Vp;qkO0TaΤap;3큊;K|U`<+Ohl;R v7*sFF\$SIsHӾL &n pXwG۩/Z\ވ` bb_ $P+ϔ"imqj8)Fc9o]sӧ9݁S] OE-nYވ8)1M[UCDSe PE)؅`msbڇQY­;kBP1nOx8G#" t$|T F2rroX2&LRzb̦T[ wh'j+s([w8Ɨȿ#ұWVB%ߐ:Ѳ/ѪQIʑ3KU ಥVY*M}Ea1=xGyrթ@@_kCr|ٽNQl|Bayak/~E5v n#F{Ee&#.B!~;Y(-8VmU Wi#2DZ,IfPNtEؽ<ڨ`[{ao3/&a{;Z:շL VTJ&mR\Ǻu oސ7DRTt 5RTi!wQשPPTr NRrVCɩ}!mk6QP^j [cq@sR8ј&mIxPmkzmu}6'$Elc[[oix K~;g_'a٠6:xwn:ƍUU(,yMOZyNL̋7jJJS!m⩬%vۤAEը3\P;38`D~̷˓A<?_vv>u|zbcbYCg/얝9Z`@pVb#1,41<^Ǐ͸@.7vyK╭E&ј|ot#XD~]Є0FkoK0gwY-QNs ܸ`(QY+OklB9Y?LJբi՛.uhCÊmxĶ ix/6o(J웳vգwH\~KXFaU<>cN7½;T#aTyv7qD[ۄsuS1GT<pgH>:;jT8x>e~NzO057gE+8⩶_x1ٜmW)v B}>5viʼNUThB)̥5(E7)ݦ1.Ѽ(aWبmJ͋\_ϳ5;? |zބEO$?jeǺǖaEVy&CR,g'/Vvx~YMјRosTX<{ vŹ^1dot)-xus'&az ~e ۊC *¿(ICp[дlY8)ǐ/WrHĿ!8@"7-J%Pk:74O1dpcmSpǚ´ [zF`)u 9ɂ?Tk݅)% xTP/)xפs>gwƅ  c_igSI0&hl5d6xbHAwԼ_݅3P^;ىMoNSqly͛ zlxݎh ɵx݃X~[ [ r윎EMU{wf`16= /nEc\fVKPݥ'YC\Btg5YLquq$!֝Lz Eg܃G'e -}ـ%| \]L.XO'7—϶aLw ;kОyvAY#@<)u]G]Wf¿ƕ Y.WC^Y|R"iYU 47v:T۹׋GCKHq\>}-XrLQ#".LհJD6Eo Hʪ&uE@IDAT |VCoQhaϋzyc7F }a,>; E \Se`wG wF8*rwvCAx9}IEdk;k/P*8gO){jE.;G€ T?̍w$\>+EI`4־СnJ]h 9?%A:?<Wc0!͡xvH(X>EGAxkz#*\i"Vt%8,߈?\ECцSxyu1ڿ OyHxuܗldgpRNR^N:lh3&z]q |[b1p:Ͼ WÂQx9+_tiU`woxAPO{ߥ-^`,"[]^E" B]OǿqL94"fNH/31|V?oT? A"3 C;"vE1B7o訏b p138g2/&>qKNJCl]d)F^'P=&Qpr/:GBx s*I!-JLMdh HCIǺd9'eGLA|ҹ3 5ӖџtD  GPXSLx.ըb %gz` f#3B/Y,jl ;+z|y+'5: ,֝h72La FgP{JYB,Gw0 P4B c"T ڨR*HKTO!k$?&+.1(ʱ냃Hk 3!(r<(pZq/y{1zEk6rXs494c `eq"en3%hݳNo B u3nl)0꣩!' 8ψ@j#3+>!&҈%=o0}${Do-itS 4N_c2T5x?Ih ƙEShFyRqQ nťHhY U4yde@2e >`8Fg/R %#nN#A/6 KFd@5 3L&}"x5ReW[2$澮h1rhkӪsWN7fj7۵0:O;Evqxnp$`؋gMz#tG |uH%yuvRhfk(jr̋n~a\[sPLp7`^tG Rd}œ@1/z51:@`eRilTYfzl3},ibK cb+^ދ̡CtejքϋTٍ(eH%x$AShg럜F~B/"nɊnc4_[Il׶1t ld=*ȿk)JuL XSv<9/JηM4VmBNX)h>]qr~4@]-Of"h#ߑؚ݉ C"KsGk|/W [Q,Yٞ2ЦMOA%[s]cKâVM>ԎnȕýIچ1!GR=}!LY8O>$Md/9õ7.hv|k _!TyǓQê a"qY7caH՞]dPi1qcM'}i*paE(mxc! 2@m QF N AĢpXaRiz_Q3}p|n,z7|Il4cjv消c {n=f "{SC#۪ex"=M&;'7ݤx'k1{6Ju8j|aǻ]"C]j|/*pņO/:aLbh-=Sls`܊7ebKޮ2&Ɇi.m AʵXXѮڢ[uhq@oO 2߅z{h1˫qe=s+)Y/lмbl,5#\.ƹV*E9oW%5Pw!>Pm7gyb , x S"Z 4حvC~?{fmt)ozlϤl߇(R!J%]p^$FC:ق ri54:և:h5oև' HHaXhPB74G߳*|?>zGIFhy P|ľ:N-n*E|ҐշO_ilOy(0,mOT]^xb( 6ېqNvOGG4I*lo!Vg5l.Bx#LPsO¦"U*a;+0 >C1;!#MЫ5F"6LX#-šIg >l#n{#ev|n'viJB lEі"!bP;[fdH%T@}1FM 8*'W}˟O4[͆φz\h(Rn撓'`f(9w(Z$䲩LT/TH/q >-bm޹e[<h#I3rͬƠ')Fnwh,$zҹOKgmaR' [~܋DKįHsXpmλ 1z6`ralR$.M1,{^ޏ}*ѕǣ >قU>ȍ-vxP^_lﲱqeѰ;^P) ؍`%QcKL4f6q7ɦlMO6e7Ƹ1]h,+ 0|wzcbмy`==s-tH8Z@!Qzugs$^Ka/s?MM3ߎJikNwNZW=r+.8.BK^4@"g,R8QK vQQû!ƴEҁzbInدnTS>=xdHk)?:!ᅩ3Gh`#-sOILD|)bCe(vp٪܁kEl%%D!!C@^G~FgN,.պEs)352 LB2K[_څf?|] ]vMuP)xȝBZBF{\SX_@t~HN߂YP}OS+շ<=rQdC|{0sE\LϤ_WHޜ-ұj_j+T+!-s\Q:y-I7ꕭOlRO+(ăTYGKJ|6\=<& u4vQ|Kb5N,\ZoLNSu]tXHu$"v(69zH#{bb29BlE->y.WHZɃѲQCb( C=Py8o>FMh* SiuNz:[_:YI?}4ͳtp ݤ~ى{McirΕPQVH[uf+L+7ⱠosMu @Tg[1[wpQD&_-VT&%0Hy{j".?%,:Ƒ~0Ycϳr,Z~2Mt|?ځ$ .)6q$&\eY)`)]([c/ ^ۊw.^/pF]e'bvΌ|=d!JQxi1ȣz !6[ΓbaV5*R|f6H6ar_yGqgeOӤ%SCAR"T֠Bq_:oӁ;P Ill,/);7xFhLzOMsP"lx2"2Pm t+{~M:ZoGُ4|NkMOmt逆>JIACu&qY`Lb }_>AALz]n,#:uEQiBl*Fsp D`荡F _XM'l L@LA7z."6[3ٿ7텬 /ĦyW 9m+>~qH òYgZzHrqr֐XsW,傖<6x#Rw6,`kNKf::n \m>1/}ə/jjh顬3r]^#֌F@m ™lz՞t􁛲j:7 A"2F|Rjx,DPYgj_8v[z?r*”}P}IeU5zrjZoqd.A}Ow e^6 a>Ҕ$̛؈jhWW+i%Kl J_c`xU}yrx*QNy{!Mq\>-@G7h Qie-Rwih4MrTT@1aS+S"teϣ~z+>ZYQK!>GLavFKvѶ6B`~5exy.G|G"Q߫i;"p#Q~VgFxXAfqӗKjzOY*/ 5˦yp"o°f*GR_C'oqfG@u\/6;ـ#Rm` CLQ!rhgͪ8 xcJgٍ 0Tz"44YറEK̉[J 0gkgvRLT#L..CEgH#> TecnԅygtBÉf(7;:=,`L 0&`hլȾ`L 0&pC 3&`L 0+O@OSdL 0&`L =:ǀ^aY89&`L 0&/jb=`L 0&hNМJeaL 0&`ML;M gL 0&`͉wSi,L 0&`L psL 0&`L9@s* 0&`L 4141` 0&`L 4'hN0&`L 0&&&3&`L 0D;ͩ4X&`L 0&Ā9z&`L 0&Мp9`L 0&w0G`L 0&4`Y`L 0&@@`L 0&@s"T, `L 0&hbhb=`L 0&hNМJeaL 0&`ML;M gL 0&`͉9 ò0&`.:8vΟ??퟊?W_}<{yyEh߾=RRRBQg8W=4 2NPAWtPHW)r`2) 1ȳ件  ]1ԧ& U)Ns~J,#h}v=z 1@MM ._,ڵ zѣT6V(3ۯbω2h}`Dێ1~`{ sQ'G3=ͦJqxV\TxIU! }OJo/P!`jvj |K:{y|x;wR)T\ p;xni'?%sýQ!a]ݜDjY!CN06o#wMv:. `j!\^p,^uf:~;n^莝#:q`vg0L@(V¥K,hك|L6Q: oaS".s}{31 xZ u*CNcJiTgK$ twFynߊm RhF\LP?W6J` #w̕Pul'"TAv\([N|hk7B}/',ab]0c:)N=!QOig^͟?% UA@,ٶm[n4Xy?"1ps}?U~;,.&FIjrP{0(LM*d;'waOm3.GĘ{%9?[-4Exf `u,Jz,rAm OM&drFƢ|}rל2*Kfԭ;?@u,]=%SS0.jVSp駟6zj?ˁ F7ߌu@) 5ʪ, ?|3fŽJg~nzeݙt¾uG,B|x-:~~oehZ R|9$cUBvzvv,[߽)K7MY; hrJ^+P>[6Wܮrl\6ڑ{qf &l 忠@ZoMRj~A0:qiP@5Z#!}] %_I#s ķA '~셮ɻ (s5ɂIY<\,C~X_<TysU^IUq G@rU5PF asQc{MkݻQC2)]w!4>}!TFܮ-řl5B-wZ-C͙(>ԙo{nJT3= 5:@6v^&CXsYAJuCJ7Ϫ*--"v;j[  ox<\-sO4ТnQ[U/S9CMPU^-\[GAh}vs,`'B U6)W_iٴ\Q\-'Lt,I#XGX_&2_gb//#9FQDjgQo# ԍ:V9P"p@*3J m^zyHLXZЪUVq2#ndW^*1U텨P{Ǣt[nLAkѢ!,[WB#çbf>VV&a䏞͚bu~?3^(Z2SI1ӵF)#=L^wZK᠙ J N)1I]y{`>2Rdu\ËVa??LKvEi7+A1p4H%7LB;ePܶ E{7b^,}Do&աC{JqtJ۾gs "S0ۈݮ(O~!`j;Oބ-LQ*j<nhm(\Cl{.+GYo!7b4^>%eniP=gpsh4b9i-Lƣsuzr˧#eW~~tJqd 7]ɳʃGnr[cSƺ Ao-#El~.f{LK2VXn[%; >b i8c,\<4/BBb0d_1.ڲ}(JU&cTwVӷ>)?cg)?|In{5:\"^1ɩH4i-: J4Dzr[Ps ~bп/QëYW`_=;1&0a4)wE ;̩Q#-OjP )ԫХM iF/Ę[{5 u=㸉jS;$ TrYUih)lࡒ_zt/&`(f4@{@磄>e F[IAҚh/v4r jDf^;c Ъp>v3aU8q1Im`@dM(&аZl’xLKʿy"/3_aSqAs"/&΁g?izm7 UIǡ}T]I/<<'&u'XbZυY*ƽ̤cmxjRobAJذ+g\'㖎A44Uo/i/٥B$YL6ߢXS8{x,A ބQ~ً(8+U` Y'qk6xwwۖLLڑbߚSkU,i qzb8Ѓl9ȯS>yqd6Ί9xvHO-PC LSX݊5NY^o2E40[+Zg0M qJpu'V-eQXgkj~>*6чW\,e-ƫScV}/9aX0 Dwٗyy9,3Xr ZMc. #Dj%m=O-q2c)>Ms^Ǣwa CG+|4̯m6`:5.j{n\]pB]ue[—oVt SkGKk˽{:^䟦y%_%}RkUȿp/xK|ތ"X\[Y?˺N¬]AniFSJcCCQ*3FʿƗLL¥%˥>1rq:!=yC1j?ꪟ5{ʿJ<<{٘-G6ɍv/袯}Vy١FZ*Z@ }7[lznG쳸Ϸ0H2 iD7gb~/$ .hs]$ڵFxdh\\o^^§ُmRr)t8jjԃxFJCRxF$#%XRA2Qs"_CxڜbSUh?>1A>OMA4~hǶtDlqi?ғ[zB^zBʮt,\3J=vQ=!xf98p-ހ7fnлi謿mNX>$I{_c:4$oݓ܀{Svt']EO̰ʋؓ҈hjKo,>HD<L<?oAq/Kh; m#"ސWAu9nʘDkCo\2fKrJLt. ^MFw/ߪCMiQ~_-Cqw^?ryeGlpi\ 53W/qhJW:vOؐU/RRe?AHꝀ=oT'-Y1T 4>* d2ՙsE;hEdv=z|/4ma 7Zyz>1B3ǎçZbpD_9.NiXAG JzjQժƎFѡ3.GdU%j+/%`G714ػ㡹G7=^'bFP^_FߛyG`d*nd$q!mB=56m0W>[IK–'gm{a>/\ #!ޏOM>pk+Nsb'^4FrD3t<1 sƻ6Gl:cnS=Ub[1iHGeIѨC# L3Rv(N/nM~ƽ&fh2(%iG gks'ݦn҉}FC &r5~ZG<.`UMꯜ1lq W@l.zR3g~GZnMV.48f@lB*)Rd5E8K 9+QBd 6wCuf4V>(*هBۙi6w 2zX8HվrT!)r@nB ͊V̖emZlEzmXn7m ] 9CW )ȿEtݖ8?(f#Y <aɢ{nEI7x$Ȧ6veQ {-N)cJ{}.^m|L"f_Zđ}a- b34#C}xX9/\ d Ֆ6=? }8s1`A2F|贑*O#g@c vSPRG!rcE x M׊`d4=G8jO nM*6HC'ӑv¸`*OUh¯]tPmqMκS)F>%YwݭZFi>f7]lyP={\xhNaSr8/n2lI9 Rʠ2M4'x kBU:jca# ;͆4TIf)EYfc_2RKM뀲2o44n< y܇aq?8IFA 86k.sfc &gbY9g.Ox0>l%Vq,OeY+X1TJ%ӗ9tNoΒ7f/Ō7WوE/ G*j7\ԯE /"70`յ_7H|Hk,v|C5͵uLEQ5^>idp [GԺ%Д>Kg$4L[vZ=oH븨1sNUҩ9CƩ 9v|iUϺJ40ҡc$o,oFi]c{@: Fb;'Z1B7'HDll #z3PL-7zuxaCu88N_̶44:20~g^WwGqko154,jrdC+/L2-t?+OVX.ChGl<` PzY)=oYܔq`纭Ȣ[H~w>M"cW6/=|Rr+zAF4AIgSz%vS1Z+u@RٿnR:w=W҆ex{AsޅG{wȸQiTRCi:+W̖t.e%iO.17l8y͞oFz1kG/r@Vei_A7Zӈe9jDɆe,f>ui%yxv<ŀ X[yL46XXc{;4wL]c}."~r^.X8,Tj.#퍝GsKwq>.#_ڜn|.Z~ĆƲ7ӟ_JV|zճ=@Vr:?,k ޞ1>"7=9X^lxQ:@7 O q7={)k;,Pi^I8ނa*?/dǟO@ Y*mVzNq60 #C]5kQ^X@D`DȲ*_, uRtlZXc0clF.F "1puKDu=^Jxaha}-Gmh 苔tb#Sf7}@53 WRF5pt_W~ݬD)Q^8 x>9TOMqo#Z-u21&p?M`$ b],uNdCLKw&sϙbL \-{_-2L 0&, Ќ0duӟ}XwL 0+Og Nl3hzyٕSM2h}hgnvӲ:DOb:6-rG~Kñ0&@}( {g7e{`A =ޅ^|ں)6`:02[[@l%`5A4TrX{|%u;kF=3a^lV㛀w C'7_Z ѧk(tZX][bñ\ɘr ccpZ&|$LL ndoHC Ъ>Fs{2pegtk] 0&W=oߝN|zNōc}i#`L,a9&'sA?I#ZwXmʣ오'jpX<}}W'5Bހ{]P'8`!gÐЅK?ݛf9܇37'YU'`@ u,cS"WR 9wL 0H^6z3p#Iѕ8qx/BVI6+ۏ" бq|XE|;ڍq&ŴÊCeYtǸ1}іrSkVRyi*T&b-/z9[M}ooa/]/N@TF=2*:5H5N|/4ϱ;,,^^KCg ;c}&"WqJO/0m[t5+Ƨ!qm}NoEN>~G/(54h^u"5֡[JP/`58\% g : .'8O֚Vq-}ҬM LM/ТVE@ f-voՁq{ylP*9 C fzFpbJPrl`͂FI` ޏ3S^(wz}c n}nZQw܆aůTA9z7d!ڪrTL2 KOaE}Z!q;4`Æ 0& ?>BBBlFXت}{5n}ڗwf: 񎿠?VCmUtUTpTRrK[l^Gb㎡18q" t)0}l+MF99{ [-|edq->s ⤎K~oC݉!>1|E֊E?nXK/c)߂FuFHLoCu@vArܚEةJĸ;#&V-`Lr_BF=htis]cQx5;ӏ}/h23)p מVnQjh&%@)VVAO\BY(#;?#[)o3OHn5ԡ0=nd͆ 0&'0uTjƌF&_| FK {m #&MQux5?;t%{mnj+uxxbd5 ,+x4"M7-1,/QarY8iLrv^!638 N$(hqK5mv"NܬD{u3R)<ɛIX}N j)-K ƺ(VjEPEh^9{D0ʧ. !~tuшވmNeH0&-w\Yc^HJ%|`oIOB:_X::Օ: 7,ŵFc\cd'|49?m6tjobop> oy35G^.y^P/s_@lwX/L[|ERzX\NJ2pLZ%`PE's%_N_`7M/⅗ƄPq|P;:7uQ) th_A)-|yv >jE"y}MSzŠO Om7x=JAY%.hϯa}~ -ɵ8[CeDV<#ѥcPg#Tj ɭN :PF~!~iAx/vAL A3 Yeo`W9}F_$={5h"P-}=;!,= fBqbxim^1$w!6͛MB1kieAť=XAhۢKuf7n^Y#.D%Kmr96va2(:jNmϏYfJ}'D#Kmu7,8J5 ehW7 ?, :z ؘH콲cHq!\:K8B_ MzcWO[iSd Q[*|O/dkIo<gnf }7wT2kAM֯Ik 0& 4@s'1& `o PAؙ 0&'+dL&cǎ.w)G`M@;MdLh*_丱4_,`L \p5F Flb`Lj :JI 0&P>1.B`ͅwНa'&`L 0-&`L 0&$')h&`L 0& 0&`L pOT؜U&`L 0&ML 0&`L 0@;۷oES_LaL 0& 4.w 0&`L E3RĩSZh$&&ϮOb6ap\U8i-i1n* *("~jssV\JuX!׏€pñO&`L 0vnyI"%%[ҥK8vt:wnO:Gi$7z/-<5Y[KX { Apt^pX{tT$@aL'(2gޏaF:/ c͈N;atO!V)ئ =oo`L 0&4 Vcǎy1~z#qͅ3.6 ;i$^6y;6"O uiUaǫaƌv0jdIj%cG1#TS__A^RDͮ4]10s ŗl@eђw }Zr)B迢@_rQ1iw\l.n:7 >&`L 4&h4N /K1@dPm*9o_{/B4HaOf^0?";h싁>}pa,I/_b<'_Dݡ ah~Ź\eB@pNS\l/:N )!77UU:>%H~ Ky8~ 6*5:% r؃xQsb>ri8܋WIړF M S*U 2bH<&XO8CEҼLeMB+N")z/?< W<8kIO*>zo/9 yXg)ʤ1xmj&`L 4ʿPQ(qdo/Ms;EzE4GɁ7f-1xՊy4)ŏQӎ؜y續3;~XZҰzz\H#dеmH`[A V^^~o*pmPR㗠Kz♏g I­ɇ6;IGGlȗB6r Hj.ּ|)븣mCYjI#8('Ma[JΘ&u3$wi])mo|6, 9ْ[E~G!!:PΡmTćxC']4[ؽ>RL_ꌹ\:#L`alڨJt8^Q@<)#Ӭŋn1f8ی}h5niҞS!/BwHLW8? EED>Ɇ `L 0&@;3)dEۅQd`ݵJƘ[D77%BB7Q&`[~>~GŁg&A0ܷE۠i @QFTc;y:vYb?_ڨp+xN2f#K1)lPO ;[7߾ Q-~+6~Ix`D+\l}5H{ShVZyp_?{!mE OEG0ph.f;2T>4N~oFƢUkw\e=ɕ ؚ̠ 0&` 8REB܅_G~ً0Ό.({GK^ ,Ǝ9p,v7}O e&RCW(bϋu=1*% KKTvZ]DiHL}Z>TJDOYjYNb4Xcsi@,G>%*GQ-Ξ8ϖeAšٴkrX(m(InXr"edwf6N+PCZ}=uE;!TmUF#"2lcoȧ\F (hf\P6$ׅ)Q|45XGCZOI>:Ht, h_>NKF8-P%cbtHq)dAl|/Y?&`L 4&X+Jd~wd,>kjһjj( \kޓM*I)ՆƓy#73:hu׉QkNX|漸=;"cn@^t1 j=6~*<6x@) ( T5I|(h|dF4Y휥3NV'V%)&'Z<,7'nkˏag'ҧ"v#]r2RYK폀ߗa{b}ЍFO\s:.ışa`W"_8eb2$xxe+6'z*>d>I#q]DQ~_κ~b3Q'L^a=w#{ÜユRr|(uƤ`r۵Xxx f=u݉pYt/gL 0&@ ^GD=zr…ׯbV;0ydxkcGZ-SET{k )UPфc^ T]?Y$r$/z"䮯1ݟPhQ'ĵl}6, rdT5nUTZat4࣯+~`L 0? &_=~8n6+(:8AN_GGn%}X,đYҵ4tud~*9ᶗ'#Y^la5å<.yP)Ӧ<_vU3A#eN07tiOF0-cL 0&@ 4Y 11QR#** ¯#EǑes eaL 0& 4p9b?44TZdϓXT*QVVJe ol2]s2L 0&`W9w" AY4e9T|p&`L 0@QQQ2 >}Ć 0&`L 0? I `L 0&p=`L 0&`"6g 0&`L ):)`6L 0&`L \K/>:ƝXkQ^Rʫkmj$dQF~ %a%QUg$}NaUAYT-ZDoߺк+UԪEaI'I&;ΝL&$(sg{L~ιhr2zZ֟<^Q -*8hZOFCsA%7{mW fm0Ԏ5T2+oe\@< G#p >Ԛ$]j5 QQ^F/k a3&N7Zxgř^U~?KӗdmeBR xWC6vv~eLJu m˧`6#d W/zJ ߵ>=#&dOkTϞj4v,?*~ zɖwT^X /;ZT/v|DOjVco\"j_@ SD7eGl]?:PN-Kq^?5Ai$oCyOȠĪ_Maٞ&I+jj<) z{/vl=tL\-NCX[ok", X8kɿa:6z៿>G# PP&̛АQRQY̳9`yOLB#MɿTHrpN+ eU@YЪ:"UWؽvPNJM'AO~ >/oΝ3L[o!wʪ\\N?LrƱo⃢\lu mJJrfB?<;\BMsũZ#:4~ @ OH:SSZv!}y]1do8tD J}m={c!(%烶 4#AK9BGx9 Ofb !KO@ӡB l&O qkWwꄝ h=׭!9G#0vJ pL5&){?3? g,^Jg+ PUYnh@G"vA5^_X!SxjYEH8'`Y}Z .)w_[܅isIpMGKV*/p |&X=qɘ/ҞLsYY346f ܙ?OD3ehJP^4Ĺ zy J<E`R4 qB#t^>C=t~#bz== /6j,QqXQ\u{ \F#1K֌ߜ›%86 < Lhh=oФ+טnN'!BIa>^ H q2i _KuLҾ?/7MI{f)p;o6^{6 h.=/z,# S!78 w%s0fuʠ+qU&?D3.]A 09.ޘK:lUMLfm' SݴRφtZO:l9#Dn,xV|2r~VАc1>! Q+[vsOc7kH`f@溘s8GD` 3K)}(:lN>~]K w\҇+) vBWeHFZrY=3 kNk."&ƾ>u;c(*7[C JԴ\DFĈ#LO6 B/!MH1dd o^zM@l%Bו?roA/kS9CO`+s`1BY`C*~>Bc x ""iU=&ɰi&<XmPCQ@F-(_Nx5Ewx%Pxk]qyʻFF"n*gp?R]z d22 I`CK6D/$OVs = j,ъ7RV0{.**uq0(3Ua>`_(Exq7Y?~cOrEC \}6ܶo*>$},=Kq$۷C6%ŀ$+<4чHijh`Ҕ0ryy]h8s<$#<ShL D5T4:}b3 OOj9v1&"#YjSh%Ji Mz`r7׈Vi k߭C1BqEx<XhJ$]ţ{BEG5R}WQa>>|Bo{:܀WAR*=aԊX*d`]Z@:%'WtMeUNl_2_7h&Mo^ x^*6*i]02:01l9|drf9ܜ-**a)8PT2@ĤPТA}ڝG#^zcC;Zҭo{gi.6^R }XR@&&dgtemt5e5Y1 x_ VpV(v=)ț' m~# p!Ly}&[סт 4wp sp-d7{OlS [O_.A g*ᠮz7h5}HQv: !@xC |S\_0n pT6Ul=K/ꔑEXІ,vR&8z^K)FNJDÕ\./?GNJUlɩ4!48Ue7W40Zϩ|K_'*dSOGŶ5(omC٩L|@ ILQn5Guu f)VC6|ATWr9CLhB.{# {)/t&%,Ѧ7)C`GcPp8O?'nvv5waϦ'mGZZjԬլB{8^].GD.X̂l̟m7$V[IBӧ?f!SX/cmmR@rK-T(.)IHImayxwf h#M,2_ƽx$X!͙j蔝*<*!N'oM2̾)g>ācYxXsp8u/]vN󳓑c.2oo%T<߅>K/xSN-s֮%ޮ+EڤMzXBr6Ɠ]MsMworW Xo7xHz<:-tzTbcq.>v H UKsZGofo\7p8@(=IIIng?1bn#p6;"(x&T'Sjؽs9y`y:H~֎.K\.WN VMefDY̛@)YcfL*pQ:#W`!vR:}'/7Dr 4ށ7>tF[$:g&N(\:xX[OntpaaKmj;s>|uky\|G#p8` `y9G#p89\p8G# } Cw`tQKQ /p8G#0hgPkjjp!_2]H?xG#p8w{p8G#0B xmG#p8WF^s8G#P0Bw#p8G`d"9G#p8#>rM&A_rZUN.P;ThnR Ѡnnp1O7.ﺙocM>_J_ @9{'$B*b~HM.;"$GYb=[zduac( k(d?i!9eEk} ,W m\6̵B{@IDAT"g/agGB =?^1O%߉߽=_}o,;ƓQtgG# F$&&bԨQFNN`OHHb"%/ mx&!NXW 䟕p2!#<OwBڡb,­ǹ X{PKEW@]%te!ЙXr RNXCk)ֿb74kB+D S$/ìE-~t_~Yupn鎞R:S߿t]Ϻۛg 3l(i8Ass u@O|Yyp,#V\(V&wPt2^7m+v)Wc^^iqxxo=rYMx09K:lkxA@1 ]YCn𿝅ؘ *9. !+ 4θ$ep8# ]v1+,,DrrU*4  V[ HH̘1xf)C* ݀UI}B#p͌\%Pup'2Gu?H74 {;`M d, Y[LU,3`&ޜ倷G=Ձqld`Zw<1IJIy(<&_CrɓB/|G&H ׄD͕Na7`R,ϤcG h$^K cKU;5x0aXcٙ@ .FNzE!%_]KD冈(J *wxF9%A-Cb?>6&-G#P̨Сޘ+|[<,P{^PSUZZ GGG(K233x)X8E!R`}{e.3}<oN'ՃH.JsDp5;P/Z@; kE *+H?1גNy(ox si>{ DxW= t~b%kL ޴JPU38~T(͊q7}uGh@XH(sV!hēL H#© GKBP{082WŹf ZtS8٬'DCǺ)p^ƣID0u&g{rϺʭ'$?Gf=]fmSCMfsOStJNt5!w&5Nau虨mYi80=20 s7)<k ʳkn4z0ha3Kp[J+—M +OSUGd9F$*o=4ƒ-]6ޒ%lR+1y$^;>%{BcIX>3ʒ5lG#tz׼ Nj6?RjH]B炅:=wt+#@S,V\v,p鏧cӻ3D]ي1IIM%XXe`Z4WU2kxIXxt5z#B,i,sKϳ²PkNȭEtG]a+_*A )cXXg"iqdzxVfw7.x^tLJDD<97+&Wd썩)>D 맏UADI=? * \6NNwc_7(˰z=m]|d>^_Iӌlr;#;76 SN ͬEsكWOM-y|~Ǟe=hco/\M:BymL wLh7([R1O&~ ӄi&; iW T*'7FzUvS]3c7j x;Nb)~le`¼Ѹ%)}&~.h|k8G`"0l T"GJ*MI=>~zl!KTLf;~='ݙD:EDʐߍV2XVd?2SP8*(DJ'B(Le%Faݓ^قf{<55x2Cۄ͖d5P%:r|{8Lv AA>#j'0m:;tE~L9P!Kܳxc|( ܷ@˰=}Y;BbhH]Sdl8ŏ'[ؼW?a:HAI\!mfkRTMbFQi :!Vac5Qx% X{lyp0~o쯎kfkVOh+ 3O.&Ќ*"s Cb mhgel@njB__yW'΁J'FЗ9`g~8N6#})$, J-%Ǝ%z4cc\09cJml#}KFfHڗ9KN0AEC=I^d&n05 pkԮXq= F"EZF`Hu\,)-Fʈ0\qq3Rׂw}]a4dh'5Y0i^κ#!gc>$ɥfPjIVag0^%MxfmL7aW mkD%gE#@(tt %*b69u m:fz>>i.w[{r{p{*.HNTRv6dHVy`>IrlesúҤ流 @M|r-m(<~o2ҮĬQD߿üײz-&P}#B"ś ?f',u)le$dF?³۾"+ݴ~Yss?̥Nie mpjqL?I?gsErBȭY7M[fvflʣiďxEL/G^v s ŵȮ1ZQDIxit ?PɣFm=/RL rCt: I_J+:o6qyT'̷$Kϧ\q0)4YiȨjCuQ!^?.Nx ybc/G#0lq({{#HMMss7oޠ -7+P"T9ܰe6[̟LVfVwӌn񡩨I|F7iD.iX6Fjt?[-%B!LyMTP>2ϗm1OKMkYÚU/: $#q^^_R PO޾+ bVwl&8#Ũ/D⥈z1غ/VwygoUxG$:R(ؽKKi6FJ2u렕zkYOTWb99mN7jBtB a7jm'X}+K'Jռ}Ӻk.lؑOE:tuAG?Νo2FaM{a{]DAi<@is6]7XK.4#뾊~+VQߦ<(BSY|kiG=G#mu̦k+"""X[TiY*KC_TJS(b?Y̛[bS R4,26F7Jz?S[ ҭ@rX9A^aŷm̽%"O{N'Fyq۷D]x,Ox{i/]~~6:Ii^xG#0_'7=====wp?\-2ukpMw x8|p8G6 SLĉ K2 < R;[Bex✔6G`^k/nߛq8G# @`#썴oŻp8G#{\e.s8G#X0bw#p8G`$"8G#p8#ءp8G# ]6ǖ#p8G#_ȿL&W=жPרBrj;=*¶*t\:BBiu[zE,펇Sn{>ޢBcKtʳ6_v-+E^ly~p8>Ԛ]j5 QQ^<=1cbb1DJ^vT^ LܺL(<1P ^ߏMTl2yGS02UhjiS:RLTZgWw\` n)U3,WK"u$xyOW ˠĪ_MaN³BӂM"QA=qB/9ۚZBG=O?u8{QB4:" xiG0됵=E IR\܃=gPY^!?qV7M#!+w~œMlxK ?fz"޸{ T(kDFm/|P"|6h|[qhi젮I nJlZ2z<~in  R{X|&?.j'IqX(I.⿊ 7p8B,7!&9l#,,Hvth,@Ue9nh45,<CRPb*Uc~bqݧ肚r[tph&CCx\3qv(y⎥1Qh.+DZ$qfi l8e3X⼒GamQ# <*>&F9)G,ǑrrS9G w\]rF,.-S1X,jlMm>_mԀ$DDyy. PNxHj,;C V]5Ph'Cʡ:CT̪>?noqq5HmM8^ H q2i _KLuLǕ20OfkoO#m[ ުso%cMc:\k 95{l-.3]4U||0އ%e͓2l5F q8|M$O{`b]{hbnxN-ke-Jm_U `;zQ bLNN @ [QMG#p"`_HdK0% Y%w,K w(/# Ξ@{a$=PYG͂RˈGNV ) }sSv Q-&#"1TjƖܻj27_Jd{G5B '.ˢOx fc9#.T09n6ޞ݌V$EjО_f«u27}8+)ĖsεӉ\8Ӥ9ʡl/_seU BP4lKCƅFnmExH^ kQk"Ph Su 2~4Ho%cl?=+٥Yش$~ӌF`<=gJl 6Cz/݉Z_\3G[~h\'*Sn侶Op6͇VwCgw^ݻDV^Ao$Ge睤t4*߁ =,]l*o'(xq (Ą0,PH3R=p8&jdt`Vk# $/eJY3wPUMD:=wttˀCē}wV˱SVO-/t5/,c E֛m)rٹoW`7+QFj^= sgM'Z"Zڦ Ӑ_5LNe:cbf8D-I|VLax"Q$ML9#D.h!Z4;aIdQcǰH 97/.fqeEl˄D<c95^_*K|^x%x=%яH pd9X =),Lv]w-wNNw{;' ҃}W;)A)Usq{!^) VNAʛ^u ( >PWUc겕no>׊^T~Ymr+ٍgj.a"=9l"V 6\dJMM දI`q3F㺄@a,aA*_G#p#0l Dۈ>F_gW){S@tv;W\ rIAVbF`c ԎB#9ҒQX{u"^{p!E^vtiQTn_/OEw{`Wj HF.g@<Qȧb/!.Aw~Q0~v;vWPw0ת0!ߘ 8o/nZv 󃟳"Fy2D}mTDz):iUVcmPF~gJEk1nFa:1^9-0IW8 {h%a0KCȊNh]q#.ѸhU *35j/6ǃ6t([i\ؼbĠ(Rٜno>D2 i֡ZTt=J?rJ.G(NO@daa5^ ϔQǀ)aS'` B<@ʩLۏPG#\ |G7prƼg_"m8gV&d:eDonL #ܴ+<4fazPb.r4n`H30j9{#\|xc8ÛzOV}{<|> G38_kqd ,|{Z7L Im,kK8c~vmX(SŃ\JlQ`7EG5f{8a\}LSy]N^ԴałY`Ez T.sѺapME~,hl_~,ŵk nRT3蔝\ѕoӢ\ 㳉H8=uD٪np;qQbʊe#utNmxyES.;#WI%IP*hҰNwBiP0 A3G@|vƤprS[A9kG## EF%Bn{؊)3'JJ #҇V m%&dgtem6L(U-Mqin_ VLwV([=)ț' ]~# p!~ȬLy>(r1^ œ_iͽn[͖.l({uYꮥsً#gI&{v>xDL7 BdH*; s C<^!I>&M& ΤSsd{3nX; 5[$Tٞo6:;so_a`;#U:l[}@C=*;RyCL p i%Լl G#\.A }V,3~>f`f_. 7ܯ"xFHZ/F&I~PGդ0sY+z{O/g$TjQj%)+*ȅԅҷT@ͯt&=aʶkI6gzi9>K{]6-r+^>[]  i.MAe; d<-xA{#TmNb8)pFgNT\?3CHQlMdEw--G|ҭɮT0rzyVgǛEKlQ'q8%)cb␟bw=<<w8p ֩u Ӏ-6ꤴ-?x(EK.G#pF:æxzz!!a ƍ(Y)0D'YjCRؕ': vNde#.5G#p.OM $;pso#p8GRAεKE2.G#p8G#0p`!r8G#p.]p #p8G#0p`!r8G#p.]p #p8G#0\)@C.i]i[e<G#p8FaC Y~)t!e8G#pF&hd;5G#p8E+#ty9G#pF&\{p8G#0B xmG#p8WF^s8G#PF Fn$m*T53DQЁL||CTVQ !RLV ٨]Hf*cm>қ4  VOekQ[ Pi%]nQp8vc@(++CKKl^^^GLL \\\,H^xK]i,LnW*l{q4Ӑ8; '3&wKjE%6_*V%b*f2D"IS3bdPεp{$>{Je˜`FӄRųB !j)뿨 Я YDԨ4^|G<*o*͡Ikqx8b2i2hm2(0{l<)iώ⅂[##l.Cju |3[7_0Q2}o0݉ԡ&{"Y]adJе# D8f- :;ߤ4O+x~}7f7G#0hMCcc#1j(UWW#''G \LD: !˾nK-3 q"a(?뱓1xr P6a8W㥨`<9 iq ~v6pr\r RNL2˝<4ݮ @;Ք.j+߉ҿg8`Xfg1X2S(L:Qw] Qxf '=0_ &).8E*TGQ4 Z]>;; 1q /40u9>VF1(1/NߡQ}n 6p$̉D;)R$p и.|sB];c6wpbh|솉4 *byí-f.nffl?ǫpuCf`O3*bƑeYKx|c`ɮJI".-$g8 KiEzG7탉DQBTנwxF9/./B",W)xb|O siZ)H(A|S;bAO &;9śI҇l$x |ZzY-…qrD1t:R"k~&V L#p.(\ł썹C-uK7ϟR( OOO!88jKIKE.m(PjƧgEWvp2l ҏ}<ﭽx.-ё˕hڇ]}jQ`|ң]&.4-5§ŵ<]u?A٩'qDn'MXj'gd߼;eu4##pЪAӍvmY'mo7/G#æt:V L\J~

!f> &; uudșV7鶹XIm)lAЌjl/X["0k%huË YjpFdQ"j|v*!k&Oo T΅-ţm>G ɉxrn4VMƯ7ԔҊBԒO]E|~l$>W9 ڞJRzAv.K' c'ӻ/O@-۽K# R:;Xm+_zQ0Q)̠Yxe\-NRDb.(YOV^;ZD u*O\Ǚ1ͣn2#8nhSe`G$\o> kna/ʧkY/c ?N\:;_9Lü5G#W܌3/ga$\.:Ĕԛ޳v͟8kʟNrJcC)))cP\_"_Sٸ>Q1xq7~;Xhpr|9B-%\펇9-2X{iJ8ɜ)~*nϫexjs4x+ۛo1n\B[e\, "]xwbG??,LuQ]VWٖ.czB^Hy iigs;sϹڬFB?ɭsڵ}Z~47yؼ]DÙɚcx}U1NF:kI&D@" 8 |@$[r3w`9]I}>5sѧ%lgxhDwi &&B1Jj3Y1'7OPI/'#Q_췉3'& %u- }M] ljor\>^mmA:֔(KfK0ftKu̝XTSX=\Z 9LY-]費3OdVۤPeuաSbf®IiCX3&bmSaˊePRXS06lf svx$E +.ScXQ!\ڱ.kI3:;Qg!]UA{XQʥwsmB*&^:m.p*ʉ"Uw^{lN[69=W߆$ @^ (X0yT@l|T(xUd$8|PyF▨6,sqޣ+we'MBzA bjwyc/*ƧxS}u./1iu⅕Q]Ϡז-IrX=zn~ ,X^"PR&7q>r&રlRDEݥX BqQD.\5۶ve]+cPؑ"<^UnZ#R ue hlVtcgI -ȡBz.eNlwlڧ\~k}6ωBD{5] ѝe-ѮLD@"Uܟ( UvA ''7oh…C(@7TgLa54wW;?ۆZ?W{&^܍I>s ނThڍnП&'TJ+em\مZA ,|c  K8;DfxoSxq8[k=eٟwYgWw`9_΂8\4Qt&n<lݐƗLO<~yNQQzO$K+Ԟ$O5.*Uz2gؤ9.GbF~ZǠv$(CS'oiCPH_y]p4{o؇;7<۽@IDAT$J ~EĮS\5 % Vte7z}#RC6wU8Mn^ʙ>bL5M ?N]+6ӥH ۣ/3ƍL&YGS7f4idભWV+*snOzUH$SE@G2< vsN̘1ï ofϞq/@۶m_߯hFFF'{ANaJS*<|!ֵ:`龗TiAYޯNf/iN2mtK[?oG~.7rM3)p.q,i" ]p6I.\ ee]f}.]]N8`EtĘp IWOsQ>J |kz~.i]tNwX`cʆW{vpB6ˈeD@" 86OJ1/55Fttt?{A ^&ʎDd[ 8U?26Gnzrk>fw_ɭH]H߻CEO1so\O2>+ 骶T™4Q!u9-ηAV_`>Gaoc$D0vX-۷oGKCG,{rr2DّJ H!|˞=mC~^9gϰϚSagY3J9D@" :#a4iOTd2NN@*ZH}BWF+AJuZJ$D@"!0b ⭮O&D@" H$D`t prCV)D@" H$D@"pH%D@" H$gR8U" H$D@"pH%D@" H$gR8U" H$D@"p.@M#F>e8dD@" H$\=Սhk|44ZD@" H$g*] 6YI" H$D@"pf" 3I%D@" H$B@*ÂMVH$D@" H̼oRjD@" H$8 kTruۡ7OeLNeN6ttrD@" HF Vi;-Bc&r$)+CK(,Ν;1cƌӦaDX/a@K(Fdt vl::'i)s 0NK_h#%nA;B pX_ b0ovsؾ/_ȮL%z&Etl[#W @?~/.kȝىG qZZ ddd!BF}FES ̙عcTN=;QhW E|F\hmE#.8i!t;5JMfDY䏽iNǧEnCRG{K*:=ct.'ZH;ɕHc%'Áv>#Jk; ىQm܉ѰoٓhɁnK0BOU:DE`r ;y6|ѹhjlGC@`DLކcm^ (s/>W>ID%7pvߺ.4ڜufm :`u|ԦlmhYܯ]|miҒXpހ_)o>irT}@؃P+£0yDZs{nLO6O\T_;hwBB` dCj"s[jS2` :bF,M` DR8@%ti,}+>ί68S#CWN4:JC`j]Jl/@<}Ř>q7Wb"{cmlcב86;RGWnl}*3eom, MAȍIщVu`% ɔK"1!ƌ}Fm)űIHBDu)[]G !i@$g Fӎfl$/IUvZ3C@t"m4c  2*HZ[B|W ԖŨqn!ϙ*DqCzآRYHrq az8xc*%=?c=Ս$ t aر=yJn? E,ә<8+ѝMbY^^sk57NS'-NFben*D^.?kS5tƒNjjߍV݀Gt0(6'w* Fnηazܝ=Z7e 6;\0YHgÂx& ~X7bI +Iɸvn*6onÑ @‹;uE}syy~Y_[LpV\EIdqGK nމt($<L#iۮ~g9ǩ@Ra;3?fE]NL_+b AeVbRk IdK;!RκJ85wӕJMj8:ԖO+a\XYe PlP,o 818 vYXU߯)C ]R]6& -9]$؞xѸ/&7L ŞOceNI-ԅ.$ś7e#=z;>k`-S/y6L[2s$AMv+ߡJ}U\xTD:#Ld:=6Fjku '\j!,"\uuUX*Ô?,$VTبv6:F%Pi*o!\N0ו=V'g% VQAᢱpB}A(c1]rxqu B4yTĢ" Ҏc64+is vR8"tR$#H\K!")s=I=fFl=9@" 8iwg4/k Qr7ogu)F(sqtd c@\jo4Bѳuxe}#fPD`ZŪ82 Hb2u}-ru />'i_)O:.le&s)%>VpDDMK -(?r3.S ")Ҵvo'i~8W 1 3A mf;MdcO8I6P/IHq1p! O}XM Ί @57&;Ǡ q&{k@`0f'<=0WJ=&3I BMql{ߣ /O;T83ݕh_zKu70[L(d抋a7vVYs}?ߘ:!0֊*{+\`3a_{H\C~ytO+Tj6(]toن?mA F8/bB{WP-|h@#KkvPbll+lc.l/!?7J/EM:[DNs7]:wC 'B<;C8֮ޅ"0 e͂ y$|bƸ9Ӊ1=}\?yu+`&`M~>~xGb:Re%zePڍ9Xoѵax&޴S$ [?݂؉5._AE2`b=,}QlRQн!m%KhgĭbA屐+"+}EkJPNL8/K[& ->/{)\['<7~,Be/?*GNO7S/4RkA먿?-( JJKvG\_ʜN"_KR А8{?uf¸n``{%-jkBcjŶz}MtU @(Nomx֡kA$ d4_cKD\g(Jk ʑ6  $m[bBsPw>g]6$܋&\ңjCYq0PyGSMǥtw"9!b.*& cwsU5tܝ在UT#OYR iHXR"Sa2fT`'԰f|±T=08['iɘdΣ8%]nE…74*6I@n(х}]Jh+^LvGp5[sUjkAFYOb=)+;^<+aptűi%T>qjAbE\>MV$[yQ4ws+2Cb__F 1ᡞ6  VQkdn !V4v[:םf*/p RՄHP${UGWXDvI.|+aAtc8[6a%Q$09נLjHrd"3GUn?B,SidžbW~җDj$]hlnlCIe;d`1H5xb}"ṔT\)sF{%eRI 1{{I PWCxk~WC$~ !8R߆EX`:? ]꺦ߕ{1 ŭksʻ֤_` )] "8\-kr1 -~0l\5Y [Fe/?m8מl#ҷAD$b9ލiӦ\Ŵ} wA}ɿ 3'>6=ڹv[)g.'`EpKtb,H arד]t =՗Q)aapKrq$q$fy$iDƄ!V0uJ''g/{>j.,fJtZDk5Owᱷ` B(1~DuеOƨm*Y v sV0a n_FrZwxV׌$iPG-S"q #Cӷ; qaH8 ZI]6 q!z dpJ|GW<jShLrS,Jd GzL }(+#.lwGtE\P`+iVU`o`ىrSA). ?ZUz62ykU0C^9.caTbD 48ؽ)7.KK(,O)I:6w΢ŌPF|?㤘sLdQvWZי)/HQWq!~|͉YLE%G;ghOe)\? w{^lnWg\w[:g,ࡩB6}Vf*"1t=2%jk7ϛoM\1Rn * ;^ȯ*PPŇLӳӫ') [׊?. f2*qM&D`"|zi,Ӑ}E(hbi7ORyrfSw`]^g>ޗw%x1((zVO[SNQ@靼_v"/ էıЮOϻ'ىLhBڹi]D>Ú5k{+"DSe80nl,U+ "%&2=SB $8Ä5KH$"IΟB7POJK\ks4zL .ya&DI~*X[KIPm_n*ʣx Lvq'-)=]z>tqI8GtQL.SyDAȼ,cT/}jB!bvmvZ[; ö%5NaA&ҒYXvTPs pljOє,3BAAJU"mO|CEAaobXmJ 0槞K"9=KE <|?7c(++x^g] W]x6N.S3]~0{!x56LgiN~a*:@%2V!dZ[b66~Q#9H 0y (oR-5Q 1? }<~zy⒔=W86g|w .1\UGʰ qH ˬi?.(k`dFS8\Fw ާfK.?W%`y;e3n&0Q|t;"$)HeES ǒ+fRd5c!4וy(=G{Qj~ͧ'x_&a!bK/!2rlL3k󁧗އWQ}{;7yTW <=ʃo_UFj`HXZwb" 5S{'m!Z=_%_ VV(kh܁VR3[]F#`Su}g<肎 ZE|`=h5vtбp3P!70cBVeg7:bM7;a#يLPbձF<$(L2aMtZR91l#h)V6UpKW v|>, 9ƢpG5w©;|) YH8xm+}*qFn77J!qHE`Ϡc۴v"T8?8w‡nX $'%,vll>WAIUv>\jH5q|:G PΏUh`|MšjlE(-ՊDchc<|vtB? ` PB\܅ȉJ 붖E$Eu+b@ l24:W` (Q^ !!Xse,Œuk6C9w2wz-}Jnێ=9ڨt'Lz*:l+4u <&(\LZ\^ut׹˜n _Q1>k[s-* )T[|(lMؼRm!;Ehؕe*ߕ?ڍgʑFe Hxk>S#'EsBo+%~GSCY+|F Yra?c8D)'D@"0tw~;x{E,E W*O1Zy!  lۯĶ(ϩSsa`_7`&H2~me -d\$w;Vr1Q-Eڒ#JӋ/ @ym)9IC"B<*eac_7Ub0~߭仢siqM~SUޯS- <;Z1ILP=2b|O" u+^6܇߿zX^|7q-Yt0uR.g=l%xc| ŪkAKxSjxqܛ ީUl(f:/-xR!("lxIq~+ڱVLh dE9qҦ;}Ỹ%vbav+?w {/|OO&iFIoc.oz O+ͬf>:A|k;֦F`lJxp ;]s;Wo>oe z|^| |a^c\@<շ?dy@,wp O\7wܷgi埿A5Ms .|˓=tCeǒ}O諟AR*S7"Z◛pOeBycv6|+.R'[sflD@" H$уt<=BJ"H$D@" q0$D@" H${!%H$D@" 8`txޮxEuWіFL #)D@" G eH$_2Du#r-'_c FS2&|,D@"00֑G#H#0ص|Jb /p4"%H$C@]>D@" F`ZQ[[0[2 s(D@" H$WrD@" H$_]սrD@" H$_ArD@" H$_]Fm %:nt6)ے8۱qcJLѸDQ\g.Ȋ1gأnGsbl 0tp[Q=ƒ6'ӣr fkKs f? ;a:Z;A`U$3t3H_TqO|C?`Nرchnn[.,, )))ʂb[T/jH%T]GW)BN[v>~?EX9,t]sL_⥿G.%C:yjv^D{dX=4ącas pTcPX~vB4O;<!fD<~n,|ojH.$P4E;.rc^;5;^exM*􅣨JD@" FL8z(Ν; k-[0 #,#يX-*//ဝngZZ̙3asX,!*[9`S4̀)]p^ ޖN?5IX:'KXB)bm AeV܉ׂ:8w4,%4+w}M(fJc1[:ᾱk8{.r}I72zJx EWdw[:>P~mg]+޾$y|[=S3+"]|sJIv_a{]R5YXX{>WviZMp üdZ{dcA!Oٟ+ JRrЎujBb6WM*7l:T\b%g}oz11Ts9v6Ok[3ptıb՚&<,RZҹpͳ»iq7s:] Y5 LkZݱSa0T(5ůHWHX0RӵX|XOj=qyTb {hvO@ uuƧ y*H$W{`b %@=}G/}3 00BBB7̴hoZފD ?uRTWi FWҧ۷U+EX4ӺEh<Kq ߄ xf/SLP]+CqpXOR_qHdV$˟˷<g5G?H~gM#(nCRKz~B,}iz[G⾃;6o-\%pF x,2vlƆ.Vpv ]L1=b*7?k¸`;JbWonT~QT;wQ9ecpMF89Sj;YcRi'.ÔsIv_tQ|n~N~:|﷽ %FgZ&V( O>?~;jDTb=b3wMu3ԋ{]y,MgO4ٞV{x(S=@ME9ba*,|HX{}fҽc;I'xp ^_sc^nƓMƒEܴ@*"v<.Ju.]i$0\8N}Xuxdr%$ORV}v* Ʈj:+A"yw-"R3l_" H*—[ D)7sӕD?S\=}z_;]2vFy睞bbb 6sr# 4z??bM971`(;} nYӷJP詀aWSC%帊;b}\ی>*C2Y1hb85e,>JDd, ݏ}$奍dI 費3Od2&Drhk {_eFӐ1[oKV/4`ق ͂!bs#(]W`_q!*=Q!\ڱ.~$NΧbwg%5B)K(QA t-Ml"~ {C h [d{LN[6ſKk3Ǣ׎8xG,F-`{ -A_HW8 ƽ¯5aL.6~80c0 FZD@"0FTۗ|{ {_;co%`$ />OSNukhALo!m(O(b4X"/MCxm>I~щ@}}V4ӭnR4X4!Ţ*a-ʃty  ;y:'Gۧ'KqdZ8tO{UyK ▨6,V kg;ܵ奬)-;nj\Q3qN:wgkzbFUa%&O k|}y3`>,O֨-!k؋8?5Ŕa_ &b@b cGZxavT3(.Cq}V}ro]~18wL\G lxNqN4bɜ`>W|PyF{l9EmңeO<A/P{?q$8WWޫ zrҜTDh5^HRݎ%|hr-5m\5cgI_l;xuP<V@IDAT1̸[RP%dlͳ-F}M3tǎ+8a`#]ܒ/Kq.m1YoP )no\72WU7W6SʈݝbwAP)ܜYB鯪D@" ::a ^>[oaو _m6\[fp˼&чo:*MLQӎVi^yC*[ig+no1[|ތʝdj#BVP<\3~D>͝cx,+vE{?lWUZ Ͷ]]N8Z{Ę/մs>M!MY&IU;prXHy\oG]}+o H$+ua$~SSSQPPlDGG伮aH$AS%#!ls (7n-ݔ6S}wHn F>E XOO|v?prB "JCQ|@%N)ݫ n:ݯHO?Iz8JV_XH /K$1`ر[~oߎZbd.O#4%@BiW=z؟ۆZ3ڳo$qxT#]eξ1I$Dt 0b @XXOI&).@.@&I"GD-!ye"t?NU^)D@" H$FL^U$H$D@" |FPR D@" H$D`d *[H$D@" J0*oJ" H$D@"02H`dpJ$D@" HF%RE %H$D@" Ny &ߑL#pd2 gD@" H$WSV>o4ҍdÑz44q:D@" H$g6}t:ﻔ^" H$D@"0$0$daD@" H$T'H$D@"   .YX" H$D@"pf#Pn{f%)D@" H$D4!pʻ&9FAu:n7^+~d݁.'9Hcp N@D*TB1ubG 5-,0Q!Q&݉ѹhkwe`wt6;\0熞(aC ls uЦ4₽0"o.tvq>X2};9h;l5k@^H$_iFLptPUVhm r5QIOMr5/:J[ /$ʣx~]R:xܜNZ5Zܾum4"6%g#/O)ݾv1(2Dvod[p "]{rAxt$U13lҳx5q:Ǐµ~{;vբQ+MؕgG˺/m<'S#ι:9SKFre|G`muoyuR\V'[̗9:#Xiݶw?^9ӷ8h \KCX1'-3܋_kC*T(ϯ-#Y!$uu 홼87S Dm$KS8~% xw}94vIFBU=ddAvdfh`qRMU< q 7##6JNҾ8ʳCf#%&]Rͪf9\qq \e>Z2Ƒ*댉ÒP,Pg7Cx@+pT$h*+2>WdQ0>,2$Dɿ`m{Hahm yU s0؂!W322nXG?aTn!Aj}*鉘{+n$?Z#q] quz&tԣ.\ض aQyaKKb3>>܄ Tiit+D+>C)/5_0 js: Gq,k\YUpn{34. C!G:1&$7 {7^)q8Ap̌vOj倰[krkNz㼹Ɉ#4 t%qQT6|R ⅲ,|<}\* XtQBB_E)E8!\C@("բ4e ]\ًC1-Cx#φEqaOZO 4;jΈؖ|^-SZĦÅn9Ǣ@q B07ƆHlLik ĂK#Vx֛*.u KN_ ;vB7be0Nֈu.0٩X,V]B&`u14ߝ*n!>v4ӵ(6cJYSH%WLH=mcN q.]`jBK^2M3&biLd(G쵛A<699z; 2z¹iG~H$Sښ{` %@=]Ik>\K!sTd2ԁ+R~A A0џmǮNLvi97x&Ie6@\[\R3R` "h|OLƘ*l)M.ՁcXB"pPtע޳KXg> 9૔Ii?cM1=sCUD>Kjp$OLԏ[6ss.OWHq{s;r*~;\T3@##4^tOE8G™Wz&Qލj#6X#P\yf*хc,٦ZWW@P$}lW=!x%$@Xv2Z(}v&3 N@M=<9S;m]qPr8u( a,\>ΣDEayG\q2wt_[߃kӻY azfdΌgPv7NFP(Bjrq揭Þ0dONĸg<*]iU_.KdE`tL 8@ R/G!@Nl6` ]\d*Vャ?s^iwje 系N3sfn@OPcǑ=[|/EH4ñXI6k冢zlBhǺ (𗼙VLd%∸)¿/`ϫ.᪫|mm]"EOAɲP,i1Y2]ÞxusΒ0^G$a=焺\8I9h>+z6%b% K&c }eJ9,q'Aœ8Vf?-;;ݫSYLGp8{jOajd,Ջ6Vzv J¾&' 3u"n[:7aW) [w;'!qaHֻpiZI8/ f:wE eHu3ᓚ[Ȥ5ŒfPj!F11XPl*rt'  t1 Wi%ҷ{p~gL \z_ _m6Ny !\MX9 "o{|/„R`>ոt(Ag?[ |NB[qE: L&0=&F珙ўpA< I$lF:EvZb`Yf#fGG - G;o{yŔaF;yi=`4{DByXJRɆϭin[R]5$lFZPV[\o~9ke AφGE֭+ro3) T% ɿB!h&%)m%tJB2 O4-;OCFz% K^pM66~BT7z頛N&Iy> $k<֌V [ E/J2s%SvOO3?$XQ{i] 60b\p+JxdbS^ gH I1TG2%W}ީlKNu[G6C&/rDh< ]>'a~1n(ېVI񿐃T?S\<܂.Dm(.׋ߙ`W;aUx)0vω9^W}2A_>)TOE裏 Ƶy!.$p5ߤ+]f7v 0̴wM?:jQvVt̪w[2{}WU\F! ZBMGbv-ȫ7{C)ʆsH'Yq7u%IiJI|Pt.TnZ'$DB`Mx;QJQ䁎\ 0&pɆ]BVmWE*M_~qy_4:>@K\(+ OqBOj sb`p++c_@zq*I'u4tʊ Ѵ鑜Cvlb_aR 7 JLLn8WtKm9C2h9a?]KpGQ&>;rҾ.}O–`ꩡN,mvvV?fk%3}hjjMuR Y0GfR'NTL r:Yc+c{q3JpSBFZahCn؄*\'_*k5jɯVʄuO[fN:x'z_ukeB!PhOJrIVң(lPY>QX27&( s hE 6>T :$N8s r!{o5h{yT3d`#|*\yP;!QxhN'6¹}98G~h,NPE (=[G/dӂv .Κv%ңb4D: R&Mu8WSҍ,GJW54IB8vr siSKlN%=NܷЄ/S :z Iwr14ڦZlU+%L5mnjĩBh?*׋krB{U#චf$|wl=isr) ^O9)Xjƶ";_d$,tNB|d%tdӼ5geW)dn4#I:_l>/IY3k)EƲr`ajPabX]AᄲTVA;m.C^2G$iXvn9)ydDsJ2՝g-bem'ZѲPc]ʅߘ`W?̈́mLƸqxň q@ je'fG>ZpȤ#O=JNJstPFBN$4 dI=).fS.{)xi{0D!.IfvУY;{I>P@sO5,M&mbu{(< Y4 hQ6CVZ'&M$|:-= ])WWaOms5`d+M1+K@Q~ek_<7_o?L 0&(aS@^juf//o. 0&`L`k֕k`L 0& 0D 0&`L |M-+`L 0&"V3&`L 0oVIueL 0&`C$0Sn#ONFZ] LJW'SPsOo UxJ;Q w1]E8`ټHxه3xtC%vLLȑP#JD>u~N|x+ݟ0yؗS FL{p)UVƧCzph?/oUk8g=Ǚ@ejK%߃v[A˰»+5_0&8aS1gΜ^rE18x CPfY+0_.A4!7K]Mqvl'q=n&U:X,QV,y!h^X_m $OI37'#^H”f1ՀZ,w=k빨!GSzsI誎tg ͪZMD['$=eXfA[:SY3ҕ|Q}0nVG{ܖ.*˨SiARn w-A\l'πw⅍gUYJZO0EFhm/Wg⚲XV_z| JBd~G2BE ^<Kb@8WU^p?zv8T"}\Xx5{G?J*;@6cfWbg>\,S"e1 ?+)zl=Awpa\UvaU% ݒ%HwG-0ݓB0YZ,%F0ĒBlـWbv%`LUOUqDKg/9>3'`FelevtPE>Ɠ\u,$6UӲDa0ӷ0 z#2WJm.ƣSxN`BCٓ 8go%꠱G'Ȧ ~59YyWŝzH[ӝГ}!rJ/ oiWQmPUF.hg}R*<2-|&ͧsbW5:'i nTV:Q@InuR/sIz\^=|8@ %v1O%I^Pn]~n܃W۵e%)p,DK'$buZ4yqLۗx6K[G1I7#%wϡRH! c ,$6qqe\Nc2R4VP>xb*R=+Oх5R}Ë]QR, JoBm7?Ze.N%UV/SeS\Wр4d}Ծ^ ,[zt^_PC1xwn6A&W8ĔU )OwԷJ'˫ #K1LO+V}=cKcQ}Xְ`L6@4C;IppR/UzqDʵsKU6J@GBJԔhIh<%D8Df_NDs0<(_~+i^m~۔wt'\("Kᑒh_K ~,e:>%* l. yeo:Ԗ5!=x%VV'BtN6mIKT2#Z(0POAI*#!x m\dT][:4 $iRb٨LwiB;ٱz]9G*ݒrXr*P~<ya nx,J]z @*_*&. PM*?~%gJr)㷈U!nHY|bR񋅲Pg.Ϳ KK2m򛱗Vj;.I~)BIiޭn_ۉd-^k.Y:/4 *iAcJkݰs(l|SBSmA֭_!$#5f,uC g'+Чaè̦.a"=YHj٤pyTn,4ɿMJMDޱed1x~ d3*@ݫ#˹38@Wgo,K CV ;4CǐHJOEҩ~y\RJC-\+.!٪wGpZ(Vtj]EgT"Sev6 Krg7HY4.NZdsթ+mRCNΖߙ`W*Bv ^ +/aWnf?^لJ}Q%h:J%;vj>rzy oMEZ9B3Fm( A}19wN Ƃ_R61[,lU$RB"^vU ԅ+ؙzT+?O ٹدՓ',²V:> (\l췥ZLFB4m8/IQlt9;GNZѢ" nңNgoS.|}/]؝j֓X<C>d(bñ(Y5xJFZy/6xtr8URl8U_ifߩ{fwm͕8w&eiacq@&M2Ri/J^6i2Vt$Z, l- b$;)dX~>~~?#ߒ3&wNǗ:p Ně q(42lJ#vyU.Htue ~nfMNd.5xkS%޲e = `ܕcJLԌrlsxVO9`U :A+"<\׭n~<]gL~EE &PLv.+ >I藜hrm"s%᬴j!TjXvB- 9߱mv}C_iM6֜C#@fH!"aIx2331}t ֯_YfAKjZ:ø뮻z#MHH?PQssxCSs8hf#j;Ijt<#P(.Ä)Q47@'޴FF溬Va6 D[3j[|Z5ZV6H(B3ZȿNy2zhՏ3AԽ ݱOE#hed@}}csd -t&mŀoTW< Xew_o_DQ`d9saARR{ ἦa"p8!+C'Ttj=O:P;75}QK!넰eWd^:D4j osLrbqMs1IƓ9{}hi2ޟ!!p#vcL Xæ$ҵxoFFs)!Erp|G Utl+ 5 BR;IbHcL 0!0l ?MI&I&@*'L/I@adrm.cp[`W)B^W\>`L 0&`2]dL 0&`L 0+Wur`L 0&#Vy`L 0& pUw/7 0&`L 8`1&`L 0OV@ÝT~$bi`L 0&F!+&<ÛtHŴ0&`L Fl4{`L 0&0j 0j`L 0&0 0{`L 0&0j 0j`L 0&0  G5`L 0&z!T*Уك \^vT׵`\ &ᦖVT51ͭ364VUԣXo]5fXF\4Hc -}@cű 4D}UT|&15`@~~.JK2?c0:8CTPV+[Cɒ2a!P}~p*~E!S\<94b2pWHS Z$gDx_9 3[:nŁMR^{WIiDމ<|:) #aZ؝+?6ٖ+Å؈nŢYr7 g_U`GrVvJL+3ѵa.wi?gXTAǞXkh\`"Ɉ;8x )jCD p? _/G٨=9x1ŐZ' ɞ8!]*P U5x[!! $Gx^)!P5h HӪQ#u[a~Cc <o+A2K%@Wx0Nx#abT}g̝Ds'ůlфcK¿(,7[uXz6[.73HCm`?Ԥc܉?9zl( [ ,%ۢƼ1zIp3cyzJXZ_,ӣKCٌ%`#/^ǒk* b}ρK__\ %\ܳc#c%hKBy3vlz&&h &+n\ J&,J M$ :H8#ao9P?!/KB4 H”DZ!E$̩ ކ?mTYx([=: M8 )_ zʴ ?Ѕ3"ӑ,9U )o 3 %xY#a2bP1uG` %]odzʞbpESdY>tMBP#g3By6қR;n f(2pWT ^[m yg}hyۡ^4A')~x$JI3˿C_Os"qKqORڅǏ:o6 RAܼpR. 4,pwe,Ǯ3O7W3,AJtD 9Pk(džN<)ӷ2qCDurһYǒyz&V`΢w-n.3eIy10$N>ygDW Qs 6("LJ òX8YYHeEDb2, ;&;"lTJ%7FϱR"Y5K4hm@Kg'J`=.w, A<M i~w5Ch[_B1|pH"Y[:C&,%S)bi4`t|JJTN<2|x5)=c1 MR}>(oACN֤A8 *$M;qLYD1)'f]er|=3\hm2BKx&bŸ@{gw]]*ݹDv ,هw;4w dd;}4 ؛W yTM\bRQx3D:I}p3BDZԓ?`W<7 [,dĻUP%/{_ %^IßL0=O"9F.lG;+s6z&v Uo !?w}k/) .w7&H$7NLIčJ3#*XeI̓mo0VޱLE㏛I%M}׏G<;;zkgt:WO.c?X7ޏ, $x" CfF>,5foVH1 JJi4C}~ %d6ք8,kBܩ S+|]sME}O|o~D}DI-j!6kQRT*( Mr&NV$v*ztP^"+<)dn{q nZ7Ż,"yeKXVp“dh3WО Fgͻ$ uGNanuM2S{k'Sc)^ڝMz(/"s}@J}4M_1WǟL 0E`E$K uDʵTf%@WG $1kT'g!h7lo]ߚeW;Lgn<,MKO.43|m]/&48X]R"njҺXtl/eX^YpU 'KzjһU X{UZ2 &k1wlTJI,ĉ4NN/D<žJs|Jŧ**BOW;tz )՞`q Nl-⎙C>F:J757fh%eI-<߾hG*4%Eq |~^j=&ϔ7H;VBl*bt&eƇ$,qڀY*P'B$?GɈf}XS&)RV)o$y \ mN W1a6փ>qdCYԕ܂/p,N4FLz|D mbf}XB+=4o!OJl؃ 0&02 \uO`/+腹}ϟLРC+KS'>P8 %: Nv 7Wtt\ǛcH5M2miRz%ʿZ%ݴki ><aOz ͮޙS'rHvm8VǤ}Rh~~N/|x_i}lڇ-AEr<8)*RhY0K$!n؜|Z蛮|䋾˃VIZ oT/ɕS~Rj i\?|b)n55!^VGϒqҫlmS#9x}_-8r65#xJ*|ˋeE5Ny|BA 5R6Ή`#fWLĎKshxlňZz{vW|  {4CJ:'j5d%C[YV"(Ux9jZmhC3Lh4SJ'4p!,h 3 #)Ej YZ~EOV.vUO%!\ )%ԥP zͱD۩d:ud(KBQ9(KQ^UUVC4^Gd`c;76'kMߝ_1&@ѹ>} rwtDtv&OBXh H%ʪ9{"vL(GqC| D“xxObvcxF£rg!;D!'K{{/ҩ(/Hx~;-{q1Ld"\Sku9`L@`9]"!WXœ"0 |_;;5h+N hl.m֌r`LL`??N $ W Npٖr >1 Nf!Ww{yKǯ^]0&H$0l ${%+ l{|`L 0&0 \b> 0&`L \f\f\`L 0&Xl&`L 0&p psqL 0&`LJ`J粙`L 0&e&0S:ÝT~$bi`L 0&F!+z~بc8󿘊:]L;8 `L 0&$&@߹L 0&`+L 0&`+߹L 0&`+L 0&`+߹L 0&`PT*(^n6tښ[Q^ۊF%܊2],/i6VBf{Y23Њz.,uK*)L]`60~-cMl;F6u]| ho3Dnn.2?bbb0vXt:qj5;N?J XOO]ue#ъ߉W㜙H_w.RF''E_IH\U`]-PC`l'e ܹ?#UbܟޤT[Sډpu1]E8`ټH(Db]3xtC%vLL+WRƛS0މǟkn_L7v]9V, j LJW'SՋ_wcҨkz<<ƧCCa|`{S1ɝbFVwLPQY,y3WR;#/5~ڲO[ceЊe 0&p6󨫫CZZ]"ٳg%<55ex*¿+  JdY(-dcԱxP[Xi8!趘<7^r^wg֎nL*{E٤©=|qc^ \ҙV,8=$0~ ;7wWXi)Ia(*3ZeEy]^ "H'??nWx_߄^B^xSRx7\m.žg=mRHi:" >/K~$OXgw|>T$p5f&9RNS8(=Y'TaR\֜J씾b<2 [,sݑ?-ʷ;ˣXV!BZB%xUogc}bF7yJ"9B4ЬYmx\Ԑ\Gp)=~9HsG%G!f7]0& \TPitX:3 aveT⭬:RdBewVt0Ecf,%_W;?m f\A,~믝JqK9qǯ3v^aUbTI Ae` cP=%;GuKZaʱ [p{gQ v7@yu{,Wl5Ӫ~|J PM4[?~%gJ+Qx"VLx}%}a՚iu"RMlܓn3$FϭkhU׳IAsLN1T`b<&DhXM*/uÒϡ[kEAH>RJ  og۞n7k{%XF̶E3 @@SZt ½(ay:H+:'; Z!84IXK @bt0%fŬqCJB׮-8Utzűlk1?m,Ug @Cv.QhU䗅R>z2&:;HT2yp)svV> /vEh<we\Osqo/*zܺ$-Bb)ʸ1Fߝ:.6BbE 2Ni6Mv"AճNWG8*+oLl Ov}i؛fpwΧ@+0G>Dcо%Kߌ \cپ| p'~|Ab]|1C%$|*?sCJo~t;]|+MjZko)Қ|(&8˲.wfahż;X]Sl^UTkOdESNx[cR[v;GJޠܡv*jDy pO#Ö٪)t唿-LNx];G~)TlP}H*qzkdK;!)M<:>Hbk[:='62bsAPHEt!efuwEb8NIhp*>/ҳ&]-;ɔLj$ʦ'NR|*jM>c8pftl4bOgtZP,}Jf@wO[ڝ|t-LΈ5nṿɕ3g*^&+ 0&pP0|bL%33ӧOwh5kz׹1/6>G>(:b(99֩9*&@O'ND[\@M:G5Zs$2/=h s^\+a<<lz"hc8Cb룝f$B?ZȿNyղ5h+j7~0cGfljB@<u"]tޒp;CC+dUhtWh5uLͬXI_ C -ti5S{褮6,t|xfj,;÷L 0N/Y;{saARR{ ἦa"p8!+C!`SQPlttFh|ө3vwn$_jxC&!WwQB}nKw<+>d IƓ%hhë7yN c/뻿V).Hmwv CM< UL0Uu,RD颮ܥ`*_cL | W׶&ҸxoFF ^mM"p9E `s%J-?qeyuv#@ȵsZj'H#\?&`6æcڴi4iR  wwKzeHw6 /.c /2N`L`6@k`L 0&C`L 0&UB#L 0&`L` X%`L 0&\%`L 0&`! @(q&`L 0&p)@I;HŴ0&`L >CVLy0v=7XiaL 0&h4: 0&`L```v=7 0&`L`4``4: 0&`L```v=7 0&`L`4 @j4- 0&`L p+BWT`%G߳CK+ZlT^#ͭ36utE(i4چ6Q؊ږvzm mS;j4 -Tv]c;zD㩊4]ıPf-Ƌp!(10Ғb455珨$$N2P<_!ՊP䴣0 7KOa1e#ъ/ t3ѵaھ s2AB!oBB !(CP|-Z+EQ[ުE?ի"j@WM@  y'$L2y'L9sL&D(Y3^w6^Gr yr<!\nKJŰ0ܳ|;ֱi2:,HԸa<-j_EMZ6 VN] M*G  W2]0/k;!aI*KoK5h44Xv w/BY-V⋩)Ѳ` pـ+j+8\Xo ;׍{]zZgk6(M?O(-ëwNTCOR]QWRZ.YC|l{[B 0&p1- 7+v1V|SN[b/Z v|;SIEFJ2`M5o7kwvMS]>(4rN.,./5swW wqnO2W!u޵x`|8/!jp]'JV_lI bxl #Cy9<0O8-{9 ؎7Eķ: wNFJb6]uش =jA+Er]]WO5" ղt\5o2xbժ4j,*k dF $낡jGH?  a87WT#`Fvh:Q7;h[&dsbL #w rh ckְ:3b_$u%_sbC%UWܶx!y'%jL1ºU4ux0nǗ)qxdI<"43(_1ۻt|Ҏw7pՉwә;*5k UT~lT]J #nPR{UPԓtzh>7tV+9k„$ 2xMAc/#on;tybŭ3z6-9A+:PkοkNd$Hǝ%M`?\5cC.ܜ"NH 3̤ꂸc>4IYmn_j31fᅬ_eK\l(= vy*ޟ4i|߻=ܓ U|L-hh^<۷%tW3dF+Da2/5VcsETU2+iahLU*p_LҰ _C3Qo/Ü-~iG9Mq>eL \ 666N(;\IW}Ĺ_q8H!vSK/Z0'لduye`M34_uHqlMw6໣5jS ىH,+BrwQ>^oD_Seh4MuUz{Œh_0vׅµ-Y~Pt./SzM smS W*۝q˳94[qs!x17V,Gݡ| .D RR %qIyQD?VK@}B\Șo.( [6#4~խh,(L?|9'Gw'_Dׁ}Oѡ,D]R|eg,93+c8ze17c$~4-/ 6ґz, "١ho%Jήzv4x؍3BM.N 8[ J [@~FOwwp,K_\<NRL` 3gEc2T~s5F3k-jlىd(U'Q}a_)oǏwiP/45GYѦ<137LrUW#m*;dK̝ mOG:&cDRӀQ6H<";+Fu MtY=^(;j%=˨'.NRyxq3nH~*}sqLfc0KRc^hÓx`ҽSex$DS~XyIԧ ?!Q5B8/u+G9Jnbz0EX[W3^ӎE1H=Y]vسKPӞE]}Ҍ",~Jd$ScS~;0ByJ:Z-K\;Ԩ,)=JcA@h80Z([5,!C?LޟLgŬ.v`XYW-bW v2"bBK;;<1ux{ꏙZ+Z+3ʂUSN E嶩& /~bÉv>eT-2pɨ-f= :~YଟJaQsYq\RVuH7f} F2-nNZIRhPII,+]_4~$5=W7#5-S *ӃBbD2ZtME&NKS>- vv9Lh)ihPN7aU|]I߀]d8Zp=RIՌza%z\=Mr4IՁLف8>Q~ƴ7Nɉ&?-r' 8Qbm\e`[ιmkB&dG_bv }:F\ H M([v %Y>ȕ 0 ="\@ 5b#7ƪ ܑxfg9u"ǚ¿l i7G~.l# =.9&# Pk\adOWkgFmwHȬ] BKU^4/N<Sxo{)TᝒX{Fe@ɽ Yֱӳ#0 Ϧ k2zaN o/yv+ 6,p>Hڐ`29^yxt>x@lzN&m^⮈&iܣhQحAz/<}I(IS9Gg},ߙ`W#_0ܞxg tMq21l+8]dL >ԾA) 5Yrr{O⣢Zˑ;T+sGZ<;%=bXJ*@Z[vFW$,Iq֩|Au\Cx64LtZ6h6ț#*`k2e'#҉~Zה§˳yx@)Ζ\|Vm^[nH ·r>x If5QF߬cK^h_, ]I6d] ¨zy{6}{/o{\tB+lR(BH ]Bm]UR1ʌU: H4PXEYp:׷H6VuiJi^4ӍC9LDž~[h&L W]%465#;*AiQew"J#iSoHKSpIbZ)d'eXCqٽ'kqVNK0&n{8h#-; 0xGarT|0)M7Tku,hV(2,oq~O隋o~u )HڌUyIG`,Ud[ƭ]yғa43##iW.J=UbJ6MZdy`eؑTޕlbSkɨ;0/хW,ı<0 [L߬+tFn@?u/dTebu_M] sD<C''Dïdhgux- !134=\]+4VIX-6úrix壚o Q8$V3`ą#xY67FDž~ ;>_Q6p:yCwJ 7hnccZcM)G ?i*m7{.Ԇ;'^> Ejjꐛ5+DFE#L$c\HU % :/(;I ATudy$ZӯݮQxV*ɴ_W2-N-)VQ(&K1;5}h{d Ijw2lk"ßPMFzbd}ѕ e7pH9~r劑RGJ[|dL F#Lsm6ZZH#,6G0 3_|~Q9Ey=^6]!V 0&E1`X_L6SJ!@!@z0= ^ %@MXՇ[ w__Ce`L \F{ ;˸l_CubL 0&rm2&`L 0&0, 0&`L \R `L 0&F;#E2&`L 0+;Wa`L 0&v5Z}"GZ~_Kv%4x$`L 0&h&pkhhHʿůD.\ 0&`L`ty߹L 0&`;s`L 0&F'vF}^3&`L RfL 0&{`L 0&0J @Rf'`QkC8@nn]c-~y/y;,t3 m(9߀Z n4k;d;6۠\֥[_WΜ+.HQ`L 'pɏ/R'ܹs@SSbĉi̥^Tq4L*RXBoyIaˋ{0:d.ޜ^R'epr[W*FD+R0w68 ͧNS@|wUsa 8PҊgtk\P7^:F(i4GC[#BA ؝D]aE75{*hpݳp[cVe_Ԙw, y4 FR&%57bx79K;ećOTs6+ ʿtxdKǢx',K4{7|̥1w<u2?d6UAa4m( ?gn$IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/_static/treebeard-admin-basic.png0000644000175100001770000027351514556473264025050 0ustar00runnerdockerPNG  IHDRM iCCPICC ProfileH wTSF "%*^! $CؐEׂ(.J\"kAD- " ʺXw~oޙy;w̛9@`9"a}Q\<Px-$m.fC`!݉ V:§z%@]wH -+ .p,p,wDEx#6La2Nd%#~h-la[X\&2f+$|aI3RLfgׂD&eҘY3&=MLFZ2W=+O]$e~bHCV4\q@2fͱ85sBQs,\!O 9szϏ1ܨ9ńqFj4lT#1' kL@F=/c.7*`Ngs||Ï#yIfL4)+FIf Da=>#DkZ,!/+{"CgYftkK+$wNb3w heze#͡[~hzt8V 3gD D 4.0Hlx P2\`A>(;^P *ap 'A 8 .+w#0k0&A8Q!UH ҇L!kr|`(dhTAP%T .Bנ^4B/0 &ÊlχaO8J8΃%p\7]x~ OQ(G7*JB QPbTՆBF PX4MG]h4  ]>nFwo Fcq000ɘU|L1ӄLbX aSk۰vl/v;Tq8W\(qq >0[x>>_ş_r}3!&dvÄ)<ѐJ"7K $ID 'HH%AgلM^B)ŃOQSj((O)d22 z2f>7Y}YOeٲŲdoɎ rMSC_Q)(*+\RTo*zz:U4Td((*WVWRPUQZTtNi4I=e eOeV>*TQCR;vYml,hqjqx\|L|ub{/[Rå^[,mٹ˙O%`bj2CỦDFby8˛r8/\F]w'rݹ176% "cjhشt|zB?߹BsSA``ʽ+DžA (ciFHInn?322?YujjY&Y[^fe#G;gcZϵu:[?ñčϵ-)vS[Fކ˗ov\{[ Z~vgK~ޞ{;;;ruH(hh{{ |ػ|b}}}%%[z̫\|k}=6ThTV|9;tҿʠ0pGbtKMZua"u8ԪuK{om0ol5''^뽓A';N9j8T 5g5p[ZZ{hsikⷣgϖS:<| &c/u,xtiѥ;ݗ._wRgׅW^sv7oln[Z{zzsx;;7}~#}pцǘO?UZ}o>|h5yy/(/_j9;7jׂScY<9h|mU`c"ldǂO}v%˩U_q_Kkt)d(xwJ294# ]%l,@rp|@l8y`iETIH*H.3[Tj$w@l..Pu@ |h453!\iTXtXML:com.adobe.xmp 768 473 @IDATx} \TE셅rMQ@CETfYfYVL_YVja&axEPP;(7e]v3셛Z|tϜgy;s|3sV^`0FABAj,n+F#`0&@x`0F#! F#`F#`0=p]`0&@x `0F#! F#`F#`0Ci0{p"\PaYF#`m&@>!]K4**zFUFe!ب_i'"HI;G{l5\6sW84 UDއa#s^DI,`0{ PLdGwyKͶ+Xa'v15jy覌 TmmI*?X;,;6RD*? А0FG``R[f[؇ k'и*G7(Scխn+Gu׽?.k%%^] ^ J<\uZq9F# LHmL]Cx.m ;8xoO9#L泛u#ϦW )ŝ -Exc(V|كWoQҢ>s(1@0y/t>wY"~ .d&Mp!j|DgӭcxwCJxL7O;* 8Ooye;5%h}vWa4\& ?-HfC\TuQmE9ϐ,PDϺȖaAv|I3;PUu0ڟV No8," eZ^'uf4_&6E஠ dABbRcWF-϶gDҭob:D]?'>>®G^e;f}ѿi eb@Crn Casp`Fj?+TEW^)3i "jJ/7IkOE~pH?fv%6.$ BLP?ֵzCl|2*sņ{ 6إsS+scGnxϴnt[wZz];6< 4_I3lTK&%;w<)!؏u{;iLr{.j!)QXӕ9wXBW(n wРᅂFM֑;|u'bfyG0qlKodtvvvGj^Iykۼhz@}daq(n{"($D.a]n(bMN'Qׇ P:v$k/;"%5Yg_ |%D7ux "VFBSHu(#!Pp!; fxZT@s1pq9y%pQryߴ0V<0%(n55L6G9meWVgA%>[b4׳nUVGLsC)+s_%ڛ.Hnx2ӚωN\xӨ /5Z1>(p?+&ALnhfմe/}S[ Bo[H.:S?Du[kJ嗢1I_Lͭ6ޕ˿4C&|8Bn݅x~)<Աߴ^vrגSZ^wA5uۓ.cAſlHo%ut9òV.F1!<걱FhYHgoXUvz`E^@ +OYSB$kl LބOq!>;ePJڀO=U#ָͩܐ-I!)whVwοjV CiyJi*茱a2ܼ*_SvtY5=KE#*ҧ~ʜLֲ&`-ocWRoimB\u*&tБ+dQQhٵcW+Jt_aŒżD.oņ>JqoL7Z¡3g~ #mǮ{X QbʎLR>όsj[gk<>ާ9f5߄YX*\\mX<TdSu}}#do1kD o@\ϥ{e|ʎ5%@8jb&RO4q~ r8̀&1&F;p.ZvI)܄-7!uA~uN$Z$oN(/l̨6].u (kj$MFD_w(!=fkqTnݡTu8m9/L.a_֌`8Ei<7᝜ tO3˰2xw^lΜw0q ЭACբŬxԝqBݹ4.&0f4-U4|S\Pڟ?Mhn6C>V*XyXC?ɅV 3\:k+a?;ҺʟYX7y!?~pLk<@n$Ұ2ø 8WH!7[z Z̓R] m `|NA #9aOP1 Rk'[TQό|0ATmP'uuGepwѪVBOYH렸V:A">Ɉpߙjoj>=\ +.4/#`TWZak5(Y%A i V+ܵ-޿ЮqϖQ EW CSi i$RIF41O1*`vb.oTy덟8g SDzc.Tq#X5i&5[C F3J4T<FjګmyvϿU Wt8sDN]zFAfz)ta 92Xw2$?d-/m!~?<4ê^ǗyrW e*n*nVs.+**iӐ^r5Zh!TTv@ŦiI9'o^0];yi,r]-rt;vڼq&ȻxM\}4{x[W2CͲOhtJ!#!dp&a#/4p&.]4y7􆑦[KN*: ~ dơ"uzto-qH_ 2Vbrf7&&͐9ʔؼx9*p+LItfG! 5̯Bu0G+,gW[j{Dgv{e_fpjWAW&cMRןM9&.gM qw&tGf(X#(kEzϕ]Ӡ,|3 rL؞~,~YxL剫]t?2ىB CnA?炗yRDXB)"%j6HiptN_)^N)P_%+B g0q$yT[6^- (eeH .=aL&=4\~A!')6?;cYLu8ktgz3%۴PZhn ;~t * .!#ڊ}Z"c_Wn!-K2J^;d5PV@H8)6kW˴u=yf7/1OX}:XtHgqDL[!_h+/,lf Yc&D2K/B˦& F3]"P3"M]gi\el<=fbRVRړQ#mhE+Ꮝ"7fs A]NzN,ܙxz 1iCcM}_o> QCDn/Z ir8bTѵTJ=pWV]AڥAip5NVB NfDv.BٽLJ/SXa[Aw a#p>Ʉ标PFF@_qȪ6ʋ4en|?f|djֺAYgm_?`U>8%qF?r3PVٱC n!ĴIq7ן%%c~Smx~/l(Lq~ ="׏ꪕpyv[Gm9Q]Qy=(4bi[kPbmIŋehO3v,-'BQ4ۏn-7N[tO'ۈĔ^#8'b\ϒ2E'1Tm޶'<(ONF(=o03@:0j|[mjSy&dzT#h)y|A*THkGYq[L9)PV^8 >/$nO ůP|s `_N'̚g`6nԟѝRn@wacص/Zɂa06?߅2;eϸ~m9E14-<{kB?.q4IK JyIxj[=ح'5p}[;Fp Dv.j '5?LL- ۀH!e/wl\ζ^-YoT57u\G,^|z/݀ t^lW}5kuM-kҍ7W;e(o|;h"$m5߼҆5;hLa$H'ӻ/h?Ip()0)br "S@7|(O*U OE| [] !IQh%w( ]i<˪z=]Vd6|:C}tM-Jr5Ȩ}L?ךE&y{6G U28=Y e-cn=RĨ7fvBy\/3hv/r4DB>hBEjL} 9aN;rq1lU6N/gi!/Gnv )jpza]3:G$ ?4Uh\Hs rlt.[5+6BpRTL*%#:mKnr!,'ukRbx-k'h]pF9Gr "(^%? BեM9O~XiCY}NB̸~4yR: r~*Cx+[g>_6.BlaZI褳'TmXճv@iaZZ|Qӌ@)RU1@a$V[_wPLݨb0z%mjz\뛵ْH2vI# ôiJK S(qjQPVRO?S!$Xd1ʚYSbXVHfc<_gM~2\h4rʴKgZNX4m aUYde(Ϥ\k*!qc~^ H3g,4.BX_J*dKI.aүfw=nk7/Y1@3O76:`!=,Ȳ@\Gx-.!׌F-'CF2RB'&k6e𝸮6\' Ռ[S#`00mGڻrƛyAtky\*:]6&4hǝ$.|z4,5}#ޠH@񉔽9{64J*yCc M8#`0/{͸rcgLLTR߂`0F6Nb" (UֲG?N+A*kZisʦuR3V`0}.a0F#p?"`9MF#`L|`0}&@}b0F#`&@sF#`{07#`0S02Ec0F#p# }ŸF#"`۫y#`0}]Sep0F#X@/Y'a0F#p#@_ﺿ­`0FAnm@Mo`0F#0(NGޔݠT?ր E?r#`0*}{^Rc{٠Mk?,^¤06WVnޣyfUtƇvVGuo[3?:A5^S դG堍:teGy~[7~Ų?9ރmM0ƧF#04+P{^?zEoo04kdAF-9`m-5n`6,"N#$PM6eQ0>G ֈiS4!ǦuS'd^8#`0#@ ]'`cX,k~5m0cίZ tNDogSICu]ƌ|yUe`2A\T/fk; ( 63'i1~@#75QavEem{h@un[nh@Uˢ"+Χv8U/W=ؾW 4\@H9ůs7. G ҡNbPΪ+70e֘a"U{yFzVt`JU0zj(Iw9ώ4O\ 19Yנ0$~f7$0v*v ~׏2~S.P&'Gd a\t]N&19Og^@t?3n4˖X\&hض  Nu9\k_xf` 9dA!ΟkkWՍJnv)qO*B:I ͚37VqWEa)0cWU8i_O z? >|?Kmi/>;bPg˩yQ]zk̇">zwj8x*wuiEԺb:{Zs_jd^}WE+'EuT9]\,<%xhxSzQki^+a<&&Pٝza^ntyhvG#W1hYעf/ɩ?'gM(;> v<T#b.3}VAMbeD[|r˰I^"ݸ!kYhm'Q^P)=#/ih4UA^[vEO1ipOV͌f0xhhZPIn_ŵcJcg/ϦvĬ %V:)׷NJ|`0 _b}]FxĆ{ć8n`ؼ0{:L qK.R88p0֗(ɁX/ ?PY%I.\gϋܗ{Sa\n\d]jRhD[i^9)ℨ G |ih0ј"[?;`VW7J%H,m7YpUNfȂhD\Y*`KUj$7}\?p &(ZQC%>wT% o'ౚjX.A&jp7!U$υm[sD$-@QVx FeɣetqB!*#Om ؃o`0 yY,+Ez&^ H#V*bQ)apQB FsLuPkݘN:NH)4m cXBpx9Ϋg95*`\+[eZ5RH`618{XW$L`x5XlQ͕+́R5 ).kPC:j wxǏI+qBɓ@ 9фΰT0c#+h3*S~ :UP1!~1"lg/u.NR5g4!UVpL&X:Վ4ڬdY+} QVp|{̞/*z%zβJd+`0"h#@Rp`\D RzwM~lٲ~zٲe)n@]p E7{z:R@2Dњl-aQ"bgy{_Wp+?fڪGޡt0I#[Ǎ`iUٰ(U:Heo\Fb_(W=j% Xh5t66v̞ P U^.-ff]1uoi.Onc?T%K[7C=+#r <[ʁPi>gM%Dfz0ja&a&cϱ]hɧc]h. '4|}f&7+mW.L |!O/=L**3i \ 8jYYkDڀI]fBmebWM.Q -U^)\ P1<19.e#Xh"i@f={vWg鮮u~K;#{ZXç.7O 'ZRa8{.\X k^Y`̷Kjel0O&`S!"дM"Nm6O1:.Qc1|rOARLL/>ՋkEc]#`Pgv4jywmcTA0g"fj zn:E}3[խv@]Z._FuRA Te^4Ekhڝ@̏HeN^~S)a(OP0Xkb(Q_B{]Q)TF tJh(~wg~KhgB:(g)}F8VQn/J@W Owa6&2_z%:enyTsqygb0Uooܾo[F!U|o;Ak7鎵aF#! ߮˰F#)98`0F#xnXF#`/b$0F#_q/Nƭڶ$ i/mMkצ " Ͼ*Q*qi4Tn?]*QOi~fv+`m;80.Y=6AÇ7443aC\pyŷLjO}Ξ *55"FllٴI"tAf`dVvLo$xzz-`n3v\}C nEZVK$Jcws4)E_>]Hf% x-kn0wWq!'*NSo#x~ʼ1/Zuꮌ6 %yy'Og *k$eX>Kh:^69>1dZ1zUF֭[elB>r̙36MJn=q 9gN\x1T+B\Ӽ| a$APQDY\/sc* D:t Y!m B@F&PnP?ern8j}AU|wzrFDg(G <7I^mzUN3هAodh?-Hခ4=בk^QQ |ШbꦛM-7[FړQ"VySm}}]xWIk,< ꩮk,縔:ELےugw8ttu ]!i7nJAXExvzJ[3Щ L^F4Jٸ` ^M ?N%*onA]d=L&e)fRO#Uݶ6%%&^e{vgxцdty ^F.큓qgL0h \FUϑ&9ȣOJBSDg9'SH! U//5-G聱CE0I%?{8Z;<rD$yi]l 'rn"zA{l'CEͼI*H˩}[.CS8 $O''P]@K|jfkzdi`X_ܧ=ڬ Ʀ(jsgVpT>`gl樂X/kZ f\$ $-ckע~w6@VZ،3 烈 !J pBc߇23hZKS{Xl [bIEӤ)=F="&ɳIg=YpĥK5im/RZ+Qh>i# b(D(3uō g?4>ڍ)t㏳jPg_S z6Jn?M#_@8vͳ mCzK<^[\t(,g#k2-Yoנ(# <%Sg@߉˚7#;EVԘ K%FUп؀hC%k+,fCG?WPۨm%|GP&+w".O #4{7? .aK#3WOi.>E5ƅSu {BE] |f&Z <)Ĥ(MSXIp )Yq! N;IG#+L.:}!K!λD + ]x*ޤS&);@#+.rJm)aXT|U1,94:EHBAQs`/ Hø`~c %^r֦F NLK:BQTV%1W+r u;"pFM625ld7;/o B%wpݽ"N<O}qNoD@@hq4l42Τ_~ZQQr] : qܼ&E;5,XTbvp5+Aj=j[^KL|>(&.ٺeyc;{S,YN!_U*&׀Zz &(X۪o$Yg'["r񑗶)8uwQ4-F\A&9KIMձD?(O}aB"W)!.EG6g37v8|l,Ml*mb5 ͂W/Qs4蠷DТc'߽9i(>rdU?]cn40~gNXIxr<er{h7?A(>hқwr3)Aۚ_dQt*6%`Jұ/%.mK)[l)j}^Ak/Y`PEө4Z,qp`7_҉ccw0H]w$ B Е:y|?+Ch;$E?zkyٞ > O.pҚNbLj R.] z^Ç7,,y !ۖ,'ӠRWo"8+i*v>)s(g˵%Ss; 8LfT@,T "G5/4:Y+rr2KPAʻ J޸ uF% @hT#ea,)pN)\i)Qŗ朹P'sEuCiGw~ L.~pnIT, gt'zptHnjORHj+@SwZ+pʏ=N%ߪc?ks4-u/739o&s9bYv4:ܣ'PłIwu IEa>3WڧZ HZ"^u $ 7=b94#v0wa^|B 7Y~ &-_8_xJ)*.AL?*R? . +ѲM6^gy{g{cY`:З jש0 2(fy :ox@tЌ#}oEd'ނ4V̔ki袇*V:;؏`5Sh9v"4{B`tG_:L9=4n%f]>^M+j*Z9"ľiY5zxn9WۄB`8L:.p]&M`J:tx.. rQ mYeD8=Q(L۴yLե@ [Iq]iSCvסۗvAWPCaiؐ9 :u #fY(=7ňy]I *"Csfp ,~ ԘW.O񻙕qo H΢0S{?C$fim?鈝;h@0MSB[&aɋ?E)>˟EG,e:bKS~2A\VD>!} ITngo 1Sg@)IE>֣?n|CunxW=L]t'LeFT\@IDAT%Z` u4R xc5uiI{t0`4lys=^pa0 h$AX# jhL0ȃ!&vS)wQc1|r(Qʥ f@x -HA뛖{ sD۝?_H9 p3mpO=ڹx&ه {kEǎV!`:՗5lT%tONS<^V)$5c5QڋSW ꋎT15=|tq #Dbqzh)̫"i[9bJ"EDx4n dțɜ_"gIf_{d8\+lrf%Md˨Dn/tL R4]=~)hd_>ڋCR6k TL~Dd"|yZM0*(z8;3nwhG.\W\=um(-D1\bcsK6>x`ŷHqܢ50Tp\iߜkYmA}n[R rׯv4b}\FX7QomI9>NVt~U#!Qt˄.:.,*=rxH^}0uF K|[mOvM SI._]aBok] ڡgR9(xSK|A6\ P&Qv՚ZOxz2,az'^v#M#?p/MF6UۅX8r֗/B,P?~y)Ve2ұ(B&, ‹}h8bF?ņ÷N%|ܙk?9j/VBbgx+LzoBZIgZ~)Q琜T8Pܞ?nkm6wmݚhNw,Uj % +)pA~ϯj0M:FZY/KE*x]2[<2/OUٖKݨLL>yt0((.:nuQ]8-uy<|SWo:+e2Nw~a=N>pWZZqjFԂ nZP|۴ɲkz"YHB Z͚X&uCO ذz%ꜟwޅȑ# =6wN~,YȽmtJl|V;稒j3Cku~pEBf?E.>ODOr7xTw'űV k:h"99oZ lXD.Ҳ K pta`3! v +}'tC^B<8 zHМM|U\YkFV \P$޷X[[D"_WWwQ&ȈS'EGM[NL4,A'jt Ixa Ơd Ube$y)}C7&yI``HC*:4ie}ΝiMrb9PnL0"uxT?IlAs(;ɒYqӵ%@n~D6Ufgf7AߢXeoݺRnJi}ii-*),'rfh.DIXhR1,q.._+>[f:ΧR)in}>QۂeWhMR gpլ? èizzC3T JJhM`TiMSёz0SD<|WUTwh Lj7 v.jEcPn3xX_H4Grp;RkXz^IDȣnZ<dZ\V*[i?W UGs.OK;s; 8+͌ !L&aK{{&{>vhi e2GN?¿Z%lK||<BCmԠ jIEn\G@֬@@c.[ (+)NA*("_g,~NU[N>O[OT\suz-_?%ro[N|&KZo_U&nRT´'@#\<` R5ooYDT8+ȴ[ K{(%Y_l8/3u~t2aԉ}\ƍ+,,7Td u0/jrU1|8PPs჻_WA,<7WF{>dӵ[ D?W:v CĒ-. 9gٞvj-vɚ8J] ZAiy H~Ɂ̤̘BFK oHEkS z9 /#y &YR?F[N2Ȑ%ɿ0d,-#'b> DQnK%K( ~ 28W`ұ5o &Z M6'<kni3$, t[D 1Z`to9q˃Y5} X 0f?P>9+W ՚${>N 4՟qk^]u 8t 8u1 gg稨fkk{5!)χ^)))'O 8x~5VCp\JZ,;3ܩ|:ńYIj :a T\*W-#X;>|r 3WV.K"WYvp U8&h-a: "ho hB$p[Wuw8`+LVݵ 0x^RڮtItp,Jr0c7E} P;E%DH[[g@\`ʦV `AV\@|&HԏLKd4h1F#pg Y}O޽d%Rej dajhKۑ 6>0}/?ɑuoԇ.zsu2 ׋<X^yA1kZzQ\{f$M4ldV/MP`?p#1KWmx+2Z@\-FC{=z-f\IA Mh ;8&gY花~9pQL\&8^c\`0?Lq-F#C -{pl F#`0EE`0Fo&@ۮÆc0F#p -/nEF#`0?145R`2F#`" Io~Ū0F#=@"ظ*F#` LսlF#`0A#0dcAF#`0"C&@b#MH@]`QLCݣxc0w YfQ5SOo;/hmU F#2CmSO4$b0@?ܷ6,=J=Ç( EXp"F# ! X_Ptd?k\whCc0 !P\/FF/{Q- uGs[ 6e͋]!)CcQm'\_"Nㄦ_(TŠjd`x,JF!L8KSsRGa~g; `VVV+n˯:y<׿ UԜwp$X#0 `r%f t^8rD0sۙO֟GEDƒLUӹjI I;<ž״M,Mx|"MsCz7gRURsJ7瑐!uE~Fh w8#NZ.]rfPźKPZqZU0c}=&Md׎&/^jTuTF@OIq V~a#gtkf^x q#9SqG'/Vs~уٞOOJt`*(:[G>`t tV-b0w+n5= pXA }_~U:2H3y`#*r2]B,בӧg i9r/oe97AN%Kb|G0ˍ:l^Ƭ3hLw kW}+G6<>:2l z dw6d^q! cN IENڱ˄ubʴg]% B! WEE+ EZZkm}>:[}u<cT=exzUEGJ%-$NBRZMw_k4WNS#t/eڂSS:K+4H[ihkGr l}fdj<¦[8s%:y{OetA_УmjuHE~[8i۳*>զ.mlJWTřpxyIQH_V݋&c"g!xu(N{9kuJ_PI%B˗ٳ -Oc&ÁԷe??w `)iQчlIp3MJ6Zr3V̄v'73T @Q߼.BpES<ɬjҖLMzNyZ|zzd(4?#L3Wލ;{e@GwL=#3&:0_Q H4CnBaxq pz$sAg u斚 uި "YMk>~V"{}*8Pc|},֞d1$$-6ߒ.lXҙ68y$n=mͱZ]=sIFPoO+~"@Q?C:L rƇS Auq%бp尉?Ah/\Th0S5`j LhKws01BHPI)MR8ɍaq./\3zO!M` %4t;oi޶&0Z# m6 „Ei:t;!@<I4 o+bj~oFɵS+M !nߎ ~C/qWK"<ܬ{oB")L7k {9;oKjlvSo)v_ncK1Vz\hdtf}~$vYh&q٨#j|?fqL}l|y/?8(΀o߲8&_4()N/]o}QtV[$ ^$ s߻QڋDaat)w y4 $ 't%){e{ь)ncmkS)Є #^[o /ߗNpCwetL_X_48ʿɋ t|Yz7kMٍqHQtzэi!F~="g!xbAݍNV@Ӧ;uZko(_<y T *i8UiKbpn@ ~e%ߟE?Lqɓ dD#(0sD5h(r:,tLdW6^/ܪ#+~a/Ot\+&uɇ} |-wf$M3L̖}j-=t@ Br|f\Nf {ȃ}جgO2W瞺ΔD~z1߾Δ.6o߂(dti?]by-KX`HQUY .Bx~1?%%%Je.^ۯ? Nѳ cmmQnl8ppY#n!ڵ Z> ^WWg5Z. id>WԀoŋGӫ|@#j,n6MF@(>_i54khaC 2KG,jsjW &WB5(MWcm>}[!@F.T|>7 V!G!0pl\FVZ22=l3q D̘u_rzu x1f djgYű S#BxnZBJ<~LsIhB "KR!\^;HFBם"tg=C,HSBk&ńa"GdE#JG!@n/8ܷ# #Bgx1~ǀ$'B F'bGzK!@)" GM!@OgGr!@sJ‘f!@ًaסǯ$B B 3lB B L hd ̔9'B @/%&$B SD!!@! 腿Ĥ!@!`J S"#?WӴ]-l{hR֮4V(KPq^5}0ף̷ ( ~T!$ B!{J=WT555rܤ Ppݶ%rC@lYٛ˝ ԝ*o.?PϞK$J~]r֮Kf软)#t22?ҏ͑ߒ_Z%ݲ*Zqg^J[y 7@Ua35lNp.~皊Vc/]ޜQd^å ٝ% 0usMggΠ=WJyw?Ƚ%F.׿'Ҟk7&I5(mvԞkMt1i#մh*N\RTr,dԎ8Q(!AsQ]-5BRq< M]R: 5Py &4,Ţ] b$;{ hӡqj,5K%Á/1/݀flo 9*b"%޾_}h-!@JzcNMQkhj[ 9ui#V@n_^]4_+[SlrdУTS95*/#0m@oh[1X&$?檳Sߕ5e3{NfQ/+!L哘#y Gڨk1,-Vw\qI~|=Z׮[: *UwD( ۳i_yjiw懊YVVzFð[A#i&7Ǿ$@M~4$X= D-A44]zKXjo;˞ P3Rl:А[Ha}l as nE\DI_ GW節ܶi_aa'X1ҏv×N1[CE~v< n˝n+VXϜ#pA<n DQ$^ ~: h#++Ϣy =**.2VFwB QEۯXcR1c,,Lm5D6Ik86؜$0F {{52{<S@ĘUafB(Kk,  EpOk=aHf:dL+>J; rJdaCJŋ3 2n~d"H P%{ f*3FR>Q4i2eqskW reohg9U3'(#@WfŠCr*g ϯRnڡ'6SޖlbݳoetRVCp^pRlR Iy{s[x]6Чmy%Bh_Ct0 gCa]U* M9ɡZ(t=Ч*O;e8~{Lr҅zF䲶6y fanރE3B>PSA<y}jVfoST;5[ΈS8Bכ}}lP*,S5MXB^i=+|MU[~G1\v5n 5 7L(eK'Oe5Ā \ܸ=mme45(<jU t+Q3U8z~\PxD*z%{)dWIع&>ZμWЉS2[kۢ,8;j4k?t$!@&CF'1 <=z?v'Id_ߕvO;8~Ϥp, ~$~dxp76FY 5H'ʖl5цo/wA"lME5WR(\beT ۙdjPcHK{Ǜ*p!BƛG$ƻSMi_Fq\t9|&`GuÐ`Sa,\f'v;<(h*@`t|vvjc_aubY/;I82/^_*N#s]]%&nT2vy8># B`t0#8qd544! ;j .Ô_E9rdٲe&QfO.~vJ$Y݀4jxZ݇,tphFp2Cig„%㑜r)JjpE `kL[8sx(BBfAREbtIͽn.YZէ挨" B 0r#~;M$8DA59v n_&hzC"|?AjZ(5#n{0dxNn7FZðuF 1 |$ {j6ٔ\Щ>s鈍 B`0/ ~Ώ?p xL"$Yy+fY.0%AeꅃB:D!@ %'㞋!cKbNyR;BWEnf¾ kh-ʿ;rhaOvSixig9ݷ%F%G@3®;,(FQn{RMٓ񖯈ivxc\5[q_]&{ӝ>Y8"qq^sILJ:o]-u@U1YWHqga:6Ľ|b~ 4&DOߨjs !/p,_H˪|=b!sF?zYGjY{(nReNn[5W\*|hbyzMlwo4@爛hgc@,\93{slDJ=HEo(ֆk xjd+"&6B %O8}J򊋋C:;;ͪ6%DA8WV"8_:9qN{GUrKlTdԫrQxkֽ\"¶ݚNGEl[HuCg`Nt:h'bHuy͓eW5L Cq߿,mǙy}OWmCh>W(n^e8=VYމ$<&Ko2=X.W6l=TQUAz}'e0[|^@N?U}Y߁8  B"-+FO;_;{3Q[TH3c??McPɭ$bV, c'8iLynNT '{2nf"Bz fr5ه%pkW#8{GFzM-m(e= ߇Yb/.B gyOól )A=L`ca{6 y˗^UR%?~:.fܷ۲=+mCcֽI1R_[i8̅:`sF[̾h-ШhCߢJCr*'vHp\rY:>hawe ~2=ܾvV=ު0wwn黎q[?]>UspIfBE9_-feSW'ݭu hU?Ԩ[;3W-9|+]:3 n_5 _/?#7ʠ;WU*J˧tc sЏ̠m+ ;4`Ϭ1%Xޞi55Q>AY3iCKVpq9`G)1q9\/"䍜3w$ysg n`54;hx4^|\9P)c kxkYV4">dE[oBt%Ρ8 rz]-I͕'LઠL9^́>@ޜO ]t!@ M+V걩Cޮ,|=V[0tJLkl=€i5ԎqGTM5w"XbB+48?!@< #*)cUUU&  FTesxzhRW l9P|xqQ#9ZI.~IO./2;ԢO8郘9\<*=:pyxsSSӃL;mҤI2"COq,9#B -`ïiӦŁ)0KKKF0(rjL :G3B B%0t:qAX3LB B@=!@#@ШÄ!@!@ !@#@ШÄ!@!@ !@#@ШÄ!@!d/Jill񆧆89o&jidJc!?luW˩k3]Mi,l8VbgI+Rbgh0a;N + _l%FL,`,` ])5WnbWWҊحpa3صl34Ʃ$ Y#ݙ;?mU$8 5P_&sB^B ~."FD.22r̬ʕ+ݨ;ߪ)QiƵB,g{;~h>OLw0<(*@IDAT1bc#Z4V)@Ͼ(:~C6T@,\93{V,qOw0rƭYZ Ң]%" ٺxl\'7?>P^>!Zl^Vo?ٶtXo89C#ua%"+WMKB]۰JoiL R# F]9ٳa{il5ߪtޑ*3z%>-)=($oW@u$!@<6>=v~RT_Qqqv3~d c\$Uy`ߤ,{OLy-uʛ[c_hR1Z#mʺmKu,)nL \;GS!B jK'N4{GFzM-Z֥|]|dyE`~a+j?+:Uʴޛ7f4 a+2uMȯ(F!s7gÄØh]D jڶ(,\Nxlf:q}K{aWKoiyLc>=RMxq%E%t>U<ŌI87/Q,oX8TӭF![~0q sC9?YC)@AY?[BJҋ=jLc**]0к&@Rls2<| 7>zy \9e@9 3eo/Bͭ4jUOϸY#zS,,y4)2ii5"$N8䋲ёh>T%~ř%+uwB  @HH`l)W/ò;z#.6bM0ٵ͑akgkr,"Jj=zWm=~mWX؆$k#Q:`U.9s^Z-ɭ$P_.6hfjPRo;e Wjl./y 0EϜ#pu*+y @R̽wbw_8B=H`О?-X `gqctS(nHv 1I_~G嬨ӻnMt:8`51L/_vfê-j0 Ϗr?Ә;}M\63`gѼ^ew ]B9.¿M-!@G2L&3fvcyUٴ& tbs"Ü2ѽD=}VW)s jb̪%2RsQʧsu$\ כoA .$' !dc? 01ӽ5-}cjH֌pf d"HM9lHx`fA Tj=661(,̠. ;DiDWzX tGWA9KK>jj%XIv^n-6ޥզj<_v*-r¸SVTj@`!K !sFbBIRۛ* JEMuƹyvʻ@.ǩH BxfzH~}ah.r*u9{SfoNr詮.WA.zOE+U42 jI.@P3Uu ۈ\f8o7l{0c! RjRs.YKxte ѵmJhOiž۸ tnNLF%/)V/!6*x Z&o(DwZ!&(ˮ] $q%ݲ0Wh++rgQ$G67 %c. QOI-vzZ<Zx>Nv]?F dz Y+) -j@/XC9DY^n`ֈU5v`1DZnZLCQPL.F! I~~$C{>;8Iw!H&g^m<{qo}iT%FKOX3`Os n&oBlWFK|DْM5&Zf].™Cv&9!זDن}gd+ْTS6ˈa5mv b#?p":W>#7+'I\Qxb~ȰH ?+<1 S s}0TS%q2&ʰ1Lu=vD92mf<=1mG(Ihdspcw=Ʌ]Uu3t(@.6YoS¨mƤh T\ p:2x /[q˂@ z # Bg3tN!KJJBCCE:vXxxH$Άcaa.X`$ݗ˻-$Y Z݇,y*  D.ipYYi|z$i>g nĀR6.v@P[ ,x2iu#qihA&4s>4,rSPDPTAyx׮>UP[I5_a\ydޔB:f/g:9y^z˗^UR%?~:],.͸oe{VLi/^^%ՏY&&J}m}=8|0FXNrrh3![xU-jRV8 SZI Bx \cTzƢtx>pQ('ΐoizn-ߖw9eeգ=x|DzPntu K7#o8ÑH|j观>~: (/Ozk`[zDZ| vwԎ̹ZR*<AoP(,V@ϑ?S*ԩ(P%IҖ9D{sl- I;^3(=Y!+f8ƊG,\0(!|av Xc1cޫ`,\sOd~ZL[R?M@рr,p5jMXڄwaHm1o72:y)+M}ߩVJۇ\a%zeMFhӫc |%;v='Zf1;~ra(0Jf €~1K sQSqfBywܩp:KY@)4v" XE˜{ykqhᓪu2s!zP"=$`/<~9 Os#q. :۔tt Dlw![q˂@ /4}KF-_7}XVW|jWr\D.{)]_w'< kzpm]]_ *rP¬˅"+-5rgGڒ(۰L:J$LJ6!0 뒒Ї$ Q#$ fLEQGYlISZkWrGAE<8k E\>oPa k JEH OyT?T;̧gi獠 @&9jҗ>Q4ael4B8Єm'*Gá~O.]aO.U*yɕNJ"B 0r V;M$8DAP6׮?PW1ּ8Үm2_N{;Hmg$}ԈԚX,n}P]&j'B; R#/%J2pj#;xǸbWE3OdPx4pUUU` @c!H :4$MWh]\פ!@?@?ؓI?qDxsQQуLJߏ$01%7"I"B @5 Mdv %,mZFj%B F"~ }TOj%B F7Zbt_{B B`T hT^viB B`t ht_{B B`T hT^viB B`t ht_{B B`T hT^gӰ]v b7lZRFjkj;CQY&Jsl2ەJ+cPe5(ʬ ! ȟB*Q.7!wwwnےv! xw!7;Y4d7mC7gO% vH7ʮo9ASk%3-Ҋ::l23l.)\)f#f~^BJ?Z7G6%뱰}[>K+bUYh4.xܾtysvk[mUߝP| mƨ-ܾJ&D^|!]gAʿd~j7]67j!@%"~KOn:#$$ɤ̖x41eݨۑߪ)Z 3? Z9W@ۑ^C!x2L4X}| #vn,$Rsm'y0 ~x lUոjGمzz lgg҄$Om^.e7ʁ~t]4&6p`䗕?,p+?9Zw(ٝ&ĹP} E73n;R-}Q%"F0}ʩvĥX99!$E iYGL!s| \ka/)  h$4uuuC5^r@εH-ٙL8"[^wB]7>["b.R0GmʺmKщPAƔ5UzHQ1o}K|Z[R(WQ(mnMdžnZ7T҄w/c%@ANMhXEB%X?5]iq]=CdI z }&Q~k Ixf4FGo߯>c8aiD^Bm DRTXlriV q_cmMm|+\2Ns# n_^]4_+[SlrdУTS95*/#0m@EhӀW(4\=TU C<T4Hݽ^V %x'ihLa++W$HE#[e>jUoEI >d;5g4$x-nlgۊK湠*+j☹2KDn[>)=w~~*V@[~0:ee"?~|тv nJ&tuҴɉSa#gӾzە * 2 w9})wꋙEyM2Mx,mJSRPpʗ@ xVe[iԪP2gb/ZMM.> (r0 ŏ>Y>!E9~J l,z4qn8IkG#64!6?XE {yzM ގÃzGDe;y{=s{žF݂[K / uZe6S"%L,x 556@Ij jX@5T@vU58ϟ9GjlVLo0"߅ޡ~5#?B{x–gg5%pctS(nHbXJӿ,+XGao&QYKjuێهRJ_ L/_vfj wʬuk_ tf}mim|Gy 6ҬQ!B{JZ ~@Z4cƌ}ǰQm55Ik86؜$0 ѫAр7`=r&Ƭ1b򆘭~ǹ%]<j>\+9`xhPsCOxFI/L ppă5ñ (mȱY 5!%Ŋ^䅛ui%b*3Qh6pa]+*f˻ǂRypP?v!/ʑPεrkT.-35" .e!;-~#@c3ҭ[Ee̬mA: z9 B'`SoicigQPdCa]U*M9ɡ\10cBw}*Z\`8*{Lr҅zF䲶6y fanރ9 !u̩ RaOͪ Dd F(Zou#kk\w.;ieѵmJ󍤦aRm.:7Iܽ}/`D娻A q 518~.^m gVk;gj9(1=umr ]uL-[<͠؋"j@/fvnt?=eԌ(aGA R]/WOsZGV"]O89V3@}=l@Α ~=y?{oԕp  !h^)ҁZj.|Zc}F,RK:vڿ\:[Dx*"ȋQ C8p89IHBPZ_pdՕ_lV p`3ҘH B3 h~ľ`8sGie"50߯t /6N[rP%IWsQAq= oxm*b&>^rk6߳\]qv9)BRBm7z6 $ߖũ\\|[笢d]Yo,~5o-;N[> S*l_(8WTRV$ @K=?d Ma!'om+L8,ΎS?-[A̍tQ~yXt?4=27Vs ?Zy8+ԛǨk模*%gyYOUhHӸU$mFl?9`k5v$C!@cLƔ"""$ C-_<= feQzj$5-@m\.t03@Uz;#Jpv ) /gMfrP!{u4kQ-n=ʒѵ`E7WC?M:H,6<3";ٌt'6B.Bx^~ZyElM`„ *J*Z9$A%c/C ^Фb+W{u+6A7xX w^Te`S-4%B߇zͪ!P]fz҆ ֈA 3A$2l2OV$HM BxyJߏ7d'OB8)K!0!x _aaa66  BxY2$lqB Bx9!@!@ϕ@7i B ^D@l B J犛4F!@/"^Q6+}׳iSR@X41~Nѷ,C!'x˶R gY=|^m']Z5mQZQuFlBx^4ڪXxB' @$Ǩ\l8̇o8Бk&pʕ\fydfemZ:AΓ{u7Nޚiu VPAgz}s#cȞj8u9?mJVx"}lqNFJwlI0Rꚼ3CĊU||⊤48N뗕'n'>m;*5;R1m5sgĜHߵw:`O:lĔشhE #@رz-û;;;=<аt7νD \r[ l(lHxBRU&{2JZ..i/N}K99w T$q *P#ڻ\٤&q!@݃^ޱ!G!ض?vΑֻb "`X@} .@I-Ye/e kh_!84Y`fvʾ3<ѹ3RD9z Qz&LZu`:#Q'mKۏ~Q}~{ب7{#nU6dD̝*N" s*c;v2Y*,B_\_#HĜ+iAXY6lyqmNUa1*(]RКV+[5i8\J BoJw}aUEYzb55i݆d>GyPz0VszVDDֺhSp[Z]V!5?vC(RyEQ-̠~X4WB|fc ӻټGJpC\.Akb\I~y-r3`UDyOBs䟣1G}?Vx6g(R#ML^_ar?#B&-I|C*vݸ?["f+6h=u߁D=?-l!Pw E+@>9{YkAVޕϢ WyQy4{I\}GAbJő틵 ]ʊ|Zz60+>(**%[[Z)cT?mv VPw$scu1#V~~yXH :Tnה;;GJG+-yEǼ_WÁzaɩP[ . J s7q_qckvZ wqxYU i.(s\: B91e>Gd IcBСC˗/Y__zU[Z͚n6J.T :oðHx/@kD2Tկ?95L3JOp+:]G;?Trɯ2).gfخTK'cglg9ܷn;+K> B`|x`sz Stvv{xXM^ 'Z̚5 !7K1)t}>zs@Y_b߹+ #]RGb[o|۾%3)r":7jK^(sIp9[wE~$Kp '۬ʦ>SlIJ7KeFbt_#O;R͙9gfE+ O kh0SyoĈU,異47Ĉ._[|7q?f=}RO?(tz*pۼ6I.u{63+ #k]?2LX_(lUqwsg9E W#|C  ^n~?wMMM111G^t  Ȕ׷LM 1U]kݝcG i_k/o<@IDAT/ߋg 6Cjv b b2iL]~ˬ3yu=ea*ՏEχ_Եz߄uAz; }+Iϝ[NJjJ D(!GmPMc$c2נn%!B#`Ɨ/dX{}{W@Ad&,>v#pH\3Lު0w;0 dT\ X|@"a7V:Om\W~È-7F5L>ּ"iTxx׎ҞɼK”n}!agO>av9463>"11]\ x OeYiδ4;}+{OsZrN!1??(;{2..rYzG+ cPeSCAUdDs1:'#]D5 KW%-3%;/˭R(* &}sAĻY=^W3m/lQ o.jUQ.3fYH. Bx9  {{0Lu'f.:`(bt ԞeX謟k6t$w B GEōx75qV:("tr0*k J43oF.0\/CKmӾZը0U{z#+~] [<#$ەE #P[WxF-􂮬B~ lkU9ENUt+G~zQci35motFX@Btg=B!n>Q9uV0Qc٭{H> @o> ,Q /ZՋDsb6D/< ^{:qۅZɧj;n GG*i余 ntH&G,^Oָ3zPG#/I& 9YYH B`\Ϫc*xz A<Ԅ?6=/$o&H5,Rv8W /0: ͥ]⯣T(8=P?7quG(BEH(N6ߢw8鴶A)a\^Gݷg;M)ϢS`O[ {V,.ny _ŐT{X[C?~Dɷ8v XO$;:݋?(CNUfa3c,ޕ ]{7vZru5Edֿ֚(_ze;nSaEFR4b d 8F YˉDžAWWh%k+(1i'S xkv0]e.pY-1@ìfKx! 4zFptG >ͭWffpW%s1J3| UH"<k`#Hfnc dB ^h* g܄ T*T*r- H 鵮1#B/kEX ĊQ$0[5[ CMmHV {&eձ1#;0JjeN[٠ 5bP\_D!@㑀դ0ILR__n &20_葬'yB~5NzMI!S S=ӧO=WVV>|ЪFx{{Cxr;*UQUD2!@хBCCmm@cci BA>\I!@&@vK''B %"尓N!@@{I B qIq9ӄ!@&@{B B`\ h\ i8a.)մU$ ie |KXVuZ3j$2nl fTjS4VtVskB Ƃy ~,?6inhhhmmhup' @$‡"F-bLe>uE56;`%wg3&YDYY}m۩Nڸ)YUUd*q+Γ^fh3'ogPyӿf>ʹѦEFAr~H37Qwbv1>4B⊤lHK=EHvmb Ԩsҧn浏qte%QR]ٮʸYǧ)0y8x>.!ψ9;RO:lĔشh)N# @/ < ptFgggxxUei8mc֬Y~2~ 1jQ, V5 /XLܚڎӰ챽Nk~< @aPP?W%-[+߄Ce̮>8jbƊk]i&'zRZmf3i~`~Q s܋1YWTzY-s/4j歒{"et!deu髲2q qaִ.hܨ-!zP D?Ͻ&b;+v-jΜ)f\H B @ 0OlBSSSLLp.]ݷ*ϙrtSy&$z.T$+ݷnlp &R&D*%J8gnz۠|edKf|n w|m&n#u?i߂܆FgDLޔb VY%n~~C(}YvbDa}7m)hE(86|/*YV[у ۲%Za|oJ]DWc] p=Q s~;Cr+֪w3X16f2شޑ9vj--qqAf$wB6] ցB#7.gW(|Fk'v AڍdZ @2F)aߌPE.pˤAqmߣ7 58PYFrgl|!Hv"##M._l격~l຿E]ҤkLۃ(8Aݴű.mϊuni*QJa{8fvliߐ%6N,[ai=`$0Z~-s"˻Af#L6=9RJVq,W[U݉u-io$NPێc%WG/]XSJ{'WKW<{) =xlѣ9\昹6NmVaeP?Y8~Yt;kfw蹒ؘZ9{z~QDڊ;K#UPoU@ k))D.}kW%'y# 1hszZ{~=AS;*) 1l7B27n3XkiA!ADGB D9^.+(!f$g'NB7So錯bl'ik 3RT_X 0ݪ~QQB8RHN! N25*m/"@#uivhRex-0z8kl%_!@<'_Y7jW^sp$uwCN%pB|pk%e1a:Z+GÍ)E>hH:za.VD”-7 =H< GdNs.sx4#H pLܜ8m(0*VMoL8payA]ȗ`5({ W n̬-;WDL9B%QpUthD[^ou*):paQhhsn܇PɸY+,JhQw+RR#\R#}Zz\GB;wnQh;]tض?vΑֻӆ+B Ƃ_h4 0 ;B$h<  f.Nu}X[a~ь&ge@:ی 7Vw")FR9MZl[} c'Nږ :gY^mLS#=PwZIaаY46!魪v1JZo$2rӢx:ĒyQ\;Ž*0WRPDVir[!Znk&F\.,-4Z(2K\WRLJ͋t7-KlF"#[xhPbG&N|ۈ-;qK3oM`vo"?TtG k]Ѓ>''hfqs= "_Gqқ/q%!@cGxƎ(Z~ľ`8sGiڙ!50߯t /i0eEJ)k)mId{UTwz6`4r^k0BEǫWnm={+."eSHw`]X-ES,ߖfKI?ʻYt!9/׼9﷔fVKɘ#ek3 ໔+z!RCSyEXkD[[rĔ1|SUǟ6z+|11J0^6WtuY9\Aױڰ9-*4rç\N$JKQK|/5[A rnOz″G}5lt_wؚ]euljl^٤fPJ` !UOs1wKoB S#xD4&:|r*EQT~~իljԚnִQrئ%3`gx #;jTFxcI\EiHxqbNj8;W~F`zd&~%r h֢Zz%n]!{d^bH(0:砦F zXImx2\! ^<\EM`„ *J*Z9$A%c/~}/xhCmJBC=c(5֭ؾD2 mg|L,H Ӕ4 }aɪ!P]=iCCֈ]T9B rI\J\s`rE N`k/<LR__n &2(f#ZpxeFlDjfKB J56}txseeÇߏ7d'OB8)K!0!x _aaa6ãʰїB B0 H68r៹vB B9!@!@Ϟ@Ϟ1i B ^0D`B!B gOgϘ@!@/"^!!@'@гgRfi~djP֪i7,;Xs;2᥾c"tn zvfsnk=ZfiR=G:Û 1!@cc34M744j4+ >>>>"~pY赟$GN2r*#W%˝UiUe?]S؄k 3ӕ\'"}eᧉSVvMlyq#)"LՍtQ9Gy Obg|ü*]5y-0 bsA+֤'q}g-'KR Hok~Sj 5>Ow81GrNOZMsݿrfmk(y^pݶnǕ!E7%)DEe;˹ui]QC!@=):;;=<,&T^ 'Z̚5 !7K1)p}09#B3gX~ۊ}D/t)P[*>OO?FFfXAZZ8wVd E ճsɕWl7LgpA܆BF5iaڬYon_:D9the'GKOO2kіyue)[ndn,յJVݻyFO-ލ}Ui{>8ɿ͢+gke-|4^6uw1=/ڠ$6+;mm⚙JWxv?d٘9pɠk6UJm-.B4f1TQWTZx?oJq1UmAg&ܻ Nbh)^ɿBQgPn#Fk+uYw*x5$!0~46c vA`G|*R߅ DswF+!{{{z͎ҳ3"Mi3J|3Wd'j#{/oT]xa6GrU(k?._gj{|=4m||b,!U/83BCޢй[gyk37D"hޟrd t2BU^qeB*2 PՆ+c w>C8A:d+A]۾юGybckdyg#' 2Ιq!#6%KvcZ28Kp-NY4GR2~[RZE Jc{2vi#y'dLp>QPgMf/0h$cU[Oo;TQF,dQ!Be"0t2u :ʼW^yȚMY| FD Bg5] 6Nz=L̔X|@"a7V:Om\W68ɁL}EjBjKjOK\hZA/a#^FdE` a ~3ĩOA  '({h 7l r>p=mڵ3m~)MNӜf"bÔ_BA I!PHsRvNJw|KZpPi~DmGVifi8)Hզsr*N_,e,DsNI^GZk6,]b9f !葳ǫʗ,$ PG/ $X*t@tGsݢvQ o.jUQ.3!߄!@3o~!0Lu'f/<9/ 鿾 Yь1r4%QQq#'H/nl"B7/ rp~ky!vFxS 5i*:#],o1ѩUjC#1_qE݆J58O vdlӢ |:FFWVv!? "=Ӆek-,9J/(3>ꑟskX KN ^>Gйh*F|NsnH޻K\x?rP`pWo׶M]qO zϵb@w[0(S)4MᏘ oZP` G} ㆉ+ :yL6AWp\Bc"&Be'`i_.?]|yMӽNM%o&H5,Rv8ה L>u-]2vS[ [Pǟs˛P8S |'8kۉ`C䭿Z*[cSc8?ĝ?OqF>mzfnsPE%Ez !(d<~nOʹ~a ^~ o)dNpS[`Ꭹ8vTA{EJ\{QqeH1٩%}5l/޻ƒ3\(-m03T$Yv?:ܳ\ Ε8gfblCL?k?lq|uJU/bʃ&uf>[>kF/NminqhoP#jr07L| 2wT{ΞmWɐ|!1?UUU@FIСC˗/OY/W^md㖡TwcaUEfTP,r|O? DXNXV en!cF"YƤ ) /Ό8#Ģa&*v0 (vL ӯXYU;-";zVӋ5,r*$i sH l40gnI!@π腊ixV*G&0aJ%J@p  ȥRtmyxz[+ - e͹"|$[16B/0[[y ĶmM2%(P$Z 3$znj~L ٟPźR֙FQ2&Blvs)SW@$BјO! Mq'd/\\פ!@?@??}txseeÇjߏ7d'"+z# B` hl -'kc32UB qAfȅiJ!@od{B B`\ h\;4!@!0 4ǟ BN:M!@oD''B %"尓N!@y eZ[[5|yDNL0ܯ *dy<KԽv2􌺻_HqrP|v df'l DlF8L;"X}G9֯{Nb neXT|XFA h-3:;;=<s⊤{^0^9ήOGi\4ӕo_k]!8q6ǘ#fq]5y-|Ȭ-C "B`4v^MMM111G^t@k!fC by=bO4 Rk_ߓ[]xfu{)~wm(Uɩ~c{3֊`ڷՇĮ"V|…rB#5dWČsy&lvݲ6*HFio|WsE=5Nf1}_շ.U?B6#Adw}Ik=ByB  tPoF`aykH'kY@M%D/ $R\f᫼ҲKG&`y7L HS˗/.l>$A[L9n7o%.VW櫱E9!D_8C4֪w3X16f2ش)CL䆚[q2J('# 8rV`yISĜDO;UIhf́s>]] {P-bGGc6 NtP7m9qq*tK۳b[Jޫc)$[Zz7$g78zy eswֻ|ٷ&.y ˤL6=9RJVq,W[U݉u-io$NPێc%WG/]XSJ{'WKW<{) =x=ekfV#5kߌsWUMۃ ] JbcjIEMV]j+r ,>R^N'󅕥wjIHVCU+ H֯V\`/'u$zMS)S;\^EoWIK5m83XăA WGI B!D9^>3˅a]=ߌ]I-ηZπ#w:㫶9I@x/UHezu/**T.)8I& ȵ{aEGmIp>QP7x đ>2[Yr#`e;nk ia(pֺy^2V;qLcM)t(j%ߙ^Nژ* sf  66*D&P[rq F]:}lb|wW̋cYHJoݜFE&9'턐ohUaHpy\x$+i\[#f`™u˥)bDS.|ۤ\cyLEY[~_f937dցB ^^s?0#HµmP 9pBy]8i6,IN>=,&ACkߐ-ZO{H@8p k=r*run|6>^&zx📓a $s-"z/Z?pvUX/MFQÏ. ps QHXA;}"NCf u}#g9B+Qp5t౱^>6߹d;~_E=uE7 D}j^턔P"X/R64+D. B#D5 ~L%^<{,,:0;ս+f\9GO4JiҒerF7V;uҶT,ox4?bZ.|M =C¦sXm"jT,fζTXϕ ϴ! ErӢx:>;~\MsKBX!nAԆh'M*\XZd;ېsU C9+(iBNݭ_p5?vǹlERb%VCy+V`|Kjk5ucG[;qt5<;~Rfckmg}(m{Pg!@ύQ[! (B,*%om5, eqpv TUn ._163J0^6]O_c8;d;bplFzEǼ_W1_M=} _}I)%_&p֩g^k8t_ Z[͠0C`lG%gyY_|!0^7Č'9:**J*fg쀆G***-[6c4jM7kZނ#J.Tc̷ a~d`̀i| lHgWikxxbNlQ)R^m{[Ϛ =/8YjqCQ֍EidAbf$iu}&C^gi)N"k(~Vji܁:T7"BP==UcHeϜ)SݭJ$B|}_B\&=D [PΌyH$&c[ $mMņiJՅ`UC,Ɠ6(kD6DHE"ŃvbaMJ\C3fq>h+O>Bxy~Vm^|݇Z1aoo3f*l_J`~~ve !@S#@<@O ?EEpWXXXhh(sa ޞ}.$<=*#5!@x,O"*Ǒ ?L!@[1q;!@%@{sB B` h=8!@!0~ 4~Ǟ BCO:N!@_D߱'='B -"Г!@@wI B qK}"M jt>>>"͓?A|pŹj9ef]fB)irX!pĦ!O=rvTV%q3(n~(KԽv2٪6#Qw )ļN[֓8B 1'`T @;;;=<<*nooYfAR?{ԕI†0P"w(8VZu|Vk:3vp/:G_:P[p)QjE.rp!l{$$!(muZM^YoNlFgwVu7rw@+)j%KS}v\8ڹ}XY9ůI-hyi3%PBOo0tϲOྱZ(nk'?J["FgY%(.N Oac&Ox0ڮ}u0}?`"LuT)bgaN𹬏t*EF:/5E㥊J]ZA=bL0Zn?Jrׯ_gB9c"z_dlQ(miZ(g˷ }-Itwr{%X,:4'{MeٯZY)")`|=i|tzDHҶ-vǑ`e`oḤ9UvZ 4m!XiniGo}}dOvsZ/<|E]wrֱڅҡ6P?$)lV^d3ECS\Pj`V DkJGJ4H:xb{Fi1kSݙnY'+ (X'a 3!vemˤ ЎKajc4 O/i-p(o%rXM!dҭ쎚>UIh^".0L3VbNM \<3_[-TV~4t [r؜mHU@+<4vUnN0F&ogɤ0,ǯI=r??ͅxVznß Hs .45(ܲԱW14(wj3k40L)X7Sibvg?AuZ᳴ŷ'WQX[ !',`a&'x꘎Fĭίi8sʮ]3oVz0˜"v`C ;cNă"Uab0t p_d?Zm+ 'h _l&6>14dV$g"I*-ߎ焄7u9 O@RM,ko&=B\_PW^rA܁[yA&(;5DUɵ F$*8/qoc[1 ZwB&  |oS[HCHFG:|υPEu!g]^0|h=-_l?& `?߉s?Ё`UA<Ok_%$/KՑϯE7A&F^ީYZ$c!zm#HWL*4ƹ/ IG -n\ѽc2)\̮ڜtی4o Ϸ.?EWy eBF:<1ID6ː[Aʆ.$"dQeծnІ1%J$d>7'zmj%!k\׷[)Xp4'& `+Kѱ rľW?Мxe8m/ʳg>8xZ(1 ss /툦f4Cg?XxϹVwXHI棇/-"3&w2j7FܚQQY+&?,VHv&73-KX=# 1;bSX>~ z󌡀7h}fpdo@UL9짹cDJtKqJ6Ǿf;l(g9 vJѵ]ʔsES`r܈wn[{f1Đ16-Z[\2ϖs0S)')?glXRI{?*P?F d %Rn-Lhϙ `&b yDjjjIϗ-[&H༳l?<ڰa;LJ8߁aQj‰:$+"ђ4_ȉiuV(6q5$- ִa=/I~sZ+*9_R" MQZYL ИʭEy(v2鱎V0Jhiq?2&` o2T9ZTiY )#-8ep& `S 0}2)tiW9o޼&??? T*5a:}tS0'0B^ߔ|a^0_8k%fyb' ۓy{V@X1.uAF ٓ",69Ƙ\Xط-=f>!g̓@(dv#;L0g^C سfrww_`؇, h Xz~]& `%-@~-Y$88, [d.+WEQSE-W++/iqTۥkKëҽ/I7jV;R%'JrEru?e;>mOys߶eRBn^R4?4І2!↕O++E9.y-5WRH-$_-`A_l_N Ïro%F 0L`x+3-<<7n-.Ue$^t[(Cw{ oޢ2|gPJxWw{XPÝ* d;R"*CnhS.,KE{}n":f8 eNl^gw֝w'Y7еV*!מs ueަs}-s{_AXB 毢C*뫆QH\jb”_DtzxlC:?}1ʩgYź Z|XE^ߒm ( d B-*<ߓfq]%e'p^=_(/o]C(X~0<=9QάԽ ;U]ws\)uSO$q{kԄ/@}(8)-@@c2Xm)`"$7)R" Xo>%d|P"U)SBKW8luc}UVa);_yӛ7rNzٓזDJQ6> 5f=e研WNW+:{uI<͌kvceJY_ې_PNBɖI#fgJˠly)Cm{JUɩQS ɂ& `c &}&e06)pHo]Q 'lWi=ZȄ$l>LD/.n,ʌOqGd7^*$'cvX}:}8s"(vs\{DIVr@(eAROX2:GƐS B9ǿ.V 6)=1X3!źJܗ(~Kn"_oƺ5IЩ .t-ݸ` Tw9xFhAh3lc8+6oGso5[`RqAID񙚖S:TIޅwga#G2+g~7Iv\6r:mg@Ǹ@F Ceo 0LrABళ ˓ =Btz1Xrkv`ytTU|D;nxb˲[I1#R(rp}r BjBVF%XseF:Psx01}u+\hXصϷ5q2oPlpٛwJ"yAc7V7 lglkVěsB>FqlX@1 \Q%c3\w>oI ɼN&n?_zvK㵆Xͫ#Tߗ XI>(=xTC? F-=xxdLE+zE- Tq_ {vp`P$ `&#< ~A@Vԅ?J ,^\2r^ %q-IiHJ_-k2]=鲂)R4"^~ /t O,B5c,9 d>V3։65"fgdRc-$Lav1I8mScgD8 goߕxdW"Lٟ6EZ>NC6&̃8zSXL@@S*yw}]S^@Ұ>^/G.o/sV A$uAW{+/Ma!r6DuG.0' rfD{z6`& VT .UݗXt틛ޓuD636>X,`_*34''K|=-E $rΩ3LAƿfᯐ}[0[ra[BƌC>HϻYt,+t澴.~gH}ר0Y6%n1VSuVd}ZU3bYRTmoOgƗA:cёůeL;v Iy}腣l IMUH p4oStW1Yz,$LNWq!wD/>Ã4e%l0=~1P֌zZXxoFPylZ %-qaL0EgtQ_MMMXX#2Ԥ?|ٲe;G6l0UCq{ LL&*#5nkzgr Ekϖ%y 98 3oSx"Yd$ >m=)i:J'L^8!ij5jq,ziv%a-6Bd2K60٬i$lQ&@QM[ 5={\5D"[yq.xl8& `! o~kjjsqq@J^U> t#`I > |T49L_BE4H}3%cҴ.tH7%@uqc-rN0LfU Ba/&[`I#_F1 *, kn+g 8߽2'uQ_e-oB~]*t{"5`''vh҂%L%~ipm;ݨq x3h*[㣵>rWf]PPQ;tt߰ThZjܯ^9Sqd}F5jaXWo۩6ˣT;:ɫnC9mqM4ޱ<~7+o^oH_N6 Rz+x/E2B`nP3.;6}ռ13ԑq~_;Y C) =&F;f5V@Z? Sd_ Nb]cHROG2Ѱ/BJ$z,SŸ& <ifB\*.%jD llڔmD _)d[ÈzyuGEW]D}ElkL&& vu 93w`e{C$FǯIc`M 8蒗6S+5E}q~.?|^||YM`2ј%OYg^VrBtʃ_՗&:&m5/9NgM }tۇ'juqYz_`aN T?51_0ٹMk-|owBp.Lͅ?0L4BCC]]TzzzzE!P,Q_^48(31LȘrָ|{h'eAy'g_Z?$4WRH-$_-`Sݣ~/-*9xu}ؒ>+|DuҪnCRJ?ߛ6{=RqU;bubp*Ow_t<`SBɫ:t۟8.+ȷ}wu0r[ԯᯣ{e–+/,h)D%<ȉio>] fW`]I|Q}5ć49}Q@o%dpSu^_Bh{"'Ƃ+_+7Ã9bBwW>j5-MO:/4эKj+/~\2){eld.==B"Bg5D7I^6 = Z)o ֎?0LiuppЪzI\[,;P[@&/7QrLC.ކ5E7ޕ3=XAY{#Wż&G|5=gɱ2V(-ϔ%@f٥O@:L䴈KPT&Gk/Y1t=VϢU*lRKl)=gx2ݷRif^4=v7{~R&C3 76znirI낳'e;wr}DZuuh2X\{)ox0-^]VswآCCj/NI^U硂8f`Ñq$уaYug7e8kX (kjIs#CTMD>_[ͭӵCaKױ:iXM!#%7‹ye{_0r Lw>c:QVE> `!A4s 0 S.?|m`"PYiA#wkST\-*(~e'yn #]Zd36ԉX.be20U[XէwGv4%q_~y/W8P?od#R[R{{?9 #A,vyQW\;_GA $,kK\'h>h"͛l /M}U&NzG4)FM?ӃlLg".gL6'[xuŔpc7B7ntB";qwMAhMY\0F&ogɤ84MeF_h+L~+C=Z\sDi0Bkv(h],cbQ36/0L,Enj~,@b<5#?Hu$k}ѿ@ IiAUy"$wҖվb<+ґ-]t@X^y($uj-w3UbZw{e9ЄPL35vR,썭CЀ ~e#e[`?ﱳ5 @ wB9zr $35jP7褑[[b]|ՃM;SZHtv=6$+&UcݗpvLPS(`N\hIZ,s#NeyuHLCmF~s$ˏruџe}5u^TۏE1?H70Q ǜqȠEuadg g|$o*oG:Ԉ=2oEv1͎kH#ǔqpRL#Vh?Pxrľ;qG2N^ܶtJيʳlz""86͈;S2@0972& d.A+&?Z0iLe  ň9CEoέ>9;.FcPSK՛g Lp,vSr[4DDQyLAyeCZ˾vq>Wq#d$ 疜-u!g_iGĜ@2Ĥ@蟢w[(3z& `+ #F_SS 8iN8qF.E P~~֭[-Rdή  .RC<&e$;_"_ 0"XhLר 9BcOf- ۩&fluE%ђ\5" MQZYL ИiF6R(SO:nyaň|i0M*88$.NQ#WhPCiOQ10L'#0}b\~> Ϛ5KTJ$ #܂$02Ζ=y F(߀X BbEc';Yx"pf$;KƤi]к8xT&L~+bAI1VJ=({Dz*4e{(.e^ӄ& `#EyDy޼yMMM~~~...` @k!t:& >vlx\F `& `O>-1L0L'3>A{& `& `1L0L'3>A{& `& `5C!y uWoNx~gXn߯pΌ^Ov&_qZsgFdy!Q!_E~#E,= ;Y3FE]gnr*~S'Ryb^I)y.}Kn,.>bOV1 bubD)J;7%${QzC{yO^@MN|WW#AM5ᯣ{e–+/,h)D%<ȉio>] W`]I|Q}5ć49}Q@o%pScsZaB"QW-oi6ɨ J ZJ9}D;92B5R\'SʓȘ]p[di02iKNW  #OIJd)|JOiL2v-ӺX& <7z?TZU?\o 2 D_mb 40eaӐ۰ƻr+sRU%UWD:#;k`''vhBֲ3WOO!4ԶTXFdlfU}|gQN **ZIDATzkz߰ThZjܯ^9Sqd}F5±m;攽cy1xjQ6yum71^u_zE%Ɩy2/wZ2okb%qFg: .B3 /^ٰ죵bģjO;+d}A5_Z u9#|QDAՌJѭ-.>A&F^ީYZ$c!zm#HWL֫Ǻ/ IAf-[#i\2{vjX., b&HKb,|I2?CFd}'RH-EœWPRZcڃ2ZElA3|]?)1{G>%!둮oRXQy #;0j& VΙ01U}+ryW~`zſqCyJ&F{R]K%&gA˱F3?-WiqYB*8tB1bxcs+v>+ΎRCo$OH\`/,#4TUn+IÏ1.ťw*QH! ~? 7_<:c h|RKU OKܳS5p証K_xsToH[e4DD܏9"/pOZc8Kޮ<|f<􊉊bLf6+ #\SS 8iN8qF.E P~~֭[-RdήC.RC<&e$;_"C_ #X8)<5$- [Ӥl'y0Zr|JM'c4Eiuf21@c V#ୃj-lN&"G%r40/Q=RlhXKq;GhjP-0hC?o& `Oʏ\Ϟ(frfR* 2LgΖ=y F(ߘ.BBP? tP{1Y%ku#p ,uP]3uO"/[!Z!D|L$M$LV(6NY3,1Lg&`(3埪kjjsqq1^3 W|NEt97&& `#r랫>|hQ'~!E<{ϴ*0Lf:,~_K, zƆy L0Lx:z:\W+c2tL0Lxi'׈ `& `8, 0L0'O '׈ `& `8, 0L0'O 'q_OZ|uWoNx0O!w!MɼT4hHiZQ4\HMo:`_ /~TI `sC?L5I*ʢPȼͷrrێHճ;:|KO.-UxK K동fp.Uùbt [z`+ uY&&9bn-fJ:?\c`b/?Qyxǹ;=ռ{Cod]ė˙r_z|-ju'u]lnCZpVxD !C[ŕ쏊pإR41񏆠(n1 y0LxN8ʆj9i,8O[I UZтиtV#r^{CE_ 9>%5r 4upFF]wʩvq" 緞⍨yMIQ# .;¾)R®-48S & Oi9pc7n'_FFFNV?~: Uý*Gɛ:f=OnRVD"c=YxF^ !cHTWxi.[su>0*b "$@4y.}K*ɽwP|V _X\|C*A_.X #OvoJH>;y\~'~߅Ձr%  ަ~͔ +|]~a@KXe$-ANt!4"oܴL|6. .tSBm,ԧc22DkiѣWasEw=nZ7svWy5EB[|'lE¡㼐Ur83--i"o^Z7h.YgdA}4xG|3f-'Ս3fYEEe59X6i.w"gC;`۪փ/;>t1~皠׮ق1ڏP]Uj$ oȕu9+?]7M}jfEQ14>;.8ٗ1KPx7НJ?3ͶG :Wzdu=*4b5]q*cϙ65\*S温[7xȝ,#& ^hq#SaaxuwIm@ b3rOd.I_kM]_)SC5* @xNpV% Kݹ Seg}%-9T]mrW}vy\.S?W,ebC`e3NdDZuT*%P##^йgAo "b"Pwo/NH*X ^4C+kRlÝI7@2MBD)R j_~YӉGybgYWs`(6}cڰ}E+e<)6p88'ˤKw k,-P^\#O J}/)^ԫNޒ$By6栒x͹/\moXDEFf57BF"vA 0p\&(1qgءprpB3|=EXU-z̏\{ ZXD`i3s& 60%}n_ qaXLTE٫L"'Jw9g2 $c&M\sոX hy[f }A3&7fBgN2cqu~=v‘'PFd`'rD@` )ON1s|3e_J^(pr˦ S_b"3MbMع@(&4S̍˷EXmDľ8c@>|)!DipAWlxRĬA. D@>D#}efpU\Th>.,ۗp>2|y *<0@fyBxxA࠸ Ow`ca*, rqÆ~#;BfFWoj T|Pmۍz&t7 ?.~>95Ix%|"B|,-6f?Nx oѕj$nנ̴?nk\͡Ϯ 3Dc~*)3LM}E݄>toB  b 93#f8#8{IJ+LΤbpyD`r)s#ZL1E[t;81BF3sm~DMNeR$x;" @ L*HM3l<[3~9sfdy$\c!Sʏc۹8n FېSobT^n# [ ].hfmG+#EH(R,6~n7=qj-I Z_<=W/b 7_p| /nH .X{O]qc2g*NRmKquO1!sǹon ''z;M߬S_=_s?*LWvpxքɽ9⑹gen= gאB &8.c *::z R5Nߟ HkSUXXnWsݐbaCƓăh8"KKB0GE$2xV )bN:Esh7ڷ'V 3ؘ@c !ppiX>xP0=B+4$ `^dSdrc@ @ <vOSLJp @a<#v4m>Fm(5)o"?>$$v'SO~%sո.AȁBNkJQB8;qQ 8(v dJ"˱rW0.;5Oپ1'vG2iӦ544z{{ `?DP#C9ќ$:+_k&ɨ0 AE{wE( Pq!;zpظԉA @ LNz<~EEEEFF9ã嶛K+A @ &(=DWٴ6GӦt?L"M 71=4{/  CZg)\_\WwchZxp^ryFjLfUխZ{M)JIBg ܑhuDJxkx7ʻVƘ|~h͆HB^I7w&9-Dm>@;'z0wȉ&J[8MÌ9A/*^sy;zO8{>|ږ#`4; .Tz*U|? N' @ < $ƒGgΝ;Vgg'rNۘ={6T~pXl1 '*|asgwAm7Lɤ?ٰ=˳z/=a݌W @ʁՉ}r̅zv6q=X䕨IItBLu\|vyGo23<܇[nHYƯi@9מZ/.;"ݚ B~!7|>MgH5S?GqlR˼iX;AuJA @B +0游8G/^d ~L^R ^Zɝkiܥގ ]K*Xݳ7~- <dK[+Dtg%S\M5ٔWQ%^AAS#)mya]8,1TsuKQ Bs?|e}{. eܕy*\$,ձ)/1tEy]#Du)oQMg<aǜ` XsyucҊh)}(muOK<JaKwYv}6]YI7rNV ]r OFENO0%v%qksZܳI򨻔kR>B֢ʵ? $~&ҟ?YY֮SI\c 7_+cݨ 1 VwA"dwz85>8њW@=Ȕ%Jv>%qXH A @xxd'a@p$4 m6ݘH>#q>X(hl5T"z)ҥn#`u&Q?eEyZr+D82dHmmNd_UVX:2z'4prHQcJZ4ūC$ K&3*S2K 87 B~;?B&=s>B3=c׊%}d~JZ0OyX(+vg/;**V5/Ĉ% pU9Q/i]]l4-7 _PCbh-W/~5; !LEU][̈R#T]|rCAG+#aqwtn nߘj.7%~]y('%md e`F\g.i(SsVciB^n)l[yfAX-+z3?77.E잰`ƋòĮr /@ ,E7#R'My1_|dE_M1gˊذw?<e6 W?wPc+r׾ޢhM@!IZ6 in 6L.c{P㋯Dl煔P"Yj R5boQ]_OGu/A  qCaQ TA'n#ŧ9HX/{Y 7j*{+L3-84]8cpqn|)<)ۨR % ,^QSGqCͭ@0~M ܜ{ gጢ~~x.uj+ t&</,w gi `SNбkbA  =z [i/^9a܀rE9D|h 蹳9+!$ ˚ [5yJ q5OAJ}͏y8ErQ{XFȉY$]O!iBd`1}7 Ϣ ᧘]{{,o;8A[ޝ 5Z,l[տ\q[&em(2b4W\zKɜtmm9Ɨ,Y8ΝVG-^ %εv9& {$'6QK CsU刦F.buOK<>^`㼵urM "'*c]"~FL-J! dhTgJ_ !!BS"BE}k~XZ@A6w觹j\!d6 5 uY mOYDe*qx4ӣ#/~b10pMl-=}Z;iij\84Rl۲st8OVܔ_. XJ9"WA'>4͌R5k|? N:5sJMl%N(iddceYO=ԟVV:h<%-OBm}ټ \X6gWWžWie JKN 51?}HTD:C.T;gySrCv LU}u^`S}]v} p"if OwۆۓŒ @ LPRsIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/admin.rst0000644000175100001770000000276114556473264020425 0ustar00runnerdockerAdmin ===== API --- .. module:: treebeard.admin .. autoclass:: TreeAdmin :show-inheritance: Example: .. code-block:: python from django.contrib import admin from treebeard.admin import TreeAdmin from treebeard.forms import movenodeform_factory from myproject.models import MyNode class MyAdmin(TreeAdmin): form = movenodeform_factory(MyNode) admin.site.register(MyNode, MyAdmin) .. autofunction:: admin_factory Interface --------- The features of the admin interface will depend on the tree type. Advanced Interface ~~~~~~~~~~~~~~~~~~ :doc:`Materialized Path ` and :doc:`Nested Sets ` trees have an AJAX interface based on `FeinCMS`_, that includes features like drag&drop and an attractive interface. .. image:: _static/treebeard-admin-advanced.png Basic Interface ~~~~~~~~~~~~~~~ :doc:`Adjacency List ` trees have a basic admin interface. .. image:: _static/treebeard-admin-basic.png .. _FeinCMS: http://www.feincms.org Model Detail Pages ~~~~~~~~~~~~~~~~~~ If a model's field values are modified, then it is necessary to add the fields '_position' and '_ref_node_id'. Otherwise, it is not possible to create instances of the model. Example: .. code-block:: python class MyAdmin(TreeAdmin): list_display = ('title', 'body', 'is_edited', 'timestamp', '_position', '_ref_node_id',) form = movenodeform_factory(MyNode) admin.site.register(MyNode, MyAdmin) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/al_tree.rst0000644000175100001770000000663514556473264020754 0ustar00runnerdockerAdjacency List trees ==================== .. module:: treebeard.al_tree This is a simple implementation of the traditional Adjacency List Model for storing trees in relational databases. In the adjacency list model, every node will have a ":attr:`~AL_Node.parent`" key, that will be NULL for root nodes. Since ``django-treebeard`` must return trees ordered in a predictable way, the ordering for models without the :attr:`~AL_Node.node_order_by` attribute will have an extra attribute that will store the relative position of a node between it's siblings: :attr:`~AL_Node.sib_order`. The adjacency list model has the advantage of fast writes at the cost of slow reads. If you read more than you write, use :class:`~treebeard.mp_tree.MP_Node` instead. .. warning:: As with all tree implementations, please be aware of the :doc:`caveats`. .. inheritance-diagram:: AL_Node .. autoclass:: AL_Node :show-inheritance: .. warning:: If you need to define your own :py:class:`~django.db.models.Manager` class, you'll need to subclass :py:class:`~AL_NodeManager`. .. attribute:: node_order_by Attribute: a list of model fields that will be used for node ordering. When enabled, all tree operations will assume this ordering. Example: .. code-block:: python node_order_by = ['field1', 'field2', 'field3'] .. attribute:: parent ``ForeignKey`` to itself. This attribute **MUST** be defined in the subclass (sadly, this isn't inherited correctly from the ABC in `Django 1.0`). Just copy&paste these lines to your model: .. code-block:: python parent = models.ForeignKey('self', related_name='children_set', null=True, db_index=True) .. attribute:: sib_order ``PositiveIntegerField`` used to store the relative position of a node between it's siblings. This attribute is mandatory *ONLY* if you don't set a :attr:`node_order_by` field. You can define it copy&pasting this line in your model: .. code-block:: python sib_order = models.PositiveIntegerField() Examples: .. code-block:: python class AL_TestNode(AL_Node): parent = models.ForeignKey('self', related_name='children_set', null=True, db_index=True) sib_order = models.PositiveIntegerField() desc = models.CharField(max_length=255) class AL_TestNodeSorted(AL_Node): parent = models.ForeignKey('self', related_name='children_set', null=True, db_index=True) node_order_by = ['val1', 'val2', 'desc'] val1 = models.IntegerField() val2 = models.IntegerField() desc = models.CharField(max_length=255) Read the API reference of :class:`treebeard.models.Node` for info on methods available in this class, or read the following section for methods with particular arguments or exceptions. .. automethod:: get_depth See: :meth:`treebeard.models.Node.get_depth` .. autoclass:: AL_NodeManager :show-inheritance: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/api.rst0000644000175100001770000002021114556473264020074 0ustar00runnerdockerAPI === .. module:: treebeard.models .. inheritance-diagram:: Node .. autoclass:: Node :show-inheritance: This is the base class that defines the API of all tree models in this library: - :class:`treebeard.mp_tree.MP_Node` (materialized path) - :class:`treebeard.ns_tree.NS_Node` (nested sets) - :class:`treebeard.al_tree.AL_Node` (adjacency list) .. warning:: Please be aware of the :doc:`caveats` when using this library. .. automethod:: Node.add_root Example: .. code-block:: python MyNode.add_root(numval=1, strval='abcd') Or, to pass in an existing instance: .. code-block:: python new_node = MyNode(numval=1, strval='abcd') MyNode.add_root(instance=new_node) .. automethod:: add_child Example: .. code-block:: python node.add_child(numval=1, strval='abcd') Or, to pass in an existing instance: .. code-block:: python new_node = MyNode(numval=1, strval='abcd') node.add_child(instance=new_node) .. automethod:: add_sibling Examples: .. code-block:: python node.add_sibling('sorted-sibling', numval=1, strval='abc') Or, to pass in an existing instance: .. code-block:: python new_node = MyNode(numval=1, strval='abc') node.add_sibling('sorted-sibling', instance=new_node) .. automethod:: delete .. note:: Call our queryset's delete to handle children removal. Subclasses will handle extra maintenance. .. automethod:: get_tree .. automethod:: get_depth Example: .. code-block:: python node.get_depth() .. automethod:: get_ancestors Example: .. code-block:: python node.get_ancestors() .. automethod:: get_children Example: .. code-block:: python node.get_children() .. automethod:: get_children_count Example: .. code-block:: python node.get_children_count() .. automethod:: get_descendants Example: .. code-block:: python node.get_descendants() .. automethod:: get_descendant_count Example: .. code-block:: python node.get_descendant_count() .. automethod:: get_first_child Example: .. code-block:: python node.get_first_child() .. automethod:: get_last_child Example: .. code-block:: python node.get_last_child() .. automethod:: get_first_sibling Example: .. code-block:: python node.get_first_sibling() .. automethod:: get_last_sibling Example: .. code-block:: python node.get_last_sibling() .. automethod:: get_prev_sibling Example: .. code-block:: python node.get_prev_sibling() .. automethod:: get_next_sibling Example: .. code-block:: python node.get_next_sibling() .. automethod:: get_parent Example: .. code-block:: python node.get_parent() .. automethod:: get_root Example: .. code-block:: python node.get_root() .. automethod:: get_siblings Example: .. code-block:: python node.get_siblings() .. automethod:: is_child_of Example: .. code-block:: python node.is_child_of(node2) .. automethod:: is_descendant_of Example: .. code-block:: python node.is_descendant_of(node2) .. automethod:: is_sibling_of Example: .. code-block:: python node.is_sibling_of(node2) .. automethod:: is_root Example: .. code-block:: python node.is_root() .. automethod:: is_leaf Example: .. code-block:: python node.is_leaf() .. automethod:: move .. note:: The node can be moved under another root node. Examples: .. code-block:: python node.move(node2, 'sorted-child') node.move(node2, 'prev-sibling') .. automethod:: save .. automethod:: get_first_root_node Example: .. code-block:: python MyNodeModel.get_first_root_node() .. automethod:: get_last_root_node Example: .. code-block:: python MyNodeModel.get_last_root_node() .. automethod:: get_root_nodes Example: .. code-block:: python MyNodeModel.get_root_nodes() .. automethod:: load_bulk .. note:: Any internal data that you may have stored in your nodes' data (:attr:`path`, :attr:`depth`) will be ignored. .. note:: If your node model has a ForeignKey this method will try to load the related object before loading the data. If the related object doesn't exist it won't load anything and will raise a DoesNotExist exception. This is done because the dump_data method uses integers to dump related objects. .. note:: If your node model has :attr:`node_order_by` enabled, it will take precedence over the order in the structure. Example: .. code-block:: python data = [{'data':{'desc':'1'}}, {'data':{'desc':'2'}, 'children':[ {'data':{'desc':'21'}}, {'data':{'desc':'22'}}, {'data':{'desc':'23'}, 'children':[ {'data':{'desc':'231'}}, ]}, {'data':{'desc':'24'}}, ]}, {'data':{'desc':'3'}}, {'data':{'desc':'4'}, 'children':[ {'data':{'desc':'41'}}, ]}, ] # parent = None MyNodeModel.load_bulk(data, None) Will create: .. digraph:: load_bulk_digraph "1"; "2"; "2" -> "21"; "2" -> "22"; "2" -> "23" -> "231"; "2" -> "24"; "3"; "4"; "4" -> "41"; .. automethod:: dump_bulk Example: .. code-block:: python tree = MyNodeModel.dump_bulk() branch = MyNodeModel.dump_bulk(node_obj) .. automethod:: find_problems .. automethod:: fix_tree .. automethod:: get_descendants_group_count Example: .. code-block:: python # get a list of the root nodes root_nodes = MyModel.get_descendants_group_count() for node in root_nodes: print '%s by %s (%d replies)' % (node.comment, node.author, node.descendants_count) .. automethod:: get_annotated_list Example: .. code-block:: python annotated_list = MyModel.get_annotated_list() With data: .. digraph:: get_annotated_list_digraph "a"; "a" -> "ab"; "ab" -> "aba"; "ab" -> "abb"; "ab" -> "abc"; "a" -> "ac"; Will return: .. code-block:: python [ (a, {'open':True, 'close':[], 'level': 0}) (ab, {'open':True, 'close':[], 'level': 1}) (aba, {'open':True, 'close':[], 'level': 2}) (abb, {'open':False, 'close':[], 'level': 2}) (abc, {'open':False, 'close':[0,1], 'level': 2}) (ac, {'open':False, 'close':[0], 'level': 1}) ] This can be used with a template like: .. code-block:: django {% for item, info in annotated_list %} {% if info.open %}

  • {% else %}
  • {% endif %} {{ item }} {% for close in info.close %}
{% endfor %} {% endfor %} .. note:: This method was contributed originally by `Alexey Kinyov `_, using an idea borrowed from `django-mptt`_. .. versionadded:: 1.55 .. automethod:: get_annotated_list_qs .. automethod:: get_database_vendor Example: .. code-block:: python MyNodeModel.get_database_vendor("write") .. versionadded:: 1.61 .. _django-mptt: https://github.com/django-mptt/django-mptt/././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/caveats.rst0000644000175100001770000000323314556473264020756 0ustar00runnerdockerKnown Caveats ============= Raw Queries ----------- ``django-treebeard`` uses Django raw SQL queries for some write operations, and raw queries don't update the objects in the ORM since it's being bypassed. Because of this, if you have a node in memory and plan to use it after a tree modification (adding/removing/moving nodes), you need to reload it. Overriding the default manager ------------------------------ One of the most common source of bug reports in ``django-treebeard`` is the overriding of the default managers in the subclasses. ``django-treebeard`` relies on the default manager for correctness and internal maintenance. If you override the default manager, by overriding the ``objects`` member in your subclass, you *WILL* have errors and inconsistencies in your tree. To avoid this problem, if you need to override the default manager, you'll *NEED* to subclass the manager from the base manager class for the tree you are using. Read the documentation in each tree type for details. Custom Managers --------------- Related to the previous caveat, if you need to create custom managers, you *NEED* to subclass the manager from the base manager class for the tree you are using. Read the documentation in each tree type for details. Copying model instances ----------------------- Starting in version 4.5, we made a change to support custom names in primary fields that exposed a bug in Django's documentation. This has been fixed in the dev version of Django (3.2 as of writing this), but even when using older versions, the `new instructions`_ apply. .. _new instructions: https://docs.djangoproject.com/en/3.2/topics/db/queries/#copying-model-instances ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/changes.rst0000644000175100001770000000006314556473264020736 0ustar00runnerdockerChangelog ========= .. include:: ../../CHANGES.md ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/conf.py0000644000175100001770000002313714556473264020102 0ustar00runnerdocker# -*- coding: utf-8 -*- # # django-treebeard documentation build configuration file, created by # sphinx-quickstart on Tue Nov 22 00:05:34 2016. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import os import sys import django from django.conf import settings # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '_ext'))) settings.configure( INSTALLED_APPS=['treebeard'], ) django.setup() # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'djangodocs', 'sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.graphviz', 'sphinx.ext.inheritance_diagram', 'sphinx.ext.todo', 'sphinx.ext.intersphinx', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = u'django-treebeard' copyright = u'2016, Gustavo Picón' author = u'Gustavo Picón' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '4' # The full version, including alpha/beta/rc tags. release = '4.7' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all # documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'default' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' # html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # Now only 'ja' uses this config value # html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = 'django-treebearddoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # 'preamble': '', # Latex figure (float) alignment # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'django-treebeard.tex', u'django-treebeard Documentation', u'Gustavo Picón', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'django-treebeard', u'django-treebeard Documentation', [author], 1) ] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'django-treebeard', u'django-treebeard Documentation', author, 'django-treebeard', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False intersphinx_mapping = { 'django': ('https://docs.djangoproject.com/en/stable/', 'https://docs.djangoproject.com/en/stable/_objects/'), 'python': ('https://docs.python.org/3.7', None), } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/exceptions.rst0000644000175100001770000000036514556473264021514 0ustar00runnerdockerExceptions ========== .. module:: treebeard.exceptions .. autoexception:: InvalidPosition .. autoexception:: InvalidMoveToDescendant .. autoexception:: NodeAlreadySaved .. autoexception:: PathOverflow .. autoexception:: MissingNodeOrderBy ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/forms.rst0000644000175100001770000000114014556473264020451 0ustar00runnerdockerForms ===== .. module:: treebeard.forms .. autoclass:: MoveNodeForm :show-inheritance: .. autofunction:: movenodeform_factory For a full reference of this function, please read :py:func:`~django.forms.models.modelform_factory` Example, ``MyNode`` is a subclass of :py:class:`treebeard.al_tree.AL_Node`: .. code-block:: python MyNodeForm = movenodeform_factory(MyNode) is equivalent to: .. code-block:: python class MyNodeForm(MoveNodeForm): class Meta: model = models.MyNode exclude = ('sib_order', 'parent') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/index.rst0000644000175100001770000000227214556473264020441 0ustar00runnerdockerdjango-treebeard ================ `django-treebeard `_ is a library that implements efficient tree implementations for the `Django Web Framework 1.8+ `_, written by `Gustavo Picón `_ and licensed under the Apache License 2.0. ``django-treebeard`` is: - **Flexible**: Includes 3 different tree implementations with the same API: 1. :doc:`Adjacency List ` 2. :doc:`Materialized Path ` 3. :doc:`Nested Sets ` - **Fast**: Optimized non-naive tree operations - **Easy**: Uses Django's :ref:`model-inheritance` with :ref:`abstract-base-classes`. to define your own models. - **Clean**: Testable and well tested code base. Code/branch test coverage is above 96%. Overview -------- .. toctree:: install tutorial caveats .. toctree:: :titlesonly: changes Reference --------- .. toctree:: api mp_tree ns_tree al_tree exceptions Additional features ------------------- .. toctree:: admin forms Development ----------- .. toctree:: tests Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/install.rst0000644000175100001770000000404614556473264021001 0ustar00runnerdockerInstallation ============ Prerequisites ------------- ``django-treebeard`` needs at least **Python 3.6** to run, and **Django 2.2 or later**. Installing ---------- You have several ways to install ``django-treebeard``. If you're not sure, `just use pip `_ pip (or easy_install) ~~~~~~~~~~~~~~~~~~~~~ You can install the release versions from `django-treebeard's PyPI page`_ using ``pip``: .. code-block:: console $ pip install django-treebeard or if for some reason you can't use ``pip``, you can try ``easy_install``, (at your own risk): .. code-block:: console $ easy_install --always-unzip django-treebeard setup.py ~~~~~~~~ Download a release from the `treebeard download page`_ and unpack it, then run: .. _`treebeard download page`: https://github.com/django-treebeard/django-treebeard/releases .. code-block:: console $ python setup.py install .deb packages ~~~~~~~~~~~~~ Both Debian and Ubuntu include ``django-treebeard`` as a package, so you can just use: .. code-block:: console $ apt-get install python-django-treebeard or: .. code-block:: console $ aptitude install python-django-treebeard Remember that the packages included in linux distributions are usually not the most recent versions. Configuration ------------- Add ``'treebeard'`` to the :django:setting:`INSTALLED_APPS` section in your django settings file. .. note:: If you are going to use the :class:`~treebeard.admin.TreeAdmin` class, you need to add the path to treebeard's templates in :django:setting:`TEMPLATE_DIRS`. Also you need to add ``django.template.context_processors.request`` to :django:setting:`TEMPLATES['OPTIONS']['context_processors']` setting in your django settings file (see https://docs.djangoproject.com/en/1.11/ref/templates/upgrading/ for how to define this setting within the TEMPLATES settings). For more recent versions of Django, use ``django.core.context_processors.request`` instead. .. _`django-treebeard's PyPI page`: https://pypi.org/project/django-treebeard/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/mp_tree.rst0000644000175100001770000002031014556473264020756 0ustar00runnerdockerMaterialized Path trees ======================= .. module:: treebeard.mp_tree This is an efficient implementation of Materialized Path trees for Django, as described by `Vadim Tropashko`_ in `SQL Design Patterns`_. Materialized Path is probably the fastest way of working with trees in SQL without the need of extra work in the database, like Oracle's ``CONNECT BY`` or sprocs and triggers for nested intervals. In a materialized path approach, every node in the tree will have a :attr:`~MP_Node.path` attribute, where the full path from the root to the node will be stored. This has the advantage of needing very simple and fast queries, at the risk of inconsistency because of the denormalization of ``parent``/``child`` foreign keys. This can be prevented with transactions. ``django-treebeard`` uses a particular approach: every step in the path has a fixed width and has no separators. This makes queries predictable and faster at the cost of using more characters to store a step. To address this problem, every step number is encoded. Also, two extra fields are stored in every node: :attr:`~MP_Node.depth` and :attr:`~MP_Node.numchild`. This makes the read operations faster, at the cost of a little more maintenance on tree updates/inserts/deletes. Don't worry, even with these extra steps, materialized path is more efficient than other approaches. .. warning:: As with all tree implementations, please be aware of the :doc:`caveats`. .. note:: The materialized path approach makes heavy use of ``LIKE`` in your database, with clauses like ``WHERE path LIKE '002003%'``. If you think that ``LIKE`` is too slow, you're right, but in this case the :attr:`~MP_Node.path` field is indexed in the database, and all ``LIKE`` clauses that don't **start** with a ``%`` character will use the index. This is what makes the materialized path approach so fast. .. inheritance-diagram:: MP_Node .. autoclass:: MP_Node :show-inheritance: .. warning:: Do not change the values of :attr:`path`, :attr:`depth` or :attr:`numchild` directly: use one of the included methods instead. Consider these values *read-only*. .. warning:: Do not change the values of the :attr:`steplen`, :attr:`alphabet` or :attr:`node_order_by` after saving your first object. Doing so will corrupt the tree. .. warning:: If you need to define your own :py:class:`~django.db.models.Manager` class, you'll need to subclass :py:class:`~MP_NodeManager`. Also, if in your manager you need to change the default queryset handler, you'll need to subclass :py:class:`~MP_NodeQuerySet`. Example: .. code-block:: python class SortedNode(MP_Node): node_order_by = ['numval', 'strval'] numval = models.IntegerField() strval = models.CharField(max_length=255) Read the API reference of :class:`treebeard.models.Node` for info on methods available in this class, or read the following section for methods with particular arguments or exceptions. .. attribute:: steplen Attribute that defines the length of each step in the :attr:`path` of a node. The default value of *4* allows a maximum of *1679615* children per node. Increase this value if you plan to store large trees (a ``steplen`` of *5* allows more than *60M* children per node). Note that increasing this value, while increasing the number of children per node, will decrease the max :attr:`depth` of the tree (by default: *63*). To increase the max :attr:`depth`, increase the max_length attribute of the :attr:`path` field in your model. .. attribute:: alphabet Attribute: the alphabet that will be used in base conversions when encoding the path steps into strings. The default value, ``0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ`` is the most optimal possible value that is portable between the supported databases (which means: their default collation will order the :attr:`path` field correctly). .. note:: In case you know what you are doing, there is a test that is disabled by default that can tell you the optimal default alphabet in your enviroment. To run the test you must enable the :envvar:`TREEBEARD_TEST_ALPHABET` enviroment variable: .. code-block:: console $ TREEBEARD_TEST_ALPHABET=1 py.test -k test_alphabet In OS X Mavericks, good readable values for the three supported databases in their *default* configuration: ================ ================ ==== Database Optimal Alphabet Base ================ ================ ==== MySQL 5.6.17 0-9A-Z 36 PostgreSQL 9.3.4 0-9A-Za-z 62 Sqlite3 0-9A-Za-z 62 ================ ================ ==== The default value is MySQL's since it will work in all DBs, but when working with a better database, changing the :attr:`alphabet` value is recommended in order to increase the density of the paths. For an even better approach, change the collation of the :attr:`path` column in the database to handle raw ASCII, and use the printable ASCII characters (0x20 to 0x7E) as the :attr:`alphabet`. .. attribute:: node_order_by Attribute: a list of model fields that will be used for node ordering. When enabled, all tree operations will assume this ordering. Example: .. code-block:: python node_order_by = ['field1', 'field2', 'field3'] .. attribute:: path ``CharField``, stores the full materialized path for each node. The default value of it's max_length, *255*, is the max efficient and portable value for a ``varchar``. Increase it to allow deeper trees (max depth by default: *63*) .. note:: `django-treebeard` uses Django's abstract model inheritance, so to change the ``max_length`` value of the path in your model, you have to redeclare the path field in your model: .. code-block:: python class MyNodeModel(MP_Node): path = models.CharField(max_length=1024, unique=True) .. note:: For performance, and if your database allows it, you can safely define the path column as ASCII (not utf-8/unicode/iso8859-1/etc) to keep the index smaller (and faster). Also note that some databases (mysql) have a small index size limit. InnoDB for instance has a limit of 765 bytes per index, so that would be the limit if your path is ASCII encoded. If your path column in InnoDB is using unicode, the index limit will be 255 characters since in MySQL's indexes, unicode means 3 bytes per character. .. note:: ``django-treebeard`` uses `numconv`_ for path encoding. .. attribute:: depth ``PositiveIntegerField``, depth of a node in the tree. A root node has a depth of *1*. .. attribute:: numchild ``PositiveIntegerField``, the number of children of the node. .. automethod:: add_root See: :meth:`treebeard.models.Node.add_root` .. automethod:: add_child See: :meth:`treebeard.models.Node.add_child` .. automethod:: add_sibling See: :meth:`treebeard.models.Node.add_sibling` .. automethod:: move See: :meth:`treebeard.models.Node.move` .. automethod:: get_tree See: :meth:`treebeard.models.Node.get_tree` .. note:: This method returns a queryset. .. automethod:: find_problems .. note:: A node won't appear in more than one list, even when it exhibits more than one problem. This method stops checking a node when it finds a problem and continues to the next node. .. note:: Problems 1, 2 and 3 can't be solved automatically. Example: .. code-block:: python MyNodeModel.find_problems() .. automethod:: fix_tree Example: .. code-block:: python MyNodeModel.fix_tree() .. autoclass:: MP_NodeManager :show-inheritance: .. autoclass:: MP_NodeQuerySet :show-inheritance: .. _`Vadim Tropashko`: http://vadimtropashko.wordpress.com/ .. _`Sql Design Patterns`: http://www.rampant-books.com/book_2006_1_sql_coding_styles.htm .. _numconv: https://tabo.pe/projects/numconv/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/ns_tree.rst0000644000175100001770000000342214556473264020767 0ustar00runnerdockerNested Sets trees ================= .. module:: treebeard.ns_tree An implementation of Nested Sets trees for Django, as described by `Joe Celko`_ in `Trees and Hierarchies in SQL for Smarties`_. Nested sets have very efficient reads at the cost of high maintenance on write/delete operations. .. warning:: As with all tree implementations, please be aware of the :doc:`caveats`. .. inheritance-diagram:: NS_Node .. autoclass:: NS_Node :show-inheritance: .. warning:: If you need to define your own :py:class:`~django.db.models.Manager` class, you'll need to subclass :py:class:`~NS_NodeManager`. Also, if in your manager you need to change the default queryset handler, you'll need to subclass :py:class:`~NS_NodeQuerySet`. .. attribute:: node_order_by Attribute: a list of model fields that will be used for node ordering. When enabled, all tree operations will assume this ordering. Example: .. code-block:: python node_order_by = ['field1', 'field2', 'field3'] .. attribute:: depth ``PositiveIntegerField``, depth of a node in the tree. A root node has a depth of *1*. .. attribute:: lft ``PositiveIntegerField`` .. attribute:: rgt ``PositiveIntegerField`` .. attribute:: tree_id ``PositiveIntegerField`` .. automethod:: get_tree See: :meth:`treebeard.models.Node.get_tree` .. note:: This method returns a queryset. .. autoclass:: NS_NodeManager :show-inheritance: .. autoclass:: NS_NodeQuerySet :show-inheritance: .. _`Joe Celko`: http://en.wikipedia.org/wiki/Joe_Celko .. _`Trees and Hierarchies in SQL for Smarties`: https://shop.elsevier.com/books/joe-celkos-trees-and-hierarchies-in-sql-for-smarties/celko/978-0-12-387733-8 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/tests.rst0000644000175100001770000000265014556473264020474 0ustar00runnerdockerRunning the Test Suite ====================== ``django-treebeard`` includes a comprehensive test suite. It is highly recommended that you run and update the test suite when you send patches. pytest ------ You will need `pytest`_ to run the test suite: .. code-block:: console $ pip install pytest Then just run the test suite: .. code-block:: console $ pytest You can use all the features and plugins of pytest this way. By default the test suite will run using a sqlite3 database in RAM, but you can change this setting environment variables: .. option:: DATABASE_USER .. option:: DATABASE_PASSWORD .. option:: DATABASE_HOST .. option:: DATABASE_USER_POSTGRES .. option:: DATABASE_PORT_POSTGRES .. option:: DATABASE_USER_MYSQL .. option:: DATABASE_PORT_MYSQL Sets the database settings to be used by the test suite. Useful if you want to test the same database engine/version you use in production. tox --- ``django-treebeard`` uses `tox`_ to run the test suite in all the supported environments - permutations of: - Python 3.8 - 3.11 - Django 3.2, 4.1 and 4.2 - Sqlite, MySQL, PostgreSQL and MSSQL This means that there are a lot of permutations, which takes a long time. If you want to test only one or a few environments, use the `-e` option in `tox`_, like: .. code-block:: console $ tox -e py39-dj32-postgres .. _pytest: http://pytest.org/ .. _tox: https://tox.readthedocs.io/en/latest/index.html ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/docs/source/tutorial.rst0000644000175100001770000000654314556473264021202 0ustar00runnerdockerTutorial ======== Create a basic model for your tree. In this example we'll use a Materialized Path tree: .. code-block:: python from django.db import models from treebeard.mp_tree import MP_Node class Category(MP_Node): name = models.CharField(max_length=30) node_order_by = ['name'] def __str__(self): return 'Category: {}'.format(self.name) Create and apply migrations: .. code-block:: console $ python manage.py makemigrations $ python manage.py migrate Let's create some nodes: .. code-block:: python >>> from treebeard_tutorial.models import Category >>> get = lambda node_id: Category.objects.get(pk=node_id) >>> root = Category.add_root(name='Computer Hardware') >>> node = get(root.pk).add_child(name='Memory') >>> get(node.pk).add_sibling(name='Hard Drives') >>> get(node.pk).add_sibling(name='SSD') >>> get(node.pk).add_child(name='Desktop Memory') >>> get(node.pk).add_child(name='Laptop Memory') >>> get(node.pk).add_child(name='Server Memory') .. note:: Why retrieving every node again after the first operation? Because ``django-treebeard`` uses raw queries for most write operations, and raw queries don't update the django objects of the db entries they modify. See: :doc:`caveats`. We just created this tree: .. digraph:: introduction_digraph "Computer Hardware"; "Computer Hardware" -> "Hard Drives"; "Computer Hardware" -> "Memory"; "Memory" -> "Desktop Memory"; "Memory" -> "Laptop Memory"; "Memory" -> "Server Memory"; "Computer Hardware" -> "SSD"; You can see the tree structure with code: .. code-block:: python >>> Category.dump_bulk() [{'id': 1, 'data': {'name': u'Computer Hardware'}, 'children': [ {'id': 3, 'data': {'name': u'Hard Drives'}}, {'id': 2, 'data': {'name': u'Memory'}, 'children': [ {'id': 5, 'data': {'name': u'Desktop Memory'}}, {'id': 6, 'data': {'name': u'Laptop Memory'}}, {'id': 7, 'data': {'name': u'Server Memory'}}]}, {'id': 4, 'data': {'name': u'SSD'}}]}] >>> Category.get_annotated_list() [(, {'close': [], 'level': 0, 'open': True}), (, {'close': [], 'level': 1, 'open': True}), (, {'close': [], 'level': 1, 'open': False}), (, {'close': [], 'level': 2, 'open': True}), (, {'close': [], 'level': 2, 'open': False}), (, {'close': [0], 'level': 2, 'open': False}), (, {'close': [0, 1], 'level': 1, 'open': False})] >>> Category.get_annotated_list_qs(Category.objects.filter(name__icontains='Hardware')) [(, {'open': True, 'close': [], 'level': 0})] Read the :class:`treebeard.models.Node` API reference for detailed info. .. _`treebeard mercurial repository`: http://code.tabo.pe/django-treebeard .. _`latest treebeard version from PyPI`: https://pypi.org/project/django-treebeard/ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9106185 django-treebeard-4.7.1/setup.cfg0000644000175100001770000000011114556473276016162 0ustar00runnerdocker[metadata] license_file = LICENSE [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/setup.py0000644000175100001770000000335414556473264016064 0ustar00runnerdocker#!/usr/bin/env python from setuptools import setup, find_packages from treebeard import __version__ with open('README.md') as fh: long_description = fh.read() setup_args = dict( name='django-treebeard', version=__version__, url='https://github.com/django-treebeard/django-treebeard/', author='Gustavo Picon', author_email='tabo@tabo.pe', license='Apache License 2.0', packages=find_packages(exclude=['docs']), include_package_data=True, description='Efficient tree implementations for Django', long_description=long_description, long_description_content_type='text/markdown', python_requires='>=3.8', install_requires=['Django>=3.2'], tests_require=[ 'pytest-django>=4.0,<5.0', # adds cwd() to the pythonpath, so we can run tests without # installing treebeard 'pytest-pythonpath>=0.7,<1.0' ], classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'License :: OSI Approved :: Apache Software License', 'Environment :: Web Environment', 'Framework :: Django', 'Framework :: Django :: 3.2', 'Framework :: Django :: 4.1', 'Framework :: Django :: 4.2', 'Framework :: Django :: 5.0', 'Programming Language :: Python', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Operating System :: OS Independent', 'Topic :: Software Development :: Libraries', 'Topic :: Utilities']) if __name__ == '__main__': setup(**setup_args) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8986187 django-treebeard-4.7.1/treebeard/0000755000175100001770000000000014556473276016305 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/__init__.py0000644000175100001770000000112614556473264020413 0ustar00runnerdocker""" See PEP 386 (https://www.python.org/dev/peps/pep-0386/) Release logic: 1. Remove ".devX" from __version__ (below) 2. git add treebeard/__init__.py 3. git commit -m 'Bump to ' 4. git tag 5. git push 6. ensure that all tests pass on Github Actions 7. git push --tags 8. pip install --upgrade pip wheel twine 9. python setup.py clean --all 9. python setup.py sdist bdist_wheel 10. twine upload dist/* 11. bump the version, append ".dev0" to __version__ 12. git add treebeard/__init__.py 13. git commit -m 'Start with ' 14. git push """ __version__ = '4.7.1' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/admin.py0000644000175100001770000001121614556473264017745 0ustar00runnerdocker"""Django admin support for treebeard""" import sys from django.conf import settings from django.contrib import admin, messages from django.http import HttpResponse, HttpResponseBadRequest from django.urls import path from django.utils.translation import gettext_lazy as _ from django.utils.encoding import force_str from treebeard.exceptions import (InvalidPosition, MissingNodeOrderBy, InvalidMoveToDescendant, PathOverflow) from treebeard.al_tree import AL_Node class TreeAdmin(admin.ModelAdmin): """Django Admin class for treebeard.""" change_list_template = 'admin/tree_change_list.html' def get_queryset(self, request): if issubclass(self.model, AL_Node): # AL Trees return a list instead of a QuerySet for .get_tree() # So we're returning the regular .get_queryset cause we will use # the old admin return super().get_queryset(request) else: return self.model.get_tree() def changelist_view(self, request, extra_context=None): if issubclass(self.model, AL_Node): # For AL trees, use the old admin display self.change_list_template = 'admin/tree_list.html' if extra_context is None: extra_context = {} request_context = any( map( lambda tmpl: tmpl.get('BACKEND', None) == 'django.template.backends.django.DjangoTemplates' and tmpl.get('APP_DIRS', False) and 'django.template.context_processors.request' in tmpl.get('OPTIONS', {}).get('context_processors', []), settings.TEMPLATES ) ) lacks_request = ('request' not in extra_context and not request_context) if lacks_request: extra_context['request'] = request return super().changelist_view(request, extra_context) def get_urls(self): """ Adds a url to move nodes to this admin """ urls = super().get_urls() from django.views.i18n import JavaScriptCatalog jsi18n_url = path('jsi18n/', JavaScriptCatalog.as_view(packages=['treebeard']), name='javascript-catalog' ) new_urls = [ path('move/', self.admin_site.admin_view(self.move_node), ), jsi18n_url, ] return new_urls + urls def get_node(self, node_id): return self.model.objects.get(pk=node_id) def try_to_move_node(self, as_child, node, pos, request, target): try: node.move(target, pos=pos) # Call the save method on the (reloaded) node in order to trigger # possible signal handlers etc. node = self.get_node(node.pk) node.save() except (MissingNodeOrderBy, PathOverflow, InvalidMoveToDescendant, InvalidPosition): e = sys.exc_info()[1] # An error was raised while trying to move the node, then set an # error message and return 400, this will cause a reload on the # client to show the message messages.error(request, _('Exception raised while moving node: %s') % _( force_str(e))) return HttpResponseBadRequest('Exception raised during move') if as_child: msg = _('Moved node "%(node)s" as child of "%(other)s"') else: msg = _('Moved node "%(node)s" as sibling of "%(other)s"') messages.info(request, msg % {'node': node, 'other': target}) return HttpResponse('OK') def move_node(self, request): try: node_id = request.POST['node_id'] target_id = request.POST['sibling_id'] as_child = bool(int(request.POST.get('as_child', 0))) except (KeyError, ValueError): # Some parameters were missing return a BadRequest return HttpResponseBadRequest('Malformed POST params') node = self.get_node(node_id) target = self.get_node(target_id) is_sorted = True if node.node_order_by else False pos = { (True, True): 'sorted-child', (True, False): 'last-child', (False, True): 'sorted-sibling', (False, False): 'left', }[as_child, is_sorted] return self.try_to_move_node(as_child, node, pos, request, target) def admin_factory(form_class): """Dynamically build a TreeAdmin subclass for the given form class. :param form_class: :return: A TreeAdmin subclass. """ return type( form_class.__name__ + 'Admin', (TreeAdmin,), dict(form=form_class)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/al_tree.py0000644000175100001770000003316414556473264020276 0ustar00runnerdocker"""Adjacency List""" from django.core import serializers from django.db import models from django.utils.translation import gettext_noop as _ from treebeard.exceptions import InvalidMoveToDescendant, NodeAlreadySaved from treebeard.models import Node def get_result_class(cls): """ For the given model class, determine what class we should use for the nodes returned by its tree methods (such as get_children). Usually this will be trivially the same as the initial model class, but there are special cases when model inheritance is in use: * If the model extends another via multi-table inheritance, we need to use whichever ancestor originally implemented the tree behaviour (i.e. the one which defines the 'parent' field). We can't use the subclass, because it's not guaranteed that the other nodes reachable from the current one will be instances of the same subclass. * If the model is a proxy model, the returned nodes should also use the proxy class. """ base_class = cls._meta.get_field('parent').model if cls._meta.proxy_for_model == base_class: return cls else: return base_class class AL_NodeManager(models.Manager): """Custom manager for nodes in an Adjacency List tree.""" def get_queryset(self): """Sets the custom queryset as the default.""" if self.model.node_order_by: order_by = ['parent'] + list(self.model.node_order_by) else: order_by = ['parent', 'sib_order'] return super().get_queryset().order_by(*order_by) class AL_Node(Node): """Abstract model to create your own Adjacency List Trees.""" objects = AL_NodeManager() node_order_by = None @classmethod def add_root(cls, **kwargs): """Adds a root node to the tree.""" if len(kwargs) == 1 and 'instance' in kwargs: # adding the passed (unsaved) instance to the tree newobj = kwargs['instance'] if not newobj._state.adding: raise NodeAlreadySaved("Attempted to add a tree node that is "\ "already in the database") else: newobj = cls(**kwargs) newobj._cached_depth = 1 if not cls.node_order_by: try: max = get_result_class(cls).objects.filter( parent__isnull=True).order_by( 'sib_order').reverse()[0].sib_order except IndexError: max = 0 newobj.sib_order = max + 1 newobj.save() return newobj @classmethod def get_root_nodes(cls): """:returns: A queryset containing the root nodes in the tree.""" return get_result_class(cls).objects.filter(parent__isnull=True) def get_depth(self, update=False): """ :returns: the depth (level) of the node Caches the result in the object itself to help in loops. :param update: Updates the cached value. """ if self.parent_id is None: return 1 try: if update: del self._cached_depth else: return self._cached_depth except AttributeError: pass depth = 0 node = self while node: node = node.parent depth += 1 self._cached_depth = depth return depth def get_children(self): """:returns: A queryset of all the node's children""" return get_result_class(self.__class__).objects.filter(parent=self) def get_parent(self, update=False): """:returns: the parent node of the current node object.""" if self._meta.proxy_for_model: # the current node is a proxy model; the returned parent # should be the same proxy model, so we need to explicitly # fetch it as an instance of that model rather than simply # following the 'parent' relation if self.parent_id is None: return None else: return self.__class__.objects.get(pk=self.parent_id) else: return self.parent def get_ancestors(self): """ :returns: A *list* containing the current node object's ancestors, starting by the root node and descending to the parent. """ ancestors = [] if self._meta.proxy_for_model: # the current node is a proxy model; our result set # should use the same proxy model, so we need to # explicitly fetch instances of that model # when following the 'parent' relation cls = self.__class__ node = self while node.parent_id: node = cls.objects.get(pk=node.parent_id) ancestors.insert(0, node) else: node = self.parent while node: ancestors.insert(0, node) node = node.parent return ancestors def get_root(self): """:returns: the root node for the current node object.""" ancestors = self.get_ancestors() if ancestors: return ancestors[0] return self def is_descendant_of(self, node): """ :returns: ``True`` if the node if a descendant of another node given as an argument, else, returns ``False`` """ return self.pk in [obj.pk for obj in node.get_descendants()] @classmethod def dump_bulk(cls, parent=None, keep_ids=True): """Dumps a tree branch to a python data structure.""" serializable_cls = cls._get_serializable_model() if ( parent and serializable_cls != cls and parent.__class__ != serializable_cls ): parent = serializable_cls.objects.get(pk=parent.pk) # a list of nodes: not really a queryset, but it works objs = serializable_cls.get_tree(parent) ret, lnk = [], {} pk_field = cls._meta.pk.attname for node, pyobj in zip(objs, serializers.serialize('python', objs)): depth = node.get_depth() # django's serializer stores the attributes in 'fields' fields = pyobj['fields'] del fields['parent'] # non-sorted trees have this if 'sib_order' in fields: del fields['sib_order'] if pk_field in fields: del fields[pk_field] newobj = {'data': fields} if keep_ids: newobj[pk_field] = pyobj['pk'] if (not parent and depth == 1) or\ (parent and depth == parent.get_depth()): ret.append(newobj) else: parentobj = lnk[node.parent_id] if 'children' not in parentobj: parentobj['children'] = [] parentobj['children'].append(newobj) lnk[node.pk] = newobj return ret def add_child(self, **kwargs): """Adds a child to the node.""" cls = get_result_class(self.__class__) if len(kwargs) == 1 and 'instance' in kwargs: # adding the passed (unsaved) instance to the tree newobj = kwargs['instance'] if not newobj._state.adding: raise NodeAlreadySaved("Attempted to add a tree node that is "\ "already in the database") else: newobj = cls(**kwargs) try: newobj._cached_depth = self._cached_depth + 1 except AttributeError: pass if not cls.node_order_by: try: max = cls.objects.filter(parent=self).reverse( )[0].sib_order except IndexError: max = 0 newobj.sib_order = max + 1 newobj.parent = self newobj.save() return newobj @classmethod def _get_tree_recursively(cls, results, parent, depth): if parent: nodes = parent.get_children() else: nodes = cls.get_root_nodes() for node in nodes: node._cached_depth = depth results.append(node) cls._get_tree_recursively(results, node, depth + 1) @classmethod def get_tree(cls, parent=None): """ :returns: A list of nodes ordered as DFS, including the parent. If no parent is given, the entire tree is returned. """ if parent: depth = parent.get_depth() + 1 results = [parent] else: depth = 1 results = [] cls._get_tree_recursively(results, parent, depth) return results def get_descendants(self): """ :returns: A *list* of all the node's descendants, doesn't include the node itself """ return self.__class__.get_tree(parent=self)[1:] def get_descendant_count(self): """:returns: the number of descendants of a nodee""" return len(self.get_descendants()) def get_siblings(self): """ :returns: A queryset of all the node's siblings, including the node itself. """ if self.parent: return get_result_class(self.__class__).objects.filter( parent=self.parent) return self.__class__.get_root_nodes() def add_sibling(self, pos=None, **kwargs): """Adds a new node as a sibling to the current node object.""" pos = self._prepare_pos_var_for_add_sibling(pos) if len(kwargs) == 1 and 'instance' in kwargs: # adding the passed (unsaved) instance to the tree newobj = kwargs['instance'] if not newobj._state.adding: raise NodeAlreadySaved("Attempted to add a tree node that is "\ "already in the database") else: # creating a new object newobj = get_result_class(self.__class__)(**kwargs) if not self.node_order_by: newobj.sib_order = self.__class__._get_new_sibling_order(pos, self) newobj.parent_id = self.parent_id newobj.save() return newobj @classmethod def _is_target_pos_the_last_sibling(cls, pos, target): return pos == 'last-sibling' or ( pos == 'right' and target == target.get_last_sibling()) @classmethod def _make_hole_in_db(cls, min, target_node): qset = get_result_class(cls).objects.filter(sib_order__gte=min) if target_node.is_root(): qset = qset.filter(parent__isnull=True) else: qset = qset.filter(parent=target_node.parent) qset.update(sib_order=models.F('sib_order') + 1) @classmethod def _make_hole_and_get_sibling_order(cls, pos, target_node): siblings = target_node.get_siblings() siblings = { 'left': siblings.filter(sib_order__gte=target_node.sib_order), 'right': siblings.filter(sib_order__gt=target_node.sib_order), 'first-sibling': siblings }[pos] sib_order = { 'left': target_node.sib_order, 'right': target_node.sib_order + 1, 'first-sibling': 1 }[pos] try: min = siblings.order_by('sib_order')[0].sib_order except IndexError: min = 0 if min: cls._make_hole_in_db(min, target_node) return sib_order @classmethod def _get_new_sibling_order(cls, pos, target_node): if cls._is_target_pos_the_last_sibling(pos, target_node): sib_order = target_node.get_last_sibling().sib_order + 1 else: sib_order = cls._make_hole_and_get_sibling_order(pos, target_node) return sib_order def move(self, target, pos=None): """ Moves the current node and all it's descendants to a new position relative to another node. """ pos = self._prepare_pos_var_for_move(pos) sib_order = None parent = None if pos in ('first-child', 'last-child', 'sorted-child'): # moving to a child if not target.is_leaf(): target = target.get_last_child() pos = {'first-child': 'first-sibling', 'last-child': 'last-sibling', 'sorted-child': 'sorted-sibling'}[pos] else: parent = target if pos == 'sorted-child': pos = 'sorted-sibling' else: pos = 'first-sibling' sib_order = 1 if target.is_descendant_of(self): raise InvalidMoveToDescendant( _("Can't move node to a descendant.")) if self == target and ( (pos == 'left') or (pos in ('right', 'last-sibling') and target == target.get_last_sibling()) or (pos == 'first-sibling' and target == target.get_first_sibling())): # special cases, not actually moving the node so no need to UPDATE return if pos == 'sorted-sibling': if parent: self.parent = parent else: self.parent = target.parent else: if sib_order: self.sib_order = sib_order else: self.sib_order = self.__class__._get_new_sibling_order(pos, target) if parent: self.parent = parent else: self.parent = target.parent self.save() class Meta: """Abstract model.""" abstract = True ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/exceptions.py0000644000175100001770000000140014556473264021030 0ustar00runnerdocker"""Treebeard exceptions""" class InvalidPosition(Exception): """Raised when passing an invalid pos value""" class InvalidMoveToDescendant(Exception): """Raised when attempting to move a node to one of it's descendants.""" class NodeAlreadySaved(Exception): """ Raised when attempting to add a node which is already saved to the database. """ class MissingNodeOrderBy(Exception): """ Raised when an operation needs a missing :attr:`~treebeard.MP_Node.node_order_by` attribute """ class PathOverflow(Exception): """ Raised when trying to add or move a node to a position where no more nodes can be added (see :attr:`~treebeard.MP_Node.path` and :attr:`~treebeard.MP_Node.alphabet` for more info) """ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/forms.py0000644000175100001770000001771714556473264020017 0ustar00runnerdocker"""Forms for treebeard.""" from django import forms from django.db.models.query import QuerySet from django.forms.models import ErrorList from django.forms.models import modelform_factory as django_modelform_factory from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from treebeard.al_tree import AL_Node from treebeard.mp_tree import MP_Node from treebeard.ns_tree import NS_Node class MoveNodeForm(forms.ModelForm): """ Form to handle moving a node in a tree. Handles sorted/unsorted trees. It adds two fields to the form: - Relative to: The target node where the current node will be moved to. - Position: The position relative to the target node that will be used to move the node. These can be: - For sorted trees: ``Child of`` and ``Sibling of`` - For unsorted trees: ``First child of``, ``Before`` and ``After`` .. warning:: Subclassing :py:class:`MoveNodeForm` directly is discouraged, since special care is needed to handle excluded fields, and these change depending on the tree type. It is recommended that the :py:func:`movenodeform_factory` function is used instead. """ __position_choices_sorted = ( ('sorted-child', _('Child of')), ('sorted-sibling', _('Sibling of')), ) __position_choices_unsorted = ( ('first-child', _('First child of')), ('left', _('Before')), ('right', _('After')), ) _position = forms.ChoiceField(required=True, label=_("Position")) _ref_node_id = forms.ChoiceField(required=False, label=_("Relative to")) def _get_position_ref_node(self, instance): if self.is_sorted: position = 'sorted-child' node_parent = instance.get_parent() if node_parent: ref_node_id = node_parent.pk else: ref_node_id = '' else: prev_sibling = instance.get_prev_sibling() if prev_sibling: position = 'right' ref_node_id = prev_sibling.pk else: position = 'first-child' if instance.is_root(): ref_node_id = '' else: ref_node_id = instance.get_parent().pk return {'_ref_node_id': ref_node_id, '_position': position} def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, initial=None, error_class=ErrorList, label_suffix=':', empty_permitted=False, instance=None, **kwargs): opts = self._meta if opts.model is None: raise ValueError('ModelForm has no model class specified') # update the '_position' field choices self.is_sorted = getattr(opts.model, 'node_order_by', False) if self.is_sorted: choices_sort_mode = self.__class__.__position_choices_sorted else: choices_sort_mode = self.__class__.__position_choices_unsorted self.declared_fields['_position'].choices = choices_sort_mode # update the '_ref_node_id' choices choices = self.mk_dropdown_tree(opts.model, for_node=instance) self.declared_fields['_ref_node_id'].choices = choices # use the formfield `to_python` method to coerse the field for custom ids pkFormField = opts.model._meta.pk.formfield() self.declared_fields['_ref_node_id'].coerce = pkFormField.to_python if pkFormField else int # put initial data for these fields into a map, update the map with # initial data, and pass this new map to the parent constructor as # initial data if instance is None: initial_ = {} else: initial_ = self._get_position_ref_node(instance) if initial is not None: initial_.update(initial) super().__init__( data=data, files=files, auto_id=auto_id, prefix=prefix, initial=initial_, error_class=error_class, label_suffix=label_suffix, empty_permitted=empty_permitted, instance=instance, **kwargs) def _clean_cleaned_data(self): """ delete auxilary fields not belonging to node model """ reference_node_id = None if '_ref_node_id' in self.cleaned_data: if self.cleaned_data['_ref_node_id'] != '0': reference_node_id = self.cleaned_data['_ref_node_id'] if reference_node_id.isdigit(): reference_node_id = int(reference_node_id) del self.cleaned_data['_ref_node_id'] position_type = self.cleaned_data['_position'] del self.cleaned_data['_position'] return position_type, reference_node_id def save(self, commit=True): position_type, reference_node_id = self._clean_cleaned_data() if self.instance._state.adding: if reference_node_id: reference_node = self._meta.model.objects.get( pk=reference_node_id) self.instance = reference_node.add_child(instance=self.instance) self.instance.move(reference_node, pos=position_type) else: self.instance = self._meta.model.add_root(instance=self.instance) else: self.instance.save() if reference_node_id: reference_node = self._meta.model.objects.get( pk=reference_node_id) self.instance.move(reference_node, pos=position_type) else: if self.is_sorted: pos = 'sorted-sibling' else: pos = 'first-sibling' self.instance.move(self._meta.model.get_first_root_node(), pos) # Reload the instance self.instance.refresh_from_db() super().save(commit=commit) return self.instance @staticmethod def is_loop_safe(for_node, possible_parent): if for_node is not None: return not ( possible_parent == for_node ) or (possible_parent.is_descendant_of(for_node)) return True @staticmethod def mk_indent(level): return '    ' * (level - 1) @classmethod def add_subtree(cls, for_node, node, options): """ Recursively build options tree. """ if cls.is_loop_safe(for_node, node): for item, _ in node.get_annotated_list(node): options.append((item.pk, mark_safe(cls.mk_indent(item.get_depth()) + escape(item)))) @classmethod def mk_dropdown_tree(cls, model, for_node=None): """ Creates a tree-like list of choices """ options = [(None, _('-- root --'))] for node in model.get_root_nodes(): cls.add_subtree(for_node, node, options) return options def movenodeform_factory(model, form=MoveNodeForm, fields=None, exclude=None, formfield_callback=None, widgets=None): """Dynamically build a MoveNodeForm subclass with the proper Meta. :param Node model: The subclass of :py:class:`Node` that will be handled by the form. :param form: The form class that will be used as a base. By default, :py:class:`MoveNodeForm` will be used. :return: A :py:class:`MoveNodeForm` subclass """ _exclude = _get_exclude_for_model(model, exclude) return django_modelform_factory( model, form, fields, _exclude, formfield_callback, widgets) def _get_exclude_for_model(model, exclude): if exclude: _exclude = tuple(exclude) else: _exclude = () if issubclass(model, AL_Node): _exclude += ('sib_order', 'parent') elif issubclass(model, MP_Node): _exclude += ('depth', 'numchild', 'path') elif issubclass(model, NS_Node): _exclude += ('depth', 'lft', 'rgt', 'tree_id') return _exclude ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8906186 django-treebeard-4.7.1/treebeard/locale/0000755000175100001770000000000014556473276017544 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8906186 django-treebeard-4.7.1/treebeard/locale/de/0000755000175100001770000000000014556473276020134 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9026186 django-treebeard-4.7.1/treebeard/locale/de/LC_MESSAGES/0000755000175100001770000000000014556473276021721 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/de/LC_MESSAGES/django.mo0000644000175100001770000000320514556473264023515 0ustar00runnerdockerx y &-/Oh q} jz ;'9;9u "n     -- root --AfterBeforeCan't move node to a descendant.Child ofException raised while moving node: %sFirst child ofMoved node "%(node)s" as child of "%(other)s"Moved node "%(node)s" as sibling of "%(other)s"Path Overflow from: '%s'PositionRelative toReturn to ordered treeSibling ofThe new node is too deep in the tree, try increasing the path.max_length property and UPDATE your databaseProject-Id-Version: PACKAGE VERSION Report-Msgid-Bugs-To: POT-Creation-Date: 2018-06-15 20:56+0000 PO-Revision-Date: 2018-06-15 23:09+0100 Last-Translator: FULL NAME Language-Team: LANGUAGE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); X-Generator: Poedit 1.5.7 -- Basiskategorie --NachVorKann Element nicht in ein eigenes Unter-Element verschiebenals Unterkategorie vonAusnahmefehler in folgendem Element: %sAls erste Unterkategorie vonElement "%(node)s" positioniert unterhalb von "%(other)s"Element "%(node)s" positioniert gleichauf mit "%(other)s"Pfad Überlauf von: '%s'Positionrelativ zuZurück zur geordneten Baumansichtauf gleicher Ebene wieDas neue Element ist zu tief positioniert. Versuche path.max_length zu erhöhen und aktualisiere die Datenbank././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/de/LC_MESSAGES/django.po0000644000175100001770000000450214556473264023521 0ustar00runnerdocker# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-06-15 20:56+0000\n" "PO-Revision-Date: 2018-06-15 23:09+0100\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 1.5.7\n" #: treebeard/admin.py:106 #, python-format msgid "Exception raised while moving node: %s" msgstr "Ausnahmefehler in folgendem Element: %s" #: treebeard/admin.py:110 #, python-format msgid "Moved node \"%(node)s\" as child of \"%(other)s\"" msgstr "Element \"%(node)s\" positioniert unterhalb von \"%(other)s\"" #: treebeard/admin.py:112 #, python-format msgid "Moved node \"%(node)s\" as sibling of \"%(other)s\"" msgstr "Element \"%(node)s\" positioniert gleichauf mit \"%(other)s\"" #: treebeard/al_tree.py:373 treebeard/mp_tree.py:474 treebeard/ns_tree.py:363 msgid "Can't move node to a descendant." msgstr "Kann Element nicht in ein eigenes Unter-Element verschieben" #: treebeard/forms.py:46 msgid "Child of" msgstr "als Unterkategorie von" #: treebeard/forms.py:47 msgid "Sibling of" msgstr "auf gleicher Ebene wie" #: treebeard/forms.py:51 msgid "First child of" msgstr "Als erste Unterkategorie von" #: treebeard/forms.py:52 msgid "Before" msgstr "Vor" #: treebeard/forms.py:53 msgid "After" msgstr "Nach" #: treebeard/forms.py:56 msgid "Position" msgstr "Position" #: treebeard/forms.py:60 msgid "Relative to" msgstr "relativ zu" #: treebeard/forms.py:189 msgid "-- root --" msgstr "-- Basiskategorie --" #: treebeard/mp_tree.py:382 msgid "" "The new node is too deep in the tree, try increasing the path.max_length " "property and UPDATE your database" msgstr "" "Das neue Element ist zu tief positioniert. Versuche path.max_length zu " "erhöhen und aktualisiere die Datenbank" #: treebeard/mp_tree.py:1114 #, python-format msgid "Path Overflow from: '%s'" msgstr "Pfad Überlauf von: '%s'" #: treebeard/templatetags/admin_tree.py:249 msgid "Return to ordered tree" msgstr "Zurück zur geordneten Baumansicht" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/de/LC_MESSAGES/djangojs.mo0000644000175100001770000000106714556473264024056 0ustar00runnerdocker<\pq wz&AbortAs SiblingAs childProject-Id-Version: PACKAGE VERSION Report-Msgid-Bugs-To: POT-Creation-Date: 2018-06-15 20:56+0000 PO-Revision-Date: 2018-06-15 23:10+0100 Last-Translator: FULL NAME Language-Team: LANGUAGE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); X-Generator: Poedit 1.5.7 AbbruchAls Geschwister-ElementAls Kind-Element././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/de/LC_MESSAGES/djangojs.po0000644000175100001770000000163114556473264024056 0ustar00runnerdocker# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-06-15 20:56+0000\n" "PO-Revision-Date: 2018-06-15 23:10+0100\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 1.5.7\n" #: treebeard/static/treebeard/treebeard-admin.js:158 msgid "Abort" msgstr "Abbruch" #: treebeard/static/treebeard/treebeard-admin.js:180 msgid "As Sibling" msgstr "Als Geschwister-Element" #: treebeard/static/treebeard/treebeard-admin.js:198 msgid "As child" msgstr "Als Kind-Element" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8906186 django-treebeard-4.7.1/treebeard/locale/es/0000755000175100001770000000000014556473276020153 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9026186 django-treebeard-4.7.1/treebeard/locale/es/LC_MESSAGES/0000755000175100001770000000000014556473276021740 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/es/LC_MESSAGES/django.mo0000644000175100001770000000133414556473264023535 0ustar00runnerdocker d   &X1     -- root --AfterBeforeChild ofFirst child ofPositionRelative toSibling ofProject-Id-Version: Django-treebeard Report-Msgid-Bugs-To: POT-Creation-Date: 2011-07-18 17:36+0200 PO-Revision-Date: 2010-05-03 23:40-0500 Last-Translator: Gustavo Picon Language-Team: Spanish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); -- raíz --DespuésAntesHijo dePrimer hijo dePosiciónRelativo aHermano de././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/es/LC_MESSAGES/django.po0000644000175100001770000000277314556473264023550 0ustar00runnerdockermsgid "" msgstr "" "Project-Id-Version: Django-treebeard\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2011-07-18 17:36+0200\n" "PO-Revision-Date: 2010-05-03 23:40-0500\n" "Last-Translator: Gustavo Picon \n" "Language-Team: Spanish\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: admin.py:113 #, python-format msgid "Moved node \"%(node)s\" as child of \"%(other)s\"" msgstr "" #: admin.py:119 #, python-format msgid "Moved node \"%(node)s\" as sibling of \"%(other)s\"" msgstr "" #: admin.py:129 #, python-format msgid "Exception raised while moving node: %s" msgstr "" #: al_tree.py:319 mp_tree.py:641 ns_tree.py:308 msgid "Can't move node to a descendant." msgstr "" #: forms.py:17 msgid "Child of" msgstr "Hijo de" #: forms.py:18 msgid "Sibling of" msgstr "Hermano de" #: forms.py:22 msgid "First child of" msgstr "Primer hijo de" #: forms.py:23 msgid "Before" msgstr "Antes" #: forms.py:24 msgid "After" msgstr "Después" #: forms.py:27 msgid "Position" msgstr "Posición" #: forms.py:31 msgid "Relative to" msgstr "Relativo a" #: forms.py:81 msgid "-- root --" msgstr "-- raíz --" #: mp_tree.py:521 msgid "" "The new node is too deep in the tree, try increasing the path.max_length " "property and UPDATE your database" msgstr "" #: mp_tree.py:702 #, python-format msgid "Path Overflow from: '%s'" msgstr "" #: templatetags/admin_tree.py:148 msgid "Return to ordered tree" msgstr "" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/es/LC_MESSAGES/djangojs.mo0000644000175100001770000000056614556473264024100 0ustar00runnerdocker$,8<9Project-Id-Version: Django-treebeard Report-Msgid-Bugs-To: POT-Creation-Date: 2011-07-18 14:12+0200 PO-Revision-Date: 2011-07-18 14:12+0200 Last-Translator: Language-Team: Spanish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/es/LC_MESSAGES/djangojs.po0000644000175100001770000000111314556473264024070 0ustar00runnerdockermsgid "" msgstr "" "Project-Id-Version: Django-treebeard\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2011-07-18 14:12+0200\n" "PO-Revision-Date: 2011-07-18 14:12+0200\n" "Last-Translator: \n" "Language-Team: Spanish\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: static/treebeard/treebeard-admin.js:157 msgid "Abort" msgstr "" #: static/treebeard/treebeard-admin.js:172 msgid "As Sibling" msgstr "" #: static/treebeard/treebeard-admin.js:190 msgid "As child" msgstr "" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8906186 django-treebeard-4.7.1/treebeard/locale/fr/0000755000175100001770000000000014556473276020153 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9026186 django-treebeard-4.7.1/treebeard/locale/fr/LC_MESSAGES/0000755000175100001770000000000014556473276021740 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/fr/LC_MESSAGES/django.mo0000644000175100001770000000316614556473264023542 0ustar00runnerdockerx y &-/Oh q} jQ  \ip>v A@>T      -- root --AfterBeforeCan't move node to a descendant.Child ofException raised while moving node: %sFirst child ofMoved node "%(node)s" as child of "%(other)s"Moved node "%(node)s" as sibling of "%(other)s"Path Overflow from: '%s'PositionRelative toReturn to ordered treeSibling ofThe new node is too deep in the tree, try increasing the path.max_length property and UPDATE your databaseProject-Id-Version: PACKAGE VERSION Report-Msgid-Bugs-To: PO-Revision-Date: 2018-06-15 23:09+0100 Last-Translator: FULL NAME Language-Team: LANGUAGE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); X-Generator: Poedit 1.5.7 -- racine --AprèsAvantL'élément ne peux être déplacé vers un de ces décendant.Enfant deUne expetion est survenue pendant le placement de l'élément: %sPremier enfant deÉlément "%(node)s" déplacé en temps qu'enfant de "%(other)s"Élément "%(node)s" déplacé au même niveau que "%(other)s"Chemin trop long de: '%s'PositionRelative àRetour à l'arbre triéAu même niveau queL'élément est trop profond dans l'arbre, essayez d'augmenter la propriété path.max_length et de mêtre à jour vore base de donnée././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/fr/LC_MESSAGES/django.po0000644000175100001770000000454714556473264023551 0ustar00runnerdocker# treebeard translation in french. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the treebeard package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-06-15 20:56+0000\n" "PO-Revision-Date: 2018-06-15 23:09+0100\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 1.5.7\n" #: treebeard/admin.py:106 #, python-format msgid "Exception raised while moving node: %s" msgstr "Une expetion est survenue pendant le placement de l'élément: %s" #: treebeard/admin.py:110 #, python-format msgid "Moved node \"%(node)s\" as child of \"%(other)s\"" msgstr "Élément \"%(node)s\" déplacé en temps qu'enfant de \"%(other)s\"" #: treebeard/admin.py:112 #, python-format msgid "Moved node \"%(node)s\" as sibling of \"%(other)s\"" msgstr "Élément \"%(node)s\" déplacé au même niveau que \"%(other)s\"" #: treebeard/al_tree.py:373 treebeard/mp_tree.py:474 treebeard/ns_tree.py:363 msgid "Can't move node to a descendant." msgstr "L'élément ne peux être déplacé vers un de ces décendant." #: treebeard/forms.py:46 msgid "Child of" msgstr "Enfant de" #: treebeard/forms.py:47 msgid "Sibling of" msgstr "Au même niveau que" #: treebeard/forms.py:51 msgid "First child of" msgstr "Premier enfant de" #: treebeard/forms.py:52 msgid "Before" msgstr "Avant" #: treebeard/forms.py:53 msgid "After" msgstr "Après" #: treebeard/forms.py:56 msgid "Position" msgstr "Position" #: treebeard/forms.py:60 msgid "Relative to" msgstr "Relative à" #: treebeard/forms.py:189 msgid "-- root --" msgstr "-- racine --" #: treebeard/mp_tree.py:382 msgid "" "The new node is too deep in the tree, try increasing the path.max_length " "property and UPDATE your database" msgstr "" "L'élément est trop profond dans l'arbre, essayez d'augmenter la propriété path.max_length " "et de mêtre à jour vore base de donnée" #: treebeard/mp_tree.py:1114 #, python-format msgid "Path Overflow from: '%s'" msgstr "Chemin trop long de: '%s'" #: treebeard/templatetags/admin_tree.py:249 msgid "Return to ordered tree" msgstr "Retour à l'arbre trié" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/fr/LC_MESSAGES/djangojs.mo0000644000175100001770000000101314556473264024064 0ustar00runnerdocker<\pq wQ AbortAs SiblingAs childProject-Id-Version: PACKAGE VERSION Report-Msgid-Bugs-To: PO-Revision-Date: 2018-06-15 23:10+0100 Last-Translator: FULL NAME Language-Team: LANGUAGE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); X-Generator: Poedit 1.5.7 InterrompreAu même niveauEn tant qu'enfant././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/fr/LC_MESSAGES/djangojs.po0000644000175100001770000000162614556473264024101 0ustar00runnerdocker# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-06-15 20:56+0000\n" "PO-Revision-Date: 2018-06-15 23:10+0100\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 1.5.7\n" #: treebeard/static/treebeard/treebeard-admin.js:158 msgid "Abort" msgstr "Interrompre" #: treebeard/static/treebeard/treebeard-admin.js:180 msgid "As Sibling" msgstr "Au même niveau" #: treebeard/static/treebeard/treebeard-admin.js:198 msgid "As child" msgstr "En tant qu'enfant" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8906186 django-treebeard-4.7.1/treebeard/locale/hu/0000755000175100001770000000000014556473276020160 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9026186 django-treebeard-4.7.1/treebeard/locale/hu/LC_MESSAGES/0000755000175100001770000000000014556473276021745 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/hu/LC_MESSAGES/django.mo0000755000175100001770000000320714556473264023546 0ustar00runnerdockerx y &-/Oh q} jQ \u|=4D;`       -- root --AfterBeforeCan't move node to a descendant.Child ofException raised while moving node: %sFirst child ofMoved node "%(node)s" as child of "%(other)s"Moved node "%(node)s" as sibling of "%(other)s"Path Overflow from: '%s'PositionRelative toReturn to ordered treeSibling ofThe new node is too deep in the tree, try increasing the path.max_length property and UPDATE your databaseProject-Id-Version: PACKAGE VERSION Report-Msgid-Bugs-To: PO-Revision-Date: 2018-06-15 23:09+0100 Last-Translator: FULL NAME Language-Team: LANGUAGE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); X-Generator: Poedit 1.5.7 -- Gyökérkategória --UtánaElőtteNem lehet egy csomópontot egy leszármazottja alá mozgatni.Gyermeke ennekHiba lépett fel a csomópont mozgatása közben: %sElső gyermeke ennekA(z) "%(node)s" csomópont a(z) "%(other)s" csomópont alá került.A(z) "%(node)s" csomópont a(z) "%(other)s" testvére lett.Útvonal túlfolyás innen: '%s'PozícióRelatív ehhezVissza a rendezett fáhozTestvére ennekAz új csomópont túl mélyen van a fában, próbáljuk meg megnövelni a path.max_length értékét és frissítsük az adatbázist.././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/hu/LC_MESSAGES/django.po0000755000175100001770000000455514556473264023560 0ustar00runnerdocker# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-06-15 20:56+0000\n" "PO-Revision-Date: 2018-06-15 23:09+0100\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 1.5.7\n" #: treebeard/admin.py:106 #, python-format msgid "Exception raised while moving node: %s" msgstr "Hiba lépett fel a csomópont mozgatása közben: %s" #: treebeard/admin.py:110 #, python-format msgid "Moved node \"%(node)s\" as child of \"%(other)s\"" msgstr "A(z) \"%(node)s\" csomópont a(z) \"%(other)s\" csomópont alá került." #: treebeard/admin.py:112 #, python-format msgid "Moved node \"%(node)s\" as sibling of \"%(other)s\"" msgstr "A(z) \"%(node)s\" csomópont a(z) \"%(other)s\" testvére lett." #: treebeard/al_tree.py:373 treebeard/mp_tree.py:474 treebeard/ns_tree.py:363 msgid "Can't move node to a descendant." msgstr "Nem lehet egy csomópontot egy leszármazottja alá mozgatni." #: treebeard/forms.py:46 msgid "Child of" msgstr "Gyermeke ennek" #: treebeard/forms.py:47 msgid "Sibling of" msgstr "Testvére ennek" #: treebeard/forms.py:51 msgid "First child of" msgstr "Első gyermeke ennek" #: treebeard/forms.py:52 msgid "Before" msgstr "Előtte" #: treebeard/forms.py:53 msgid "After" msgstr "Utána" #: treebeard/forms.py:56 msgid "Position" msgstr "Pozíció" #: treebeard/forms.py:60 msgid "Relative to" msgstr "Relatív ehhez" #: treebeard/forms.py:189 msgid "-- root --" msgstr "-- Gyökérkategória --" #: treebeard/mp_tree.py:382 msgid "" "The new node is too deep in the tree, try increasing the path.max_length " "property and UPDATE your database" msgstr "" "Az új csomópont túl mélyen van a fában, próbáljuk meg megnövelni a " "path.max_length értékét és frissítsük az adatbázist." #: treebeard/mp_tree.py:1114 #, python-format msgid "Path Overflow from: '%s'" msgstr "Útvonal túlfolyás innen: '%s'" #: treebeard/templatetags/admin_tree.py:249 msgid "Return to ordered tree" msgstr "Vissza a rendezett fához" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/hu/LC_MESSAGES/djangojs.mo0000755000175100001770000000100314556473264024073 0ustar00runnerdocker<\pq wQ   AbortAs SiblingAs childProject-Id-Version: PACKAGE VERSION Report-Msgid-Bugs-To: PO-Revision-Date: 2018-06-15 23:10+0100 Last-Translator: FULL NAME Language-Team: LANGUAGE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); X-Generator: Poedit 1.5.7 MegszakítTestvérkéntGyermekként././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/hu/LC_MESSAGES/djangojs.po0000755000175100001770000000161614556473264024110 0ustar00runnerdocker# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-06-15 20:56+0000\n" "PO-Revision-Date: 2018-06-15 23:10+0100\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 1.5.7\n" #: treebeard/static/treebeard/treebeard-admin.js:158 msgid "Abort" msgstr "Megszakít" #: treebeard/static/treebeard/treebeard-admin.js:180 msgid "As Sibling" msgstr "Testvérként" #: treebeard/static/treebeard/treebeard-admin.js:198 msgid "As child" msgstr "Gyermekként" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8906186 django-treebeard-4.7.1/treebeard/locale/nl/0000755000175100001770000000000014556473276020155 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9026186 django-treebeard-4.7.1/treebeard/locale/nl/LC_MESSAGES/0000755000175100001770000000000014556473276021742 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/nl/LC_MESSAGES/django.mo0000644000175100001770000000275014556473264023542 0ustar00runnerdockerx y &-/Oh q} jR ]or,w ' *$3KSdyh     -- root --AfterBeforeCan't move node to a descendant.Child ofException raised while moving node: %sFirst child ofMoved node "%(node)s" as child of "%(other)s"Moved node "%(node)s" as sibling of "%(other)s"Path Overflow from: '%s'PositionRelative toReturn to ordered treeSibling ofThe new node is too deep in the tree, try increasing the path.max_length property and UPDATE your databaseProject-Id-Version: Django-treebeard Report-Msgid-Bugs-To: POT-Creation-Date: 2011-07-18 17:36+0200 PO-Revision-Date: 2011-07-18 14:11+0200 Last-Translator: Jaap Roes Language-Team: Dutch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); -- hoofdniveau --NaVoorKan node niet naar eigen subnode verplaatsenOnderdeelFatale fout tijdens het verplaatsen: %s1e onderdeel"%(node)s" is nu onderdeel van "%(other)s""%(node)s" staat nu voor "%(other)s"Path overflow van: '%s'PositieTen opzichte vanAls gesorteerde boomNaastDe nieuwe node bevindt zich te diep in de boom. Verhoog de path.max_lenght waarde en UPDATE de database.././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/nl/LC_MESSAGES/django.po0000644000175100001770000000346514556473264023551 0ustar00runnerdockermsgid "" msgstr "" "Project-Id-Version: Django-treebeard\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2011-07-18 17:36+0200\n" "PO-Revision-Date: 2011-07-18 14:11+0200\n" "Last-Translator: Jaap Roes \n" "Language-Team: Dutch\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: admin.py:113 #, python-format msgid "Moved node \"%(node)s\" as child of \"%(other)s\"" msgstr "\"%(node)s\" is nu onderdeel van \"%(other)s\"" #: admin.py:119 #, python-format msgid "Moved node \"%(node)s\" as sibling of \"%(other)s\"" msgstr "\"%(node)s\" staat nu voor \"%(other)s\"" #: admin.py:129 #, python-format msgid "Exception raised while moving node: %s" msgstr "Fatale fout tijdens het verplaatsen: %s" #: al_tree.py:319 mp_tree.py:641 ns_tree.py:308 msgid "Can't move node to a descendant." msgstr "Kan node niet naar eigen subnode verplaatsen" #: forms.py:17 msgid "Child of" msgstr "Onderdeel" #: forms.py:18 msgid "Sibling of" msgstr "Naast" #: forms.py:22 msgid "First child of" msgstr "1e onderdeel" #: forms.py:23 msgid "Before" msgstr "Voor" #: forms.py:24 msgid "After" msgstr "Na" #: forms.py:27 msgid "Position" msgstr "Positie" #: forms.py:31 msgid "Relative to" msgstr "Ten opzichte van" #: forms.py:81 msgid "-- root --" msgstr "-- hoofdniveau --" #: mp_tree.py:521 msgid "" "The new node is too deep in the tree, try increasing the path.max_length " "property and UPDATE your database" msgstr "" "De nieuwe node bevindt zich te diep in de boom. Verhoog de path.max_lenght " "waarde en UPDATE de database." #: mp_tree.py:702 #, python-format msgid "Path Overflow from: '%s'" msgstr "Path overflow van: '%s'" #: templatetags/admin_tree.py:148 msgid "Return to ordered tree" msgstr "Als gesorteerde boom" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/nl/LC_MESSAGES/djangojs.mo0000644000175100001770000000102414556473264024070 0ustar00runnerdocker<\pq wR AbortAs SiblingAs childProject-Id-Version: Django-treebeard Report-Msgid-Bugs-To: POT-Creation-Date: 2011-07-18 14:12+0200 PO-Revision-Date: 2011-07-18 14:12+0200 Last-Translator: Jaap Roes Language-Team: Dutch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); AnnulerenAls naastliggend onderdeelAls subonderdeel././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/nl/LC_MESSAGES/djangojs.po0000644000175100001770000000122414556473264024075 0ustar00runnerdockermsgid "" msgstr "" "Project-Id-Version: Django-treebeard\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2011-07-18 14:12+0200\n" "PO-Revision-Date: 2011-07-18 14:12+0200\n" "Last-Translator: Jaap Roes \n" "Language-Team: Dutch\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: static/treebeard/treebeard-admin.js:157 msgid "Abort" msgstr "Annuleren" #: static/treebeard/treebeard-admin.js:172 msgid "As Sibling" msgstr "Als naastliggend onderdeel" #: static/treebeard/treebeard-admin.js:190 msgid "As child" msgstr "Als subonderdeel" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8906186 django-treebeard-4.7.1/treebeard/locale/pl/0000755000175100001770000000000014556473276020157 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9026186 django-treebeard-4.7.1/treebeard/locale/pl/LC_MESSAGES/0000755000175100001770000000000014556473276021744 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/pl/LC_MESSAGES/django.mo0000644000175100001770000000141714556473264023543 0ustar00runnerdocker d   &j1  -- root --AfterBeforeChild ofFirst child ofPositionRelative toSibling ofProject-Id-Version: Django-treebeard Report-Msgid-Bugs-To: POT-Creation-Date: 2010-05-03 23:53-0500 PO-Revision-Date: 2010-05-03 23:40-0500 Last-Translator: Bartosz Turkot Language-Team: Polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); -- kategoria główna --ZaPrzedDziecko kategoriiPierwsze dziecko kategoriiPozycjaWzględemSąsiad kategorii././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/pl/LC_MESSAGES/django.po0000644000175100001770000000156014556473264023545 0ustar00runnerdockermsgid "" msgstr "" "Project-Id-Version: Django-treebeard\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2010-05-03 23:53-0500\n" "PO-Revision-Date: 2010-05-03 23:40-0500\n" "Last-Translator: Bartosz Turkot \n" "Language-Team: Polish\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: forms.py:16 msgid "Child of" msgstr "Dziecko kategorii" #: forms.py:17 msgid "Sibling of" msgstr "Sąsiad kategorii" #: forms.py:21 msgid "First child of" msgstr "Pierwsze dziecko kategorii" #: forms.py:22 msgid "Before" msgstr "Przed" #: forms.py:23 msgid "After" msgstr "Za" #: forms.py:26 msgid "Position" msgstr "Pozycja" #: forms.py:30 msgid "Relative to" msgstr "Względem" #: forms.py:80 msgid "-- root --" msgstr "-- kategoria główna --" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8906186 django-treebeard-4.7.1/treebeard/locale/ru/0000755000175100001770000000000014556473276020172 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9026186 django-treebeard-4.7.1/treebeard/locale/ru/LC_MESSAGES/0000755000175100001770000000000014556473276021757 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/ru/LC_MESSAGES/django.mo0000644000175100001770000000155214556473264023556 0ustar00runnerdocker d   &1 .=V -- root --AfterBeforeChild ofFirst child ofPositionRelative toSibling ofProject-Id-Version: Django-treebeard Report-Msgid-Bugs-To: POT-Creation-Date: 2011-07-18 17:36+0200 PO-Revision-Date: 2009-04-10 18:37+0400 Last-Translator: chembervint Language-Team: Russian MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2; -- корень --ПослеДоВложенныйПервый вложенныйПозицияОтносительноСоседний к././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/ru/LC_MESSAGES/django.po0000644000175100001770000000321414556473264023556 0ustar00runnerdockermsgid "" msgstr "" "Project-Id-Version: Django-treebeard\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2011-07-18 17:36+0200\n" "PO-Revision-Date: 2009-04-10 18:37+0400\n" "Last-Translator: chembervint \n" "Language-Team: Russian\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%" "10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" #: admin.py:113 #, python-format msgid "Moved node \"%(node)s\" as child of \"%(other)s\"" msgstr "" #: admin.py:119 #, python-format msgid "Moved node \"%(node)s\" as sibling of \"%(other)s\"" msgstr "" #: admin.py:129 #, python-format msgid "Exception raised while moving node: %s" msgstr "" #: al_tree.py:319 mp_tree.py:641 ns_tree.py:308 msgid "Can't move node to a descendant." msgstr "" #: forms.py:17 msgid "Child of" msgstr "Вложенный" #: forms.py:18 msgid "Sibling of" msgstr "Соседний к" #: forms.py:22 msgid "First child of" msgstr "Первый вложенный" #: forms.py:23 msgid "Before" msgstr "До" #: forms.py:24 msgid "After" msgstr "После" #: forms.py:27 msgid "Position" msgstr "Позиция" #: forms.py:31 msgid "Relative to" msgstr "Относительно" #: forms.py:81 msgid "-- root --" msgstr "-- корень --" #: mp_tree.py:521 msgid "" "The new node is too deep in the tree, try increasing the path.max_length " "property and UPDATE your database" msgstr "" #: mp_tree.py:702 #, python-format msgid "Path Overflow from: '%s'" msgstr "" #: templatetags/admin_tree.py:148 msgid "Return to ordered tree" msgstr "" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/ru/LC_MESSAGES/djangojs.mo0000644000175100001770000000067514556473264024120 0ustar00runnerdocker$,89Project-Id-Version: Django-treebeard Report-Msgid-Bugs-To: POT-Creation-Date: 2011-07-18 14:12+0200 PO-Revision-Date: 2011-07-18 14:12+0200 Last-Translator: Language-Team: Russian MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2; ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/locale/ru/LC_MESSAGES/djangojs.po0000644000175100001770000000122514556473264024113 0ustar00runnerdockermsgid "" msgstr "" "Project-Id-Version: Django-treebeard\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2011-07-18 14:12+0200\n" "PO-Revision-Date: 2011-07-18 14:12+0200\n" "Last-Translator: \n" "Language-Team: Russian\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%" "10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" #: static/treebeard/treebeard-admin.js:157 msgid "Abort" msgstr "" #: static/treebeard/treebeard-admin.js:172 msgid "As Sibling" msgstr "" #: static/treebeard/treebeard-admin.js:190 msgid "As child" msgstr "" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/models.py0000644000175100001770000005274714556473264020156 0ustar00runnerdocker"""Models and base API""" import operator from functools import reduce from django.db.models import Q from django.db import models, router, connections from treebeard.exceptions import InvalidPosition, MissingNodeOrderBy class Node(models.Model): """Node class""" _db_connection = None @classmethod def add_root(cls, **kwargs): # pragma: no cover """ Adds a root node to the tree. The new root node will be the new rightmost root node. If you want to insert a root node at a specific position, use :meth:`add_sibling` in an already existing root node instead. :param `**kwargs`: object creation data that will be passed to the inherited Node model :param instance: Instead of passing object creation data, you can pass an already-constructed (but not yet saved) model instance to be inserted into the tree. :returns: the created node object. It will be save()d by this method. :raise NodeAlreadySaved: when the passed ``instance`` already exists in the database """ raise NotImplementedError @classmethod def get_foreign_keys(cls): """Get foreign keys and models they refer to, so we can pre-process the data for load_bulk """ foreign_keys = {} for field in cls._meta.fields: if ( field.get_internal_type() == 'ForeignKey' and field.name != 'parent' ): foreign_keys[field.name] = field.remote_field.model return foreign_keys @classmethod def _process_foreign_keys(cls, foreign_keys, node_data): """For each foreign key try to load the actual object so load_bulk doesn't fail trying to load an int where django expects a model instance """ for key in foreign_keys.keys(): if key in node_data: node_data[key] = foreign_keys[key].objects.get( pk=node_data[key]) @classmethod def load_bulk(cls, bulk_data, parent=None, keep_ids=False): """ Loads a list/dictionary structure to the tree. :param bulk_data: The data that will be loaded, the structure is a list of dictionaries with 2 keys: - ``data``: will store arguments that will be passed for object creation, and - ``children``: a list of dictionaries, each one has it's own ``data`` and ``children`` keys (a recursive structure) :param parent: The node that will receive the structure as children, if not specified the first level of the structure will be loaded as root nodes :param keep_ids: If enabled, loads the nodes with the same primary keys that are given in the structure. Will error if there are nodes without primary key info or if the primary keys are already used. :returns: A list of the added node ids. """ # tree, iterative preorder added = [] # stack of nodes to analyze stack = [(parent, node) for node in bulk_data[::-1]] foreign_keys = cls.get_foreign_keys() pk_field = cls._meta.pk.attname while stack: parent, node_struct = stack.pop() # shallow copy of the data structure so it doesn't persist... node_data = node_struct['data'].copy() cls._process_foreign_keys(foreign_keys, node_data) if keep_ids: node_data[pk_field] = node_struct[pk_field] if parent: node_obj = parent.add_child(**node_data) else: node_obj = cls.add_root(**node_data) added.append(node_obj.pk) if 'children' in node_struct: # extending the stack with the current node as the parent of # the new nodes stack.extend([ (node_obj, node) for node in node_struct['children'][::-1] ]) return added @classmethod def dump_bulk(cls, parent=None, keep_ids=True): # pragma: no cover """ Dumps a tree branch to a python data structure. :param parent: The node whose descendants will be dumped. The node itself will be included in the dump. If not given, the entire tree will be dumped. :param keep_ids: Stores the pk value (primary key) of every node. Enabled by default. :returns: A python data structure, described with detail in :meth:`load_bulk` """ raise NotImplementedError @classmethod def get_root_nodes(cls): # pragma: no cover """:returns: A queryset containing the root nodes in the tree.""" raise NotImplementedError @classmethod def get_first_root_node(cls): """ :returns: The first root node in the tree or ``None`` if it is empty. """ try: return cls.get_root_nodes()[0] except IndexError: return None @classmethod def get_last_root_node(cls): """ :returns: The last root node in the tree or ``None`` if it is empty. """ try: return cls.get_root_nodes().reverse()[0] except IndexError: return None @classmethod def find_problems(cls): # pragma: no cover """Checks for problems in the tree structure.""" raise NotImplementedError @classmethod def fix_tree(cls): # pragma: no cover """ Solves problems that can appear when transactions are not used and a piece of code breaks, leaving the tree in an inconsistent state. """ raise NotImplementedError @classmethod def get_tree(cls, parent=None): """ :returns: A list of nodes ordered as DFS, including the parent. If no parent is given, the entire tree is returned. """ raise NotImplementedError @classmethod def get_descendants_group_count(cls, parent=None): """ Helper for a very common case: get a group of siblings and the number of *descendants* (not only children) in every sibling. :param parent: The parent of the siblings to return. If no parent is given, the root nodes will be returned. :returns: A `list` (**NOT** a Queryset) of node objects with an extra attribute: `descendants_count`. """ if parent is None: qset = cls.get_root_nodes() else: qset = parent.get_children() nodes = list(qset) for node in nodes: node.descendants_count = node.get_descendant_count() return nodes def get_depth(self): # pragma: no cover """:returns: the depth (level) of the node""" raise NotImplementedError def get_siblings(self): # pragma: no cover """ :returns: A queryset of all the node's siblings, including the node itself. """ raise NotImplementedError def get_children(self): # pragma: no cover """:returns: A queryset of all the node's children""" raise NotImplementedError def get_children_count(self): """:returns: The number of the node's children""" return self.get_children().count() def get_descendants(self): """ :returns: A queryset of all the node's descendants, doesn't include the node itself (some subclasses may return a list). """ raise NotImplementedError def get_descendant_count(self): """:returns: the number of descendants of a node.""" return self.get_descendants().count() def get_first_child(self): """ :returns: The leftmost node's child, or None if it has no children. """ try: return self.get_children()[0] except IndexError: return None def get_last_child(self): """ :returns: The rightmost node's child, or None if it has no children. """ try: return self.get_children().reverse()[0] except IndexError: return None def get_first_sibling(self): """ :returns: The leftmost node's sibling, can return the node itself if it was the leftmost sibling. """ return self.get_siblings()[0] def get_last_sibling(self): """ :returns: The rightmost node's sibling, can return the node itself if it was the rightmost sibling. """ return self.get_siblings().reverse()[0] def get_prev_sibling(self): """ :returns: The previous node's sibling, or None if it was the leftmost sibling. """ siblings = self.get_siblings() ids = [obj.pk for obj in siblings] if self.pk in ids: idx = ids.index(self.pk) if idx > 0: return siblings[idx - 1] def get_next_sibling(self): """ :returns: The next node's sibling, or None if it was the rightmost sibling. """ siblings = self.get_siblings() ids = [obj.pk for obj in siblings] if self.pk in ids: idx = ids.index(self.pk) if idx < len(siblings) - 1: return siblings[idx + 1] def is_sibling_of(self, node): """ :returns: ``True`` if the node is a sibling of another node given as an argument, else, returns ``False`` :param node: The node that will be checked as a sibling """ return self.get_siblings().filter(pk=node.pk).exists() def is_child_of(self, node): """ :returns: ``True`` if the node is a child of another node given as an argument, else, returns ``False`` :param node: The node that will be checked as a parent """ return node.get_children().filter(pk=self.pk).exists() def is_descendant_of(self, node): # pragma: no cover """ :returns: ``True`` if the node is a descendant of another node given as an argument, else, returns ``False`` :param node: The node that will be checked as an ancestor """ raise NotImplementedError def add_child(self, **kwargs): # pragma: no cover """ Adds a child to the node. The new node will be the new rightmost child. If you want to insert a node at a specific position, use the :meth:`add_sibling` method of an already existing child node instead. :param `**kwargs`: Object creation data that will be passed to the inherited Node model :param instance: Instead of passing object creation data, you can pass an already-constructed (but not yet saved) model instance to be inserted into the tree. :returns: The created node object. It will be save()d by this method. :raise NodeAlreadySaved: when the passed ``instance`` already exists in the database """ raise NotImplementedError def add_sibling(self, pos=None, **kwargs): # pragma: no cover """ Adds a new node as a sibling to the current node object. :param pos: The position, relative to the current node object, where the new node will be inserted, can be one of: - ``first-sibling``: the new node will be the new leftmost sibling - ``left``: the new node will take the node's place, which will be moved to the right 1 position - ``right``: the new node will be inserted at the right of the node - ``last-sibling``: the new node will be the new rightmost sibling - ``sorted-sibling``: the new node will be at the right position according to the value of node_order_by :param `**kwargs`: Object creation data that will be passed to the inherited Node model :param instance: Instead of passing object creation data, you can pass an already-constructed (but not yet saved) model instance to be inserted into the tree. :returns: The created node object. It will be saved by this method. :raise InvalidPosition: when passing an invalid ``pos`` parm :raise InvalidPosition: when :attr:`node_order_by` is enabled and the ``pos`` parm wasn't ``sorted-sibling`` :raise MissingNodeOrderBy: when passing ``sorted-sibling`` as ``pos`` and the :attr:`node_order_by` attribute is missing :raise NodeAlreadySaved: when the passed ``instance`` already exists in the database """ raise NotImplementedError def get_root(self): # pragma: no cover """:returns: the root node for the current node object.""" raise NotImplementedError def is_root(self): """:returns: True if the node is a root node (else, returns False)""" return self.get_root().pk == self.pk def is_leaf(self): """:returns: True if the node is a leaf node (else, returns False)""" return not self.get_children().exists() def get_ancestors(self): # pragma: no cover """ :returns: A queryset containing the current node object's ancestors, starting by the root node and descending to the parent. (some subclasses may return a list) """ raise NotImplementedError def get_parent(self, update=False): # pragma: no cover """ :returns: the parent node of the current node object. Caches the result in the object itself to help in loops. :param update: Updates the cached value. """ raise NotImplementedError def move(self, target, pos=None): # pragma: no cover """ Moves the current node and all it's descendants to a new position relative to another node. :param target: The node that will be used as a relative child/sibling when moving :param pos: The position, relative to the target node, where the current node object will be moved to, can be one of: - ``first-child``: the node will be the new leftmost child of the ``target`` node - ``last-child``: the node will be the new rightmost child of the ``target`` node - ``sorted-child``: the new node will be moved as a child of the ``target`` node according to the value of :attr:`node_order_by` - ``first-sibling``: the node will be the new leftmost sibling of the ``target`` node - ``left``: the node will take the ``target`` node's place, which will be moved to the right 1 position - ``right``: the node will be moved to the right of the ``target`` node - ``last-sibling``: the node will be the new rightmost sibling of the ``target`` node - ``sorted-sibling``: the new node will be moved as a sibling of the ``target`` node according to the value of :attr:`node_order_by` .. note:: If no ``pos`` is given the library will use ``last-sibling``, or ``sorted-sibling`` if :attr:`node_order_by` is enabled. :returns: None :raise InvalidPosition: when passing an invalid ``pos`` parm :raise InvalidPosition: when :attr:`node_order_by` is enabled and the ``pos`` parm wasn't ``sorted-sibling`` or ``sorted-child`` :raise InvalidMoveToDescendant: when trying to move a node to one of it's own descendants :raise PathOverflow: when the library can't make room for the node's new position :raise MissingNodeOrderBy: when passing ``sorted-sibling`` or ``sorted-child`` as ``pos`` and the :attr:`node_order_by` attribute is missing """ raise NotImplementedError def delete(self, *args, **kwargs): """Removes a node and all it's descendants.""" return self.__class__.objects.filter(pk=self.pk).delete(*args, **kwargs) delete.alters_data = True delete.queryset_only = True def _prepare_pos_var(self, pos, method_name, valid_pos, valid_sorted_pos): if pos is None: if self.node_order_by: pos = 'sorted-sibling' else: pos = 'last-sibling' if pos not in valid_pos: raise InvalidPosition('Invalid relative position: %s' % (pos, )) if self.node_order_by and pos not in valid_sorted_pos: raise InvalidPosition( 'Must use %s in %s when node_order_by is enabled' % ( ' or '.join(valid_sorted_pos), method_name)) if pos in valid_sorted_pos and not self.node_order_by: raise MissingNodeOrderBy('Missing node_order_by attribute.') return pos _valid_pos_for_add_sibling = ('first-sibling', 'left', 'right', 'last-sibling', 'sorted-sibling') _valid_pos_for_sorted_add_sibling = ('sorted-sibling',) def _prepare_pos_var_for_add_sibling(self, pos): return self._prepare_pos_var( pos, 'add_sibling', self._valid_pos_for_add_sibling, self._valid_pos_for_sorted_add_sibling) _valid_pos_for_move = _valid_pos_for_add_sibling + ( 'first-child', 'last-child', 'sorted-child') _valid_pos_for_sorted_move = _valid_pos_for_sorted_add_sibling + ( 'sorted-child',) def _prepare_pos_var_for_move(self, pos): return self._prepare_pos_var( pos, 'move', self._valid_pos_for_move, self._valid_pos_for_sorted_move) def get_sorted_pos_queryset(self, siblings, newobj): """ :returns: A queryset of the nodes that must be moved to the right. Called only for Node models with :attr:`node_order_by` This function is based on _insertion_target_filters from django-mptt (BSD licensed) by Jonathan Buchanan: https://github.com/django-mptt/django-mptt/blob/0.3.0/mptt/signals.py """ fields, filters = [], [] for field in self.node_order_by: value = getattr(newobj, field) filters.append( Q( *[Q(**{f: v}) for f, v in fields] + [Q(**{'%s__gt' % field: value})] ) ) fields.append((field, value)) return siblings.filter(reduce(operator.or_, filters)) @classmethod def get_annotated_list_qs(cls, qs): """ Gets an annotated list from a queryset. """ result, info = [], {} start_depth, prev_depth = (None, None) for node in qs: depth = node.get_depth() if start_depth is None: start_depth = depth open = (depth and (prev_depth is None or depth > prev_depth)) if prev_depth is not None and depth < prev_depth: info['close'] = list(range(0, prev_depth - depth)) info = {'open': open, 'close': [], 'level': depth - start_depth} result.append((node, info,)) prev_depth = depth if start_depth and start_depth > 0: info['close'] = list(range(0, prev_depth - start_depth + 1)) return result @classmethod def get_annotated_list(cls, parent=None, max_depth=None): """ Gets an annotated list from a tree branch. :param parent: The node whose descendants will be annotated. The node itself will be included in the list. If not given, the entire tree will be annotated. :param max_depth: Optionally limit to specified depth """ result, info = [], {} start_depth, prev_depth = (None, None) qs = cls.get_tree(parent) if max_depth: qs = qs.filter(depth__lte=max_depth) return cls.get_annotated_list_qs(qs) @classmethod def _get_serializable_model(cls): """ Returns a model with a valid _meta.local_fields (serializable). Basically, this means the original model, not a proxied model. (this is a workaround for a bug in django) """ current_class = cls while current_class._meta.proxy: current_class = current_class._meta.proxy_for_model return current_class @classmethod def _get_database_connection(cls, action): return { 'read': connections[router.db_for_read(cls)], 'write': connections[router.db_for_write(cls)] }[action] @classmethod def get_database_vendor(cls, action): """ returns the supported database vendor used by a treebeard model when performing read (select) or write (update, insert, delete) operations. :param action: `read` or `write` :returns: postgresql, mysql or sqlite """ return cls._get_database_connection(action).vendor @classmethod def _get_database_cursor(cls, action): return cls._get_database_connection(action).cursor() class Meta: """Abstract model.""" abstract = True ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/mp_tree.py0000644000175100001770000013326314556473264020317 0ustar00runnerdocker"""Materialized Path Trees""" import operator from functools import reduce from django.core import serializers from django.db import models, transaction, connection from django.db.models import F, Q, Value from django.db.models.functions import Concat, Substr from django.utils.translation import gettext_noop as _ from treebeard.numconv import NumConv from treebeard.models import Node from treebeard.exceptions import InvalidMoveToDescendant, PathOverflow,\ NodeAlreadySaved # The following functions generate vendor-specific SQL functions def sql_concat(*args, **kwargs): vendor = kwargs.pop('vendor', None) if vendor == 'mysql': return 'CONCAT({})'.format(', '.join(args)) if vendor == 'microsoft': return ' + '.join(args) return '||'.join(args) def sql_length(field, vendor=None): if vendor == 'microsoft': return 'LEN({})'.format(field) return 'LENGTH({})'.format(field) def sql_substr(field, pos, length=None, **kwargs): vendor = kwargs.pop('vendor', None) function = 'SUBSTR({field}, {pos})' if length: function = 'SUBSTR({field}, {pos}, {length})' if vendor == 'microsoft': if not length: length = 'LEN({})'.format(field) function = 'SUBSTRING({field}, {pos}, {length})' return function.format(field=field, pos=pos, length=length) def get_result_class(cls): """ For the given model class, determine what class we should use for the nodes returned by its tree methods (such as get_children). Usually this will be trivially the same as the initial model class, but there are special cases when model inheritance is in use: * If the model extends another via multi-table inheritance, we need to use whichever ancestor originally implemented the tree behaviour (i.e. the one which defines the 'path' field). We can't use the subclass, because it's not guaranteed that the other nodes reachable from the current one will be instances of the same subclass. * If the model is a proxy model, the returned nodes should also use the proxy class. """ base_class = cls._meta.get_field('path').model if cls._meta.proxy_for_model == base_class: return cls else: return base_class class MP_NodeQuerySet(models.query.QuerySet): """ Custom queryset for the tree node manager. Needed only for the custom delete method. """ def delete(self, *args, **kwargs): """ Custom delete method, will remove all descendant nodes to ensure a consistent tree (no orphans) :returns: tuple of the number of objects deleted and a dictionary with the number of deletions per object type """ # we'll have to manually run through all the nodes that are going # to be deleted and remove nodes from the list if an ancestor is # already getting removed, since that would be redundant removed = {} for node in self.order_by('depth', 'path'): found = False for depth in range(1, int(len(node.path) / node.steplen)): path = node._get_basepath(node.path, depth) if path in removed: # we are already removing a parent of this node # skip found = True break if not found: removed[node.path] = node # ok, got the minimal list of nodes to remove... # we must also remove their children # and update every parent node's numchild attribute # LOTS OF FUN HERE! parents = {} toremove = [] for path, node in removed.items(): parentpath = node._get_basepath(node.path, node.depth - 1) if parentpath: if parentpath not in parents: parents[parentpath] = node.get_parent(True) parent = parents[parentpath] if parent and parent.numchild > 0: parent.numchild -= 1 parent.save() if node.is_leaf(): toremove.append(Q(path=node.path)) else: toremove.append(Q(path__startswith=node.path)) # Django will handle this as a SELECT and then a DELETE of # ids, and will deal with removing related objects model = get_result_class(self.model) if toremove: qset = model.objects.filter(reduce(operator.or_, toremove)) else: qset = model.objects.none() return super(MP_NodeQuerySet, qset).delete(*args, **kwargs) delete.alters_data = True delete.queryset_only = True class MP_NodeManager(models.Manager): """Custom manager for nodes in a Materialized Path tree.""" def get_queryset(self): """Sets the custom queryset as the default.""" return MP_NodeQuerySet(self.model).order_by('path') class MP_AddHandler(object): def __init__(self): self.stmts = [] class MP_ComplexAddMoveHandler(MP_AddHandler): def run_sql_stmts(self): cursor = self.node_cls._get_database_cursor('write') for sql, vals in self.stmts: cursor.execute(sql, vals) def get_sql_update_numchild(self, path, incdec='inc'): """:returns: The sql needed the numchild value of a node""" sql = "UPDATE %s SET numchild=numchild%s1"\ " WHERE path=%%s" % ( connection.ops.quote_name( get_result_class(self.node_cls)._meta.db_table), {'inc': '+', 'dec': '-'}[incdec]) vals = [path] return sql, vals def reorder_nodes_before_add_or_move(self, pos, newpos, newdepth, target, siblings, oldpath=None, movebranch=False): """ Handles the reordering of nodes and branches when adding/moving nodes. :returns: A tuple containing the old path and the new path. """ if ( (pos == 'last-sibling') or (pos == 'right' and target == target.get_last_sibling()) ): # easy, the last node last = target.get_last_sibling() newpath = last._inc_path() if movebranch: self.stmts.append( self.get_sql_newpath_in_branches(oldpath, newpath)) else: # do the UPDATE dance if newpos is None: siblings = target.get_siblings() siblings = {'left': siblings.filter(path__gte=target.path), 'right': siblings.filter(path__gt=target.path), 'first-sibling': siblings}[pos] basenum = target._get_lastpos_in_path() newpos = {'first-sibling': 1, 'left': basenum, 'right': basenum + 1}[pos] newpath = self.node_cls._get_path(target.path, newdepth, newpos) # If the move is amongst siblings and is to the left and there # are siblings to the right of its new position then to be on # the safe side we temporarily dump it on the end of the list tempnewpath = None if movebranch and len(oldpath) == len(newpath): parentoldpath = self.node_cls._get_basepath( oldpath, int(len(oldpath) / self.node_cls.steplen) - 1 ) parentnewpath = self.node_cls._get_basepath( newpath, newdepth - 1) if ( parentoldpath == parentnewpath and siblings and newpath < oldpath ): last = target.get_last_sibling() basenum = last._get_lastpos_in_path() tempnewpath = self.node_cls._get_path( newpath, newdepth, basenum + 2) self.stmts.append( self.get_sql_newpath_in_branches( oldpath, tempnewpath)) # Optimisation to only move siblings which need moving # (i.e. if we've got holes, allow them to compress) movesiblings = [] priorpath = newpath for node in siblings: # If the path of the node is already greater than the path # of the previous node it doesn't need shifting if node.path > priorpath: break # It does need shifting, so add to the list movesiblings.append(node) # Calculate the path that it would be moved to, as that's # the next "priorpath" priorpath = node._inc_path() movesiblings.reverse() for node in movesiblings: # moving the siblings (and their branches) at the right of the # related position one step to the right sql, vals = self.get_sql_newpath_in_branches( node.path, node._inc_path()) self.stmts.append((sql, vals)) if movebranch: if oldpath.startswith(node.path): # if moving to a parent, update oldpath since we just # increased the path of the entire branch oldpath = vals[0] + oldpath[len(vals[0]):] if target.path.startswith(node.path): # and if we moved the target, update the object # django made for us, since the update won't do it # maybe useful in loops target.path = vals[0] + target.path[len(vals[0]):] if movebranch: # node to move if tempnewpath: self.stmts.append( self.get_sql_newpath_in_branches( tempnewpath, newpath)) else: self.stmts.append( self.get_sql_newpath_in_branches( oldpath, newpath)) return oldpath, newpath def get_sql_newpath_in_branches(self, oldpath, newpath): """ :returns: The sql needed to move a branch to another position. .. note:: The generated sql will only update the depth values if needed. """ vendor = self.node_cls.get_database_vendor('write') sql1 = "UPDATE %s SET" % ( connection.ops.quote_name( get_result_class(self.node_cls)._meta.db_table), ) if vendor == 'mysql': # hooray for mysql ignoring standards in their default # configuration! # to make || work as it should, enable ansi mode # http://dev.mysql.com/doc/refman/5.0/en/ansi-mode.html sqlpath = "CONCAT(%s, SUBSTR(path, %s))" else: sqlpath = sql_concat("%s", sql_substr("path", "%s", vendor=vendor), vendor=vendor) sql2 = ["path=%s" % (sqlpath, )] vals = [newpath, len(oldpath) + 1] if len(oldpath) != len(newpath) and vendor != 'mysql': # when using mysql, this won't update the depth and it has to be # done in another query # doesn't even work with sql_mode='ANSI,TRADITIONAL' # TODO: FIND OUT WHY?!?? right now I'm just blaming mysql sql2.append(("depth=" + sql_length("%s", vendor=vendor) + "/%%s") % (sqlpath, )) vals.extend([newpath, len(oldpath) + 1, self.node_cls.steplen]) sql3 = "WHERE path LIKE %s" vals.extend([oldpath + '%']) sql = '%s %s %s' % (sql1, ', '.join(sql2), sql3) return sql, vals class MP_AddRootHandler(MP_AddHandler): def __init__(self, cls, **kwargs): super().__init__() self.cls = cls self.kwargs = kwargs def process(self): # do we have a root node already? last_root = self.cls.get_last_root_node() if last_root and last_root.node_order_by: # there are root nodes and node_order_by has been set # delegate sorted insertion to add_sibling return last_root.add_sibling('sorted-sibling', **self.kwargs) if last_root: # adding the new root node as the last one newpath = last_root._inc_path() else: # adding the first root node newpath = self.cls._get_path(None, 1, 1) if len(self.kwargs) == 1 and 'instance' in self.kwargs: # adding the passed (unsaved) instance to the tree newobj = self.kwargs['instance'] if not newobj._state.adding: raise NodeAlreadySaved("Attempted to add a tree node that is "\ "already in the database") else: # creating the new object newobj = self.cls(**self.kwargs) newobj.depth = 1 newobj.path = newpath # saving the instance before returning it newobj.save() return newobj class MP_AddChildHandler(MP_AddHandler): def __init__(self, node, **kwargs): super().__init__() self.node = node self.node_cls = node.__class__ self.kwargs = kwargs def process(self): if self.node_cls.node_order_by and not self.node.is_leaf(): # there are child nodes and node_order_by has been set # delegate sorted insertion to add_sibling self.node.numchild += 1 return self.node.get_last_child().add_sibling( 'sorted-sibling', **self.kwargs) if len(self.kwargs) == 1 and 'instance' in self.kwargs: # adding the passed (unsaved) instance to the tree newobj = self.kwargs['instance'] if not newobj._state.adding: raise NodeAlreadySaved("Attempted to add a tree node that is "\ "already in the database") else: # creating a new object newobj = self.node_cls(**self.kwargs) newobj.depth = self.node.depth + 1 if self.node.is_leaf(): # the node had no children, adding the first child newobj.path = self.node_cls._get_path( self.node.path, newobj.depth, 1) max_length = self.node_cls._meta.get_field('path').max_length if len(newobj.path) > max_length: raise PathOverflow( _('The new node is too deep in the tree, try' ' increasing the path.max_length property' ' and UPDATE your database')) else: # adding the new child as the last one newobj.path = self.node.get_last_child()._inc_path() get_result_class(self.node_cls).objects.filter( path=self.node.path).update(numchild=F('numchild')+1) # we increase the numchild value of the object in memory self.node.numchild += 1 # saving the instance before returning it newobj._cached_parent_obj = self.node newobj.save() return newobj class MP_AddSiblingHandler(MP_ComplexAddMoveHandler): def __init__(self, node, pos, **kwargs): super().__init__() self.node = node self.node_cls = node.__class__ self.pos = pos self.kwargs = kwargs def process(self): self.pos = self.node._prepare_pos_var_for_add_sibling(self.pos) if len(self.kwargs) == 1 and 'instance' in self.kwargs: # adding the passed (unsaved) instance to the tree newobj = self.kwargs['instance'] if not newobj._state.adding: raise NodeAlreadySaved("Attempted to add a tree node that is "\ "already in the database") else: # creating a new object newobj = self.node_cls(**self.kwargs) newobj.depth = self.node.depth if self.pos == 'sorted-sibling': siblings = self.node.get_sorted_pos_queryset( self.node.get_siblings(), newobj) try: newpos = siblings.all()[0]._get_lastpos_in_path() except IndexError: newpos = None if newpos is None: self.pos = 'last-sibling' else: newpos, siblings = None, [] _, newpath = self.reorder_nodes_before_add_or_move( self.pos, newpos, self.node.depth, self.node, siblings, None, False) parentpath = self.node._get_basepath(newpath, self.node.depth - 1) if parentpath: self.stmts.append( self.get_sql_update_numchild(parentpath, 'inc')) self.run_sql_stmts() # saving the instance before returning it newobj.path = newpath newobj.save() return newobj class MP_MoveHandler(MP_ComplexAddMoveHandler): def __init__(self, node, target, pos=None): super().__init__() self.node = node self.node_cls = node.__class__ self.target = target self.pos = pos def process(self): self.pos = self.node._prepare_pos_var_for_move(self.pos) oldpath = self.node.path # initialize variables and if moving to a child, updates "move to # child" to become a "move to sibling" if possible (if it can't # be done, it means that we are adding the first child) newdepth, siblings, newpos = self.update_move_to_child_vars() if self.target.is_descendant_of(self.node): raise InvalidMoveToDescendant( _("Can't move node to a descendant.")) if ( oldpath == self.target.path and ( (self.pos == 'left') or ( self.pos in ('right', 'last-sibling') and self.target.path == self.target.get_last_sibling().path ) or ( self.pos == 'first-sibling' and self.target.path == self.target.get_first_sibling().path ) ) ): # special cases, not actually moving the node so no need to UPDATE return if self.pos == 'sorted-sibling': siblings = self.node.get_sorted_pos_queryset( self.target.get_siblings(), self.node) try: newpos = siblings.all()[0]._get_lastpos_in_path() except IndexError: newpos = None if newpos is None: self.pos = 'last-sibling' # generate the sql that will do the actual moving of nodes oldpath, newpath = self.reorder_nodes_before_add_or_move( self.pos, newpos, newdepth, self.target, siblings, oldpath, True) # updates needed for mysql and children count in parents self.sanity_updates_after_move(oldpath, newpath) self.run_sql_stmts() def sanity_updates_after_move(self, oldpath, newpath): """ Updates the list of sql statements needed after moving nodes. 1. :attr:`depth` updates *ONLY* needed by mysql databases (*sigh*) 2. update the number of children of parent nodes """ if ( self.node_cls.get_database_vendor('write') == 'mysql' and len(oldpath) != len(newpath) ): # no words can describe how dumb mysql is # we must update the depth of the branch in a different query self.stmts.append( self.get_mysql_update_depth_in_branch(newpath)) oldparentpath = self.node_cls._get_parent_path_from_path(oldpath) newparentpath = self.node_cls._get_parent_path_from_path(newpath) if ( (not oldparentpath and newparentpath) or (oldparentpath and not newparentpath) or (oldparentpath != newparentpath) ): # node changed parent, updating count if oldparentpath: self.stmts.append( self.get_sql_update_numchild(oldparentpath, 'dec')) if newparentpath: self.stmts.append( self.get_sql_update_numchild(newparentpath, 'inc')) def update_move_to_child_vars(self): """Update preliminar vars in :meth:`move` when moving to a child""" newdepth = self.target.depth newpos = None siblings = [] if self.pos in ('first-child', 'last-child', 'sorted-child'): # moving to a child parent = self.target newdepth += 1 if self.target.is_leaf(): # moving as a target's first child newpos = 1 self.pos = 'first-sibling' siblings = get_result_class(self.node_cls).objects.none() else: self.target = self.target.get_last_child() self.pos = { 'first-child': 'first-sibling', 'last-child': 'last-sibling', 'sorted-child': 'sorted-sibling'}[self.pos] # this is not for save(), since if needed, will be handled with a # custom UPDATE, this is only here to update django's object, # should be useful in loops parent.numchild += 1 return newdepth, siblings, newpos def get_mysql_update_depth_in_branch(self, path): """ :returns: The sql needed to update the depth of all the nodes in a branch. """ vendor = self.node_cls.get_database_vendor('write') sql = ("UPDATE %s SET depth=" + sql_length("path", vendor=vendor) + "/%%s WHERE path LIKE %%s") % ( connection.ops.quote_name( get_result_class(self.node_cls)._meta.db_table), ) vals = [self.node_cls.steplen, path + '%'] return sql, vals class MP_Node(Node): """Abstract model to create your own Materialized Path Trees.""" steplen = 4 alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' node_order_by = [] path = models.CharField(max_length=255, unique=True) depth = models.PositiveIntegerField() numchild = models.PositiveIntegerField(default=0) gap = 1 objects = MP_NodeManager() numconv_obj_ = None @classmethod def _int2str(cls, num): return cls.numconv_obj().int2str(num) @classmethod def _str2int(cls, num): return cls.numconv_obj().str2int(num) @classmethod def numconv_obj(cls): if cls.numconv_obj_ is None: cls.numconv_obj_ = NumConv(len(cls.alphabet), cls.alphabet) return cls.numconv_obj_ @classmethod def add_root(cls, **kwargs): """ Adds a root node to the tree. This method saves the node in database. The object is populated as if via: ``` obj = cls(**kwargs) ``` :raise PathOverflow: when no more root objects can be added """ return MP_AddRootHandler(cls, **kwargs).process() @classmethod def dump_bulk(cls, parent=None, keep_ids=True): """Dumps a tree branch to a python data structure.""" cls = get_result_class(cls) # Because of fix_tree, this method assumes that the depth # and numchild properties in the nodes can be incorrect, # so no helper methods are used qset = cls._get_serializable_model().objects.all() if parent: qset = qset.filter(path__startswith=parent.path) ret, lnk = [], {} pk_field = cls._meta.pk.attname for pyobj in serializers.serialize('python', qset): # django's serializer stores the attributes in 'fields' fields = pyobj['fields'] path = fields['path'] depth = int(len(path) / cls.steplen) # this will be useless in load_bulk del fields['depth'] del fields['path'] del fields['numchild'] if pk_field in fields: # this happens immediately after a load_bulk del fields[pk_field] newobj = {'data': fields} if keep_ids: newobj[pk_field] = pyobj['pk'] if (not parent and depth == 1) or\ (parent and len(path) == len(parent.path)): ret.append(newobj) else: parentpath = cls._get_basepath(path, depth - 1) parentobj = lnk[parentpath] if 'children' not in parentobj: parentobj['children'] = [] parentobj['children'].append(newobj) lnk[path] = newobj return ret @classmethod def find_problems(cls): """ Checks for problems in the tree structure, problems can occur when: 1. your code breaks and you get incomplete transactions (always use transactions!) 2. changing the ``steplen`` value in a model (you must :meth:`dump_bulk` first, change ``steplen`` and then :meth:`load_bulk` :returns: A tuple of five lists: 1. a list of ids of nodes with characters not found in the ``alphabet`` 2. a list of ids of nodes when a wrong ``path`` length according to ``steplen`` 3. a list of ids of orphaned nodes 4. a list of ids of nodes with the wrong depth value for their path 5. a list of ids nodes that report a wrong number of children """ cls = get_result_class(cls) vendor = cls.get_database_vendor('write') evil_chars, bad_steplen, orphans = [], [], [] wrong_depth, wrong_numchild = [], [] for node in cls.objects.all(): found_error = False for char in node.path: if char not in cls.alphabet: evil_chars.append(node.pk) found_error = True break if found_error: continue if len(node.path) % cls.steplen: bad_steplen.append(node.pk) continue try: node.get_parent(True) except cls.DoesNotExist: orphans.append(node.pk) continue if node.depth != int(len(node.path) / cls.steplen): wrong_depth.append(node.pk) continue real_numchild = cls.objects.filter( path__range=cls._get_children_path_interval(node.path) ).extra( where=[(sql_length("path", vendor=vendor) + '/%d=%d') % (cls.steplen, node.depth + 1)] ).count() if real_numchild != node.numchild: wrong_numchild.append(node.pk) continue return evil_chars, bad_steplen, orphans, wrong_depth, wrong_numchild @classmethod def fix_tree(cls, destructive=False, fix_paths=False): """ Solves some problems that can appear when transactions are not used and a piece of code breaks, leaving the tree in an inconsistent state. The problems this method solves are: 1. Nodes with an incorrect ``depth`` or ``numchild`` values due to incorrect code and lack of database transactions. 2. "Holes" in the tree. This is normal if you move/delete nodes a lot. Holes in a tree don't affect performance, 3. Incorrect ordering of nodes when ``node_order_by`` is enabled. Ordering is enforced on *node insertion*, so if an attribute in ``node_order_by`` is modified after the node is inserted, the tree ordering will be inconsistent. :param fix_paths: A boolean value. If True, a slower, more complex fix_tree method will be attempted. If False (the default), it will use a safe (and fast!) fix approach, but it will only solve the ``depth`` and ``numchild`` nodes, it won't fix the tree holes or broken path ordering. :param destructive: Deprecated; alias for ``fix_paths``. """ cls = get_result_class(cls) vendor = cls.get_database_vendor('write') cursor = cls._get_database_cursor('write') # fix the depth field # we need the WHERE to speed up postgres sql = ( "UPDATE %s " "SET depth=" + sql_length("path", vendor=vendor) + "/%%s " "WHERE depth!=" + sql_length("path", vendor=vendor) + "/%%s" ) % (connection.ops.quote_name(cls._meta.db_table), ) vals = [cls.steplen, cls.steplen] cursor.execute(sql, vals) # fix the numchild field vals = ['_' * cls.steplen] # the cake and sql portability are a lie if cls.get_database_vendor('read') == 'mysql': sql = ( "SELECT tbn1.path, tbn1.numchild, (" "SELECT COUNT(1) " "FROM %(table)s AS tbn2 " "WHERE tbn2.path LIKE " + sql_concat("tbn1.path", "%%s", vendor=vendor) + ") AS real_numchild " "FROM %(table)s AS tbn1 " "HAVING tbn1.numchild != real_numchild" ) % {'table': connection.ops.quote_name(cls._meta.db_table)} else: subquery = "(SELECT COUNT(1) FROM %(table)s AS tbn2"\ " WHERE tbn2.path LIKE " + sql_concat("tbn1.path", "%%s", vendor=vendor) + ")" sql = ("SELECT tbn1.path, tbn1.numchild, " + subquery + " FROM %(table)s AS tbn1 WHERE tbn1.numchild != " + subquery) sql = sql % { 'table': connection.ops.quote_name(cls._meta.db_table)} # we include the subquery twice vals *= 2 cursor.execute(sql, vals) sql = "UPDATE %(table)s "\ "SET numchild=%%s "\ "WHERE path=%%s" % { 'table': connection.ops.quote_name(cls._meta.db_table)} for node_data in cursor.fetchall(): vals = [node_data[2], node_data[0]] cursor.execute(sql, vals) if fix_paths or destructive: with transaction.atomic(): # To fix holes and mis-orderings in paths, we consider each non-leaf node in turn # and ensure that its children's path values are consecutive (and in the order # given by node_order_by, if applicable). children_to_fix is a queue of child sets # that we know about but have not yet fixed, expressed as a tuple of # (parent_path, depth). Since we're updating paths as we go, we must take care to # only add items to this list after the corresponding parent node has been fixed # (and is thus not going to change). # Initially children_to_fix is the set of root nodes, i.e. ones with a path # starting with '' and depth 1. children_to_fix = [('', 1)] while children_to_fix: parent_path, depth = children_to_fix.pop(0) children = cls.objects.filter( path__startswith=parent_path, depth=depth ).values('pk', 'path', 'depth', 'numchild') desired_sequence = children.order_by(*(cls.node_order_by or ['path'])) # mapping of current path position (converted to numeric) to item actual_sequence = {} # highest numeric path position currently in use max_position = None # loop over items to populate actual_sequence and max_position for item in desired_sequence: actual_position = cls._str2int(item['path'][-cls.steplen:]) actual_sequence[actual_position] = item if max_position is None or actual_position > max_position: max_position = actual_position # loop over items to perform path adjustments for (i, item) in enumerate(desired_sequence): desired_position = i + 1 # positions are 1-indexed actual_position = cls._str2int(item['path'][-cls.steplen:]) if actual_position == desired_position: pass else: # if a node is already in the desired position, move that node # to max_position + 1 to get it out of the way occupant = actual_sequence.get(desired_position) if occupant: old_path = occupant['path'] max_position += 1 new_path = cls._get_path(parent_path, depth, max_position) if len(new_path) > len(old_path): previous_max_path = cls._get_path(parent_path, depth, max_position - 1) raise PathOverflow(_("Path Overflow from: '%s'" % (previous_max_path, ))) cls._rewrite_node_path(old_path, new_path) # update actual_sequence to reflect the new position actual_sequence[max_position] = occupant del(actual_sequence[desired_position]) occupant['path'] = new_path # move item into the (now vacated) desired position old_path = item['path'] new_path = cls._get_path(parent_path, depth, desired_position) cls._rewrite_node_path(old_path, new_path) # update actual_sequence to reflect the new position actual_sequence[desired_position] = item del(actual_sequence[actual_position]) item['path'] = new_path if item['numchild']: # this item has children to process, and we have now moved the parent # node into its final position, so it's safe to add to children_to_fix children_to_fix.append((item['path'], depth + 1)) @classmethod def _rewrite_node_path(cls, old_path, new_path): cls.objects.filter(path__startswith=old_path).update( path=Concat( Value(new_path), Substr('path', len(old_path) + 1) ) ) @classmethod def get_tree(cls, parent=None): """ :returns: A *queryset* of nodes ordered as DFS, including the parent. If no parent is given, the entire tree is returned. """ cls = get_result_class(cls) if parent is None: # return the entire tree return cls.objects.all() if parent.is_leaf(): return cls.objects.filter(pk=parent.pk) return cls.objects.filter( path__startswith=parent.path, depth__gte=parent.depth ).order_by( 'path' ) @classmethod def get_root_nodes(cls): """:returns: A queryset containing the root nodes in the tree.""" return get_result_class(cls).objects.filter(depth=1).order_by('path') @classmethod def get_descendants_group_count(cls, parent=None): """ Helper for a very common case: get a group of siblings and the number of *descendants* in every sibling. """ # ~ # disclaimer: this is the FOURTH implementation I wrote for this # function. I really tried to make it return a queryset, but doing so # with a *single* query isn't trivial with Django's ORM. # ok, I DID manage to make Django's ORM return a queryset here, # defining two querysets, passing one subquery in the tables parameters # of .extra() of the second queryset, using the undocumented order_by # feature, and using a HORRIBLE hack to avoid django quoting the # subquery as a table, BUT (and there is always a but) the hack didn't # survive turning the QuerySet into a ValuesQuerySet, so I just used # good old SQL. # NOTE: in case there is interest, the hack to avoid django quoting the # subquery as a table, was adding the subquery to the alias cache of # the queryset's query object: # # qset.query.quote_cache[subquery] = subquery # # If there is a better way to do this in an UNMODIFIED django 1.0, let # me know. # ~ cls = get_result_class(cls) vendor = cls.get_database_vendor('write') if parent: depth = parent.depth + 1 params = cls._get_children_path_interval(parent.path) extrand = 'AND path BETWEEN %s AND %s' else: depth = 1 params = [] extrand = '' subpath = sql_substr("path", "1", "%(subpathlen)s", vendor=vendor) sql = ( 'SELECT * FROM %(table)s AS t1 INNER JOIN ' ' (SELECT ' ' ' + subpath + ' AS subpath, ' ' COUNT(1)-1 AS count ' ' FROM %(table)s ' ' WHERE depth >= %(depth)s %(extrand)s' ' GROUP BY ' + subpath + ') AS t2 ' ' ON t1.path=t2.subpath ' ' ORDER BY t1.path' ) % { 'table': connection.ops.quote_name(cls._meta.db_table), 'subpathlen': depth * cls.steplen, 'depth': depth, 'extrand': extrand} cursor = cls._get_database_cursor('write') cursor.execute(sql, params) ret = [] field_names = [field[0] for field in cursor.description] for node_data in cursor.fetchall(): node = cls(**dict(zip(field_names, node_data[:-2]))) node.descendants_count = node_data[-1] ret.append(node) return ret def get_depth(self): """:returns: the depth (level) of the node""" return self.depth def get_siblings(self): """ :returns: A queryset of all the node's siblings, including the node itself. """ qset = get_result_class(self.__class__).objects.filter( depth=self.depth ).order_by( 'path' ) if self.depth > 1: # making sure the non-root nodes share a parent parentpath = self._get_basepath(self.path, self.depth - 1) qset = qset.filter( path__range=self._get_children_path_interval(parentpath)) return qset def get_children(self): """:returns: A queryset of all the node's children""" if self.is_leaf(): return get_result_class(self.__class__).objects.none() return get_result_class(self.__class__).objects.filter( depth=self.depth + 1, path__range=self._get_children_path_interval(self.path) ).order_by( 'path' ) def get_next_sibling(self): """ :returns: The next node's sibling, or None if it was the rightmost sibling. """ try: return self.get_siblings().filter(path__gt=self.path)[0] except IndexError: return None def get_descendants(self): """ :returns: A queryset of all the node's descendants as DFS, doesn't include the node itself """ if self.is_leaf(): return get_result_class(self.__class__).objects.none() return self.__class__.get_tree(self).exclude(pk=self.pk) def get_prev_sibling(self): """ :returns: The previous node's sibling, or None if it was the leftmost sibling. """ try: return self.get_siblings().filter(path__lt=self.path).reverse()[0] except IndexError: return None def get_children_count(self): """ :returns: The number the node's children, calculated in the most efficient possible way. """ return self.numchild def is_sibling_of(self, node): """ :returns: ``True`` if the node is a sibling of another node given as an argument, else, returns ``False`` """ aux = self.depth == node.depth # Check non-root nodes share a parent only if they have the same depth if aux and self.depth > 1: # making sure the non-root nodes share a parent parentpath = self._get_basepath(self.path, self.depth - 1) return aux and node.path.startswith(parentpath) return aux def is_child_of(self, node): """ :returns: ``True`` is the node if a child of another node given as an argument, else, returns ``False`` """ return (self.path.startswith(node.path) and self.depth == node.depth + 1) def is_descendant_of(self, node): """ :returns: ``True`` if the node is a descendant of another node given as an argument, else, returns ``False`` """ return self.path.startswith(node.path) and self.depth > node.depth def add_child(self, **kwargs): """ Adds a child to the node. This method saves the node in database. The object is populated as if via: ``` obj = self.__class__(**kwargs) ``` :raise PathOverflow: when no more child nodes can be added """ return MP_AddChildHandler(self, **kwargs).process() def add_sibling(self, pos=None, **kwargs): """ Adds a new node as a sibling to the current node object. This method saves the node in database. The object is populated as if via: ``` obj = self.__class__(**kwargs) ``` :raise PathOverflow: when the library can't make room for the node's new position """ return MP_AddSiblingHandler(self, pos, **kwargs).process() def get_root(self): """:returns: the root node for the current node object.""" return get_result_class(self.__class__).objects.get( path=self.path[0:self.steplen]) def is_root(self): """:returns: True if the node is a root node (else, returns False)""" return self.depth == 1 def is_leaf(self): """:returns: True if the node is a leaf node (else, returns False)""" return self.numchild == 0 def get_ancestors(self): """ :returns: A queryset containing the current node object's ancestors, starting by the root node and descending to the parent. """ if self.is_root(): return get_result_class(self.__class__).objects.none() paths = [ self.path[0:pos] for pos in range(0, len(self.path), self.steplen)[1:] ] return get_result_class(self.__class__).objects.filter( path__in=paths).order_by('depth') def get_parent(self, update=False): """ :returns: the parent node of the current node object. Caches the result in the object itself to help in loops. """ depth = int(len(self.path) / self.steplen) if depth <= 1: return try: if update: del self._cached_parent_obj else: return self._cached_parent_obj except AttributeError: pass parentpath = self._get_basepath(self.path, depth - 1) self._cached_parent_obj = get_result_class( self.__class__).objects.get(path=parentpath) return self._cached_parent_obj def move(self, target, pos=None): """ Moves the current node and all it's descendants to a new position relative to another node. :raise PathOverflow: when the library can't make room for the node's new position """ return MP_MoveHandler(self, target, pos).process() @classmethod def _get_basepath(cls, path, depth): """:returns: The base path of another path up to a given depth""" if path: return path[0:depth * cls.steplen] return '' @classmethod def _get_path(cls, path, depth, newstep): """ Builds a path given some values :param path: the base path :param depth: the depth of the node :param newstep: the value (integer) of the new step """ parentpath = cls._get_basepath(path, depth - 1) key = cls._int2str(newstep) return '{0}{1}{2}'.format( parentpath, cls.alphabet[0] * (cls.steplen - len(key)), key ) def _inc_path(self): """:returns: The path of the next sibling of a given node path.""" newpos = self._str2int(self.path[-self.steplen:]) + 1 key = self._int2str(newpos) if len(key) > self.steplen: raise PathOverflow(_("Path Overflow from: '%s'" % (self.path, ))) return '{0}{1}{2}'.format( self.path[:-self.steplen], self.alphabet[0] * (self.steplen - len(key)), key ) def _get_lastpos_in_path(self): """:returns: The integer value of the last step in a path.""" return self._str2int(self.path[-self.steplen:]) @classmethod def _get_parent_path_from_path(cls, path): """:returns: The parent path for a given path""" if path: return path[0:len(path) - cls.steplen] return '' @classmethod def _get_children_path_interval(cls, path): """:returns: An interval of all possible children paths for a node.""" return (path + cls.alphabet[0] * cls.steplen, path + cls.alphabet[-1] * cls.steplen) class Meta: """Abstract model.""" abstract = True ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/ns_tree.py0000644000175100001770000006110214556473264020313 0ustar00runnerdocker"""Nested Sets""" import operator from functools import reduce from django.core import serializers from django.db import connection, models from django.db.models import Q from django.utils.translation import gettext_noop as _ from treebeard.exceptions import InvalidMoveToDescendant, NodeAlreadySaved from treebeard.models import Node def get_result_class(cls): """ For the given model class, determine what class we should use for the nodes returned by its tree methods (such as get_children). Usually this will be trivially the same as the initial model class, but there are special cases when model inheritance is in use: * If the model extends another via multi-table inheritance, we need to use whichever ancestor originally implemented the tree behaviour (i.e. the one which defines the 'lft'/'rgt' fields). We can't use the subclass, because it's not guaranteed that the other nodes reachable from the current one will be instances of the same subclass. * If the model is a proxy model, the returned nodes should also use the proxy class. """ base_class = cls._meta.get_field('lft').model if cls._meta.proxy_for_model == base_class: return cls else: return base_class def merge_deleted_counters(c1, c2): """ Merge return values from Django's Queryset.delete() method. """ object_counts = { key: c1[1].get(key, 0) + c2[1].get(key, 0) for key in set(c1[1]) | set(c2[1]) } return (c1[0] + c2[0], object_counts) class NS_NodeQuerySet(models.query.QuerySet): """ Custom queryset for the tree node manager. Needed only for the customized delete method. """ def delete(self, *args, removed_ranges=None, deleted_counter=None, **kwargs): """ Custom delete method, will remove all descendant nodes to ensure a consistent tree (no orphans) :returns: tuple of the number of objects deleted and a dictionary with the number of deletions per object type """ model = get_result_class(self.model) if deleted_counter is None: deleted_counter = (0, {}) if removed_ranges is not None: # we already know the children, let's call the default django # delete method and let it handle the removal of the user's # foreign keys... result = super().delete(*args, **kwargs) deleted_counter = merge_deleted_counters(deleted_counter, result) cursor = model._get_database_cursor('write') # Now closing the gap (Celko's trees book, page 62) # We do this for every gap that was left in the tree when the nodes # were removed. If many nodes were removed, we're going to update # the same nodes over and over again. This would be probably # cheaper precalculating the gapsize per intervals, or just do a # complete reordering of the tree (uses COUNT)... for tree_id, drop_lft, drop_rgt in sorted(removed_ranges, reverse=True): sql, params = model._get_close_gap_sql(drop_lft, drop_rgt, tree_id) cursor.execute(sql, params) else: # we'll have to manually run through all the nodes that are going # to be deleted and remove nodes from the list if an ancestor is # already getting removed, since that would be redundant removed = {} for node in self.order_by('tree_id', 'lft'): found = False for rid, rnode in removed.items(): if node.is_descendant_of(rnode): found = True break if not found: removed[node.pk] = node # ok, got the minimal list of nodes to remove... # we must also remove their descendants toremove = [] ranges = [] for id, node in removed.items(): toremove.append(Q(lft__range=(node.lft, node.rgt)) & Q(tree_id=node.tree_id)) ranges.append((node.tree_id, node.lft, node.rgt)) if toremove: deleted_counter = model.objects.filter( reduce(operator.or_, toremove) ).delete(removed_ranges=ranges, deleted_counter=deleted_counter) return deleted_counter delete.alters_data = True delete.queryset_only = True class NS_NodeManager(models.Manager): """Custom manager for nodes in a Nested Sets tree.""" def get_queryset(self): """Sets the custom queryset as the default.""" return NS_NodeQuerySet(self.model).order_by('tree_id', 'lft') class NS_Node(Node): """Abstract model to create your own Nested Sets Trees.""" node_order_by = [] lft = models.PositiveIntegerField(db_index=True) rgt = models.PositiveIntegerField(db_index=True) tree_id = models.PositiveIntegerField(db_index=True) depth = models.PositiveIntegerField(db_index=True) objects = NS_NodeManager() @classmethod def add_root(cls, **kwargs): """Adds a root node to the tree.""" # do we have a root node already? last_root = cls.get_last_root_node() if last_root and last_root.node_order_by: # there are root nodes and node_order_by has been set # delegate sorted insertion to add_sibling return last_root.add_sibling('sorted-sibling', **kwargs) if last_root: # adding the new root node as the last one newtree_id = last_root.tree_id + 1 else: # adding the first root node newtree_id = 1 if len(kwargs) == 1 and 'instance' in kwargs: # adding the passed (unsaved) instance to the tree newobj = kwargs['instance'] if not newobj._state.adding: raise NodeAlreadySaved("Attempted to add a tree node that is "\ "already in the database") else: # creating the new object newobj = get_result_class(cls)(**kwargs) newobj.depth = 1 newobj.tree_id = newtree_id newobj.lft = 1 newobj.rgt = 2 # saving the instance before returning it newobj.save() return newobj @classmethod def _move_right(cls, tree_id, rgt, lftmove=False, incdec=2): if lftmove: lftop = '>=' else: lftop = '>' sql = 'UPDATE %(table)s '\ ' SET lft = CASE WHEN lft %(lftop)s %(parent_rgt)d '\ ' THEN lft %(incdec)+d '\ ' ELSE lft END, '\ ' rgt = CASE WHEN rgt >= %(parent_rgt)d '\ ' THEN rgt %(incdec)+d '\ ' ELSE rgt END '\ ' WHERE rgt >= %(parent_rgt)d AND '\ ' tree_id = %(tree_id)s' % { 'table': connection.ops.quote_name( get_result_class(cls)._meta.db_table), 'parent_rgt': rgt, 'tree_id': tree_id, 'lftop': lftop, 'incdec': incdec} return sql, [] @classmethod def _move_tree_right(cls, tree_id): sql = 'UPDATE %(table)s '\ ' SET tree_id = tree_id+1 '\ ' WHERE tree_id >= %(tree_id)d' % { 'table': connection.ops.quote_name( get_result_class(cls)._meta.db_table), 'tree_id': tree_id} return sql, [] def add_child(self, **kwargs): """Adds a child to the node.""" if not self.is_leaf(): # there are child nodes, delegate insertion to add_sibling if self.node_order_by: pos = 'sorted-sibling' else: pos = 'last-sibling' last_child = self.get_last_child() last_child._cached_parent_obj = self return last_child.add_sibling(pos, **kwargs) # we're adding the first child of this node sql, params = self.__class__._move_right(self.tree_id, self.rgt, False, 2) if len(kwargs) == 1 and 'instance' in kwargs: # adding the passed (unsaved) instance to the tree newobj = kwargs['instance'] if not newobj._state.adding: raise NodeAlreadySaved("Attempted to add a tree node that is "\ "already in the database") else: # creating a new object newobj = get_result_class(self.__class__)(**kwargs) newobj.tree_id = self.tree_id newobj.depth = self.depth + 1 newobj.lft = self.lft + 1 newobj.rgt = self.lft + 2 # this is just to update the cache self.rgt += 2 newobj._cached_parent_obj = self cursor = self._get_database_cursor('write') cursor.execute(sql, params) # saving the instance before returning it newobj.save() return newobj def add_sibling(self, pos=None, **kwargs): """Adds a new node as a sibling to the current node object.""" pos = self._prepare_pos_var_for_add_sibling(pos) if len(kwargs) == 1 and 'instance' in kwargs: # adding the passed (unsaved) instance to the tree newobj = kwargs['instance'] if not newobj._state.adding: raise NodeAlreadySaved("Attempted to add a tree node that is "\ "already in the database") else: # creating a new object newobj = get_result_class(self.__class__)(**kwargs) newobj.depth = self.depth sql = None target = self if target.is_root(): newobj.lft = 1 newobj.rgt = 2 if pos == 'sorted-sibling': siblings = list(target.get_sorted_pos_queryset( target.get_siblings(), newobj)) if siblings: pos = 'left' target = siblings[0] else: pos = 'last-sibling' last_root = target.__class__.get_last_root_node() if ( (pos == 'last-sibling') or (pos == 'right' and target == last_root) ): newobj.tree_id = last_root.tree_id + 1 else: newpos = {'first-sibling': 1, 'left': target.tree_id, 'right': target.tree_id + 1}[pos] sql, params = target.__class__._move_tree_right(newpos) newobj.tree_id = newpos else: newobj.tree_id = target.tree_id if pos == 'sorted-sibling': siblings = list(target.get_sorted_pos_queryset( target.get_siblings(), newobj)) if siblings: pos = 'left' target = siblings[0] else: pos = 'last-sibling' if pos in ('left', 'right', 'first-sibling'): siblings = list(target.get_siblings()) if pos == 'right': if target == siblings[-1]: pos = 'last-sibling' else: pos = 'left' found = False for node in siblings: if found: target = node break elif node == target: found = True if pos == 'left': if target == siblings[0]: pos = 'first-sibling' if pos == 'first-sibling': target = siblings[0] move_right = self.__class__._move_right if pos == 'last-sibling': newpos = target.get_parent().rgt sql, params = move_right(target.tree_id, newpos, False, 2) elif pos == 'first-sibling': newpos = target.lft sql, params = move_right(target.tree_id, newpos - 1, False, 2) elif pos == 'left': newpos = target.lft sql, params = move_right(target.tree_id, newpos, True, 2) newobj.lft = newpos newobj.rgt = newpos + 1 # saving the instance before returning it if sql: cursor = self._get_database_cursor('write') cursor.execute(sql, params) newobj.save() return newobj def move(self, target, pos=None): """ Moves the current node and all it's descendants to a new position relative to another node. """ pos = self._prepare_pos_var_for_move(pos) cls = get_result_class(self.__class__) parent = None if pos in ('first-child', 'last-child', 'sorted-child'): # moving to a child if target.is_leaf(): parent = target pos = 'last-child' else: target = target.get_last_child() pos = {'first-child': 'first-sibling', 'last-child': 'last-sibling', 'sorted-child': 'sorted-sibling'}[pos] if target.is_descendant_of(self): raise InvalidMoveToDescendant( _("Can't move node to a descendant.")) if self == target and ( (pos == 'left') or (pos in ('right', 'last-sibling') and target == target.get_last_sibling()) or (pos == 'first-sibling' and target == target.get_first_sibling())): # special cases, not actually moving the node so no need to UPDATE return if pos == 'sorted-sibling': siblings = list(target.get_sorted_pos_queryset( target.get_siblings(), self)) if siblings: pos = 'left' target = siblings[0] else: pos = 'last-sibling' if pos in ('left', 'right', 'first-sibling'): siblings = list(target.get_siblings()) if pos == 'right': if target == siblings[-1]: pos = 'last-sibling' else: pos = 'left' found = False for node in siblings: if found: target = node break elif node == target: found = True if pos == 'left': if target == siblings[0]: pos = 'first-sibling' if pos == 'first-sibling': target = siblings[0] # ok let's move this cursor = self._get_database_cursor('write') move_right = cls._move_right gap = self.rgt - self.lft + 1 sql = None target_tree = target.tree_id # first make a hole if pos == 'last-child': newpos = parent.rgt sql, params = move_right(target.tree_id, newpos, False, gap) elif target.is_root(): newpos = 1 if pos == 'last-sibling': target_tree = target.get_siblings().reverse()[0].tree_id + 1 elif pos == 'first-sibling': target_tree = 1 sql, params = cls._move_tree_right(1) elif pos == 'left': sql, params = cls._move_tree_right(target.tree_id) else: if pos == 'last-sibling': newpos = target.get_parent().rgt sql, params = move_right(target.tree_id, newpos, False, gap) elif pos == 'first-sibling': newpos = target.lft sql, params = move_right(target.tree_id, newpos - 1, False, gap) elif pos == 'left': newpos = target.lft sql, params = move_right(target.tree_id, newpos, True, gap) if sql: cursor.execute(sql, params) # we reload 'self' because lft/rgt may have changed fromobj = cls.objects.get(pk=self.pk) depthdiff = target.depth - fromobj.depth if parent: depthdiff += 1 # move the tree to the hole sql = "UPDATE %(table)s "\ " SET tree_id = %(target_tree)d, "\ " lft = lft + %(jump)d , "\ " rgt = rgt + %(jump)d , "\ " depth = depth + %(depthdiff)d "\ " WHERE tree_id = %(from_tree)d AND "\ " lft BETWEEN %(fromlft)d AND %(fromrgt)d" % { 'table': connection.ops.quote_name(cls._meta.db_table), 'from_tree': fromobj.tree_id, 'target_tree': target_tree, 'jump': newpos - fromobj.lft, 'depthdiff': depthdiff, 'fromlft': fromobj.lft, 'fromrgt': fromobj.rgt} cursor.execute(sql, []) # close the gap sql, params = cls._get_close_gap_sql(fromobj.lft, fromobj.rgt, fromobj.tree_id) cursor.execute(sql, params) @classmethod def _get_close_gap_sql(cls, drop_lft, drop_rgt, tree_id): sql = 'UPDATE %(table)s '\ ' SET lft = CASE '\ ' WHEN lft > %(drop_lft)d '\ ' THEN lft - %(gapsize)d '\ ' ELSE lft END, '\ ' rgt = CASE '\ ' WHEN rgt > %(drop_lft)d '\ ' THEN rgt - %(gapsize)d '\ ' ELSE rgt END '\ ' WHERE (lft > %(drop_lft)d '\ ' OR rgt > %(drop_lft)d) AND '\ ' tree_id=%(tree_id)d' % { 'table': connection.ops.quote_name( get_result_class(cls)._meta.db_table), 'gapsize': drop_rgt - drop_lft + 1, 'drop_lft': drop_lft, 'tree_id': tree_id} return sql, [] @classmethod def load_bulk(cls, bulk_data, parent=None, keep_ids=False): """Loads a list/dictionary structure to the tree.""" cls = get_result_class(cls) # tree, iterative preorder added = [] if parent: parent_id = parent.pk else: parent_id = None # stack of nodes to analyze stack = [(parent_id, node) for node in bulk_data[::-1]] foreign_keys = cls.get_foreign_keys() pk_field = cls._meta.pk.attname while stack: parent_id, node_struct = stack.pop() # shallow copy of the data structure so it doesn't persist... node_data = node_struct['data'].copy() cls._process_foreign_keys(foreign_keys, node_data) if keep_ids: node_data[pk_field] = node_struct[pk_field] if parent_id: parent = cls.objects.get(pk=parent_id) node_obj = parent.add_child(**node_data) else: node_obj = cls.add_root(**node_data) added.append(node_obj.pk) if 'children' in node_struct: # extending the stack with the current node as the parent of # the new nodes stack.extend([ (node_obj.pk, node) for node in node_struct['children'][::-1] ]) return added def get_children(self): """:returns: A queryset of all the node's children""" return self.get_descendants().filter(depth=self.depth + 1) def get_depth(self): """:returns: the depth (level) of the node""" return self.depth def is_leaf(self): """:returns: True if the node is a leaf node (else, returns False)""" return self.rgt - self.lft == 1 def get_root(self): """:returns: the root node for the current node object.""" if self.lft == 1: return self return get_result_class(self.__class__).objects.get( tree_id=self.tree_id, lft=1) def is_root(self): """:returns: True if the node is a root node (else, returns False)""" return self.lft == 1 def get_siblings(self): """ :returns: A queryset of all the node's siblings, including the node itself. """ if self.lft == 1: return self.get_root_nodes() return self.get_parent(True).get_children() @classmethod def dump_bulk(cls, parent=None, keep_ids=True): """Dumps a tree branch to a python data structure.""" qset = cls._get_serializable_model().get_tree(parent) ret, lnk = [], {} pk_field = cls._meta.pk.attname for pyobj in qset: serobj = serializers.serialize('python', [pyobj])[0] # django's serializer stores the attributes in 'fields' fields = serobj['fields'] depth = fields['depth'] # this will be useless in load_bulk del fields['lft'] del fields['rgt'] del fields['depth'] del fields['tree_id'] if pk_field in fields: # this happens immediately after a load_bulk del fields[pk_field] newobj = {'data': fields} if keep_ids: newobj[pk_field] = serobj['pk'] if (not parent and depth == 1) or\ (parent and depth == parent.depth): ret.append(newobj) else: parentobj = pyobj.get_parent() parentser = lnk[parentobj.pk] if 'children' not in parentser: parentser['children'] = [] parentser['children'].append(newobj) lnk[pyobj.pk] = newobj return ret @classmethod def get_tree(cls, parent=None): """ :returns: A *queryset* of nodes ordered as DFS, including the parent. If no parent is given, all trees are returned. """ cls = get_result_class(cls) if parent is None: # return the entire tree return cls.objects.all() if parent.is_leaf(): return cls.objects.filter(pk=parent.pk) return cls.objects.filter( tree_id=parent.tree_id, lft__range=(parent.lft, parent.rgt - 1)) def get_descendants(self): """ :returns: A queryset of all the node's descendants as DFS, doesn't include the node itself """ if self.is_leaf(): return get_result_class(self.__class__).objects.none() return self.__class__.get_tree(self).exclude(pk=self.pk) def get_descendant_count(self): """:returns: the number of descendants of a node.""" return (self.rgt - self.lft - 1) / 2 def get_ancestors(self): """ :returns: A queryset containing the current node object's ancestors, starting by the root node and descending to the parent. """ if self.is_root(): return get_result_class(self.__class__).objects.none() return get_result_class(self.__class__).objects.filter( tree_id=self.tree_id, lft__lt=self.lft, rgt__gt=self.rgt) def is_descendant_of(self, node): """ :returns: ``True`` if the node if a descendant of another node given as an argument, else, returns ``False`` """ return ( self.tree_id == node.tree_id and self.lft > node.lft and self.rgt < node.rgt ) def get_parent(self, update=False): """ :returns: the parent node of the current node object. Caches the result in the object itself to help in loops. """ if self.is_root(): return try: if update: del self._cached_parent_obj else: return self._cached_parent_obj except AttributeError: pass # parent = our most direct ancestor self._cached_parent_obj = self.get_ancestors().reverse()[0] return self._cached_parent_obj @classmethod def get_root_nodes(cls): """:returns: A queryset containing the root nodes in the tree.""" return get_result_class(cls).objects.filter(lft=1) class Meta: """Abstract model.""" abstract = True ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/numconv.py0000644000175100001770000000751314556473264020347 0ustar00runnerdocker"""Convert strings to numbers and numbers to strings. Gustavo Picon https://tabo.pe/projects/numconv/ """ __version__ = '2.1.1' # from april fool's rfc 1924 BASE85 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' \ '!#$%&()*+-;<=>?@^_`{|}~' # rfc4648 alphabets BASE16 = BASE85[:16] BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' BASE32HEX = BASE85[:32] BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' BASE64URL = BASE64[:62] + '-_' # http://en.wikipedia.org/wiki/Base_62 useful for url shorteners BASE62 = BASE85[:62] class NumConv(object): """Class to create converter objects. :param radix: The base that will be used in the conversions. The default value is 10 for decimal conversions. :param alphabet: A string that will be used as a encoding alphabet. The length of the alphabet can be longer than the radix. In this case the alphabet will be internally truncated. The default value is :data:`numconv.BASE85` :raise TypeError: when *radix* isn't an integer :raise ValueError: when *radix* is invalid :raise ValueError: when *alphabet* has duplicated characters """ def __init__(self, radix=10, alphabet=BASE85): """basic validation and cached_map storage""" if int(radix) != radix: raise TypeError('radix must be an integer') if not 2 <= radix <= len(alphabet): raise ValueError('radix must be >= 2 and <= %d' % ( len(alphabet), )) self.radix = radix self.alphabet = alphabet self.cached_map = dict(zip(self.alphabet, range(len(self.alphabet)))) if len(self.cached_map) != len(self.alphabet): raise ValueError("duplicate characters found in '%s'" % ( self.alphabet, )) def int2str(self, num): """Converts an integer into a string. :param num: A numeric value to be converted to another base as a string. :rtype: string :raise TypeError: when *num* isn't an integer :raise ValueError: when *num* isn't positive """ if int(num) != num: raise TypeError('number must be an integer') if num < 0: raise ValueError('number must be positive') radix, alphabet = self.radix, self.alphabet if radix in (8, 10, 16) and \ alphabet[:radix].lower() == BASE85[:radix].lower(): return ({8: '%o', 10: '%d', 16: '%x'}[radix] % num).upper() ret = '' while True: ret = alphabet[num % radix] + ret if num < radix: break num //= radix return ret def str2int(self, num): """Converts a string into an integer. If possible, the built-in python conversion will be used for speed purposes. :param num: A string that will be converted to an integer. :rtype: integer :raise ValueError: when *num* is invalid """ radix, alphabet = self.radix, self.alphabet if radix <= 36 and alphabet[:radix].lower() == BASE85[:radix].lower(): return int(num, radix) ret = 0 lalphabet = alphabet[:radix] for char in num: if char not in lalphabet: raise ValueError("invalid literal for radix2int() with radix " "%d: '%s'" % (radix, num)) ret = ret * radix + self.cached_map[char] return ret def int2str(num, radix=10, alphabet=BASE85): """helper function for quick base conversions from integers to strings""" return NumConv(radix, alphabet).int2str(num) def str2int(num, radix=10, alphabet=BASE85): """helper function for quick base conversions from strings to integers""" return NumConv(radix, alphabet).str2int(num) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8906186 django-treebeard-4.7.1/treebeard/static/0000755000175100001770000000000014556473276017574 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9066186 django-treebeard-4.7.1/treebeard/static/treebeard/0000755000175100001770000000000014556473276021531 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/static/treebeard/expand-collapse.png0000644000175100001770000000177414556473264025324 0ustar00runnerdockerPNG  IHDR@}sRGBbKGD pHYs  tIME 9=s|IDATXUnH.76t{6$keF@2!Qwq\2]p%%qwp di/dU_踺4}a2@8E$IH$c<___/$IxDI(ofN= A f?j 0o/?+)2J|ۜώ p.VR+ĕJ60&cCV&v;J(|R׃,RnoofSI4$IvvvE ƘvӞ雈n1s7 @r`rGNaeMhZR\.$˲C)ńcFCW\׃Ӊ(L49n,KnBFqX}I$? /;ؾͼ< IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/static/treebeard/treebeard-admin.css0000644000175100001770000000261014556473264025262 0ustar00runnerdocker/* Treebeard Admin */ #roots { margin: 0; padding: 0; } #roots li { list-style: none; padding: 5px !important; line-height: 13px; border-bottom: 1px solid #EEE; } #roots li a { font-weight: bold; font-size: 12px; } #roots li input { margin: 0 5px; } .oder-grabber { width: 1.5em; text-align: center; } .drag-handler span { width: 16px; background: transparent url(expand-collapse.png) no-repeat left -48px; height: 16px; margin: 0 5px; display: inline-block; } .drag-handler span.active { background: transparent url(expand-collapse.png) no-repeat left -32px; cursor: move; } .spacer { width: 10px; margin: 0 10px; } .collapse { width: 16px; height: 16px; display: inline-block; text-indent: -999px; } .collapsed { background: transparent url(expand-collapse.png) no-repeat left -16px; } .expanded { background: transparent url(expand-collapse.png) no-repeat left 0; } #drag_line { border-top: 5px solid #A0A; background: #A0A; display: block; position: absolute; } #drag_line span { position: relative; display: block; width: 100px; background: #FFD; color: #000; left: 100px; text-align: center; border: 1px solid #000; vertical-align: center; } /*tr:target { I'm handling the highlight with js to have more control background-color: #FF0; }*/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/static/treebeard/treebeard-admin.js0000644000175100001770000003444414556473264025120 0ustar00runnerdocker(function ($) { // Ok, let's do eeet ACTIVE_NODE_BG_COLOR = '#B7D7E8'; RECENTLY_MOVED_COLOR = '#FFFF00'; RECENTLY_MOVED_FADEOUT = '#FFFFFF'; ABORT_COLOR = '#EECCCC'; DRAG_LINE_COLOR = '#AA00AA'; MOVE_NODE_ENDPOINT = 'move/'; RECENTLY_FADE_DURATION = 2000; CSRF_TOKEN = document.currentScript.dataset.csrftoken; // Add jQuery util for disabling selection // Originally taken from jquery-ui (where it is deprecated) // https://api.jqueryui.com/disableSelection/ $.fn.extend( { disableSelection: ( function() { var eventType = "onselectstart" in document.createElement( "div" ) ? "selectstart" : "mousedown"; return function() { return this.on( eventType + ".ui-disableSelection", function( event ) { event.preventDefault(); } ); }; } )(), enableSelection: function() { return this.off( ".ui-disableSelection" ); } } ); // This is the basic Node class, which handles UI tree operations for each 'row' var Node = function (elem) { var $elem = $(elem); var node_id = $elem.attr('node'); var parent_id = $elem.attr('parent'); var level = parseInt($elem.attr('level')); var children_num = parseInt($elem.attr('children-num')); return { elem: elem, $elem: $elem, node_id: node_id, parent_id: parent_id, level: level, has_children: function () { return children_num > 0; }, node_name: function () { // Returns the text of the node return $elem.find('th a:not(.collapse)').text(); }, is_collapsed: function () { return $elem.find('a.collapse').hasClass('collapsed'); }, children: function () { return $('tr[parent=' + node_id + ']'); }, collapse: function () { // For each children, hide it's children and so on... $.each(this.children(),function () { var node = new Node(this); node.collapse(); }).hide(); // Swicth class to set the property expand/collapse icon $elem.find('a.collapse').removeClass('expanded').addClass('collapsed'); }, parent_node: function () { // Returns a Node object of the parent return new Node($('tr[node=' + parent_id + ']', $elem.parent())[0]); }, expand: function () { // Display each kid (will display in collapsed state) this.children().show(); // Swicth class to set the property expand/collapse icon $elem.find('a.collapse').removeClass('collapsed').addClass('expanded'); }, toggle: function () { if (this.is_collapsed()) { this.expand(); } else { this.collapse(); } }, clone: function () { return $elem.clone(); } } }; $(document).ready(function () { $(document).ajaxSend(function (event, xhr, settings) { if (!(/^http:.*/.test(settings.url) || /^https:.*/.test(settings.url))) { // Only send the token to relative URLs i.e. locally. xhr.setRequestHeader("X-CSRFToken", CSRF_TOKEN); } }); // Don't activate drag or collapse if GET filters are set on the page if ($('#has-filters').val() === "1") { return; } $body = $('body'); // Activate all rows for drag & drop // then bind mouse down event $('td.drag-handler span').addClass('active').bind('mousedown', function (evt) { $ghost = $('
'); $drag_line = $('
'); $ghost.appendTo($body); $drag_line.appendTo($body); var stop_drag = function () { $ghost.remove(); $drag_line.remove(); $body.enableSelection().unbind('mousemove').unbind('mouseup'); node.elem.removeAttribute('style'); }; // Create a clone create the illusion that we're moving the node var node = new Node($(this).closest('tr')[0]); cloned_node = node.clone(); node.$elem.css({ 'background': ACTIVE_NODE_BG_COLOR }); $targetRow = null; as_child = false; // Now make the new clone move with the mouse $body.disableSelection().bind('mousemove',function (evt2) { $ghost.html(cloned_node).css({ // from FeinCMS :P 'opacity': .8, 'position': 'absolute', 'top': evt2.pageY, 'left': evt2.pageX - 30, 'width': 600 }); // Iterate through all rows and see where am I moving so I can place // the drag line accordingly rowHeight = node.$elem.height(); $('tr', node.$elem.parent()).each(function (index, element) { $row = $(element); rtop = $row.offset().top; // The tooltip will display whether I'm dropping the element as // child or sibling $tooltip = $drag_line.find('span'); $tooltip.css({ 'left': node.$elem.width() - $tooltip.width(), 'height': rowHeight, }); node_top = node.$elem.offset().top; // Check if you are dragging over the same node if (evt2.pageY >= node_top && evt2.pageY <= node_top + rowHeight) { $targetRow = null; $tooltip.text(gettext('Abort')); $drag_line.css({ 'top': node_top, 'height': rowHeight, 'borderWidth': 0, 'opacity': 0.8, 'backgroundColor': ABORT_COLOR }); } else // Check if mouse is over this row if (evt2.pageY >= rtop && evt2.pageY <= rtop + rowHeight / 2) { // The mouse is positioned on the top half of a $row $targetRow = $row; as_child = false; $drag_line.css({ 'left': node.$elem.offset().left, 'width': node.$elem.width(), 'top': rtop, 'borderWidth': '5px', 'height': 0, 'opacity': 1 }); $tooltip.text(gettext('As Sibling')); } else if (evt2.pageY >= rtop + rowHeight / 2 && evt2.pageY <= rtop + rowHeight) { // The mouse is positioned on the bottom half of a row $targetRow = $row; target_node = new Node($targetRow[0]); if (target_node.is_collapsed()) { target_node.expand(); } as_child = true; $drag_line.css({ 'top': rtop, 'left': node.$elem.offset().left, 'height': rowHeight, 'opacity': 0.4, 'width': node.$elem.width(), 'borderWidth': 0, 'backgroundColor': DRAG_LINE_COLOR }); $tooltip.text(gettext('As child')); } }); }).bind('mouseup',function () { if ($targetRow !== null) { target_node = new Node($targetRow[0]); if (target_node.node_id !== node.node_id) { /*alert('Insert node ' + node.node_name() + ' as child of: ' + target_node.parent_node().node_name() + '\n and sibling of: ' + target_node.node_name());*/ // Call $.ajax so we can handle the error // On Drop, make an XHR call to perform the node move $.ajax({ url: MOVE_NODE_ENDPOINT, type: 'POST', data: { node_id: node.node_id, parent_id: target_node.parent_id, sibling_id: target_node.node_id, as_child: as_child ? 1 : 0 }, complete: function (req, status) { // http://stackoverflow.com/questions/1439895/add-a-hash-with-javascript-to-url-without-scrolling-page/1439910#1439910 node.$elem.remove(); window.location.hash = 'node-' + node.node_id; window.location.reload(); }, error: function (req, status, error) { // On error (!200) also reload to display // the message node.$elem.remove(); window.location.hash = 'node-' + node.node_id; window.location.reload(); } }); } } stop_drag(); }).bind('keyup', function (kbevt) { // Cancel drag on escape if (kbevt.keyCode === 27) { stop_drag(); } }); }); $('a.collapse').click(function () { var node = new Node($(this).closest('tr')[0]); // send the DOM node, not jQ node.toggle(); return false; }); var hash = window.location.hash; // This is a hack, the actual element's id ends in '-id' but the url's hash // doesn't, I'm doing this to avoid scrolling the page... is that a good thing? if (hash) { $(hash + '-id').animate({ backgroundColor: RECENTLY_MOVED_COLOR }, RECENTLY_FADE_DURATION, function () { $(this).animate({ backgroundColor: RECENTLY_MOVED_FADEOUT }, RECENTLY_FADE_DURATION, function () { this.removeAttribute('style'); }); }); } }); })(django.jQuery); // http://stackoverflow.com/questions/190560/jquery-animate-backgroundcolor/2302005#2302005 (function (d) { d.each(["backgroundColor", "borderBottomColor", "borderLeftColor", "borderRightColor", "borderTopColor", "color", "outlineColor"], function (f, e) { d.fx.step[e] = function (g) { if (!g.colorInit) { g.start = c(g.elem, e); g.end = b(g.end); g.colorInit = true } g.elem.style[e] = "rgb(" + [Math.max(Math.min(parseInt((g.pos * (g.end[0] - g.start[0])) + g.start[0]), 255), 0), Math.max(Math.min(parseInt((g.pos * (g.end[1] - g.start[1])) + g.start[1]), 255), 0), Math.max(Math.min(parseInt((g.pos * (g.end[2] - g.start[2])) + g.start[2]), 255), 0)].join(",") + ")" } }); function b(f) { var e; if (f && f.constructor == Array && f.length == 3) { return f } if (e = /rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(f)) { return[parseInt(e[1]), parseInt(e[2]), parseInt(e[3])] } if (e = /rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(f)) { return[parseFloat(e[1]) * 2.55, parseFloat(e[2]) * 2.55, parseFloat(e[3]) * 2.55] } if (e = /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(f)) { return[parseInt(e[1], 16), parseInt(e[2], 16), parseInt(e[3], 16)] } if (e = /#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(f)) { return[parseInt(e[1] + e[1], 16), parseInt(e[2] + e[2], 16), parseInt(e[3] + e[3], 16)] } if (e = /rgba\(0, 0, 0, 0\)/.exec(f)) { return a.transparent } return a[d.trim(f).toLowerCase()] } function c(g, e) { var f; do { f = d.css(g, e); if (f != "" && f != "transparent" || d.nodeName(g, "body")) { break } e = "backgroundColor" } while (g = g.parentNode); return b(f) } var a = {aqua: [0, 255, 255], azure: [240, 255, 255], beige: [245, 245, 220], black: [0, 0, 0], blue: [0, 0, 255], brown: [165, 42, 42], cyan: [0, 255, 255], darkblue: [0, 0, 139], darkcyan: [0, 139, 139], darkgrey: [169, 169, 169], darkgreen: [0, 100, 0], darkkhaki: [189, 183, 107], darkmagenta: [139, 0, 139], darkolivegreen: [85, 107, 47], darkorange: [255, 140, 0], darkorchid: [153, 50, 204], darkred: [139, 0, 0], darksalmon: [233, 150, 122], darkviolet: [148, 0, 211], fuchsia: [255, 0, 255], gold: [255, 215, 0], green: [0, 128, 0], indigo: [75, 0, 130], khaki: [240, 230, 140], lightblue: [173, 216, 230], lightcyan: [224, 255, 255], lightgreen: [144, 238, 144], lightgrey: [211, 211, 211], lightpink: [255, 182, 193], lightyellow: [255, 255, 224], lime: [0, 255, 0], magenta: [255, 0, 255], maroon: [128, 0, 0], navy: [0, 0, 128], olive: [128, 128, 0], orange: [255, 165, 0], pink: [255, 192, 203], purple: [128, 0, 128], violet: [128, 0, 128], red: [255, 0, 0], silver: [192, 192, 192], white: [255, 255, 255], yellow: [255, 255, 0], transparent: [255, 255, 255]} })(django.jQuery); ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.8906186 django-treebeard-4.7.1/treebeard/templates/0000755000175100001770000000000014556473276020303 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9066186 django-treebeard-4.7.1/treebeard/templates/admin/0000755000175100001770000000000014556473276021373 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/templates/admin/tree_change_list.html0000644000175100001770000000142614556473264025560 0ustar00runnerdocker{# Used for MP and NS trees #} {% extends "admin/change_list.html" %} {% load admin_list admin_tree static %} {% block extrastyle %} {{ block.super }} {% endblock %} {% block extrahead %} {{ block.super }} {% endblock %} {% block result_list %} {% if action_form and actions_on_top and cl.full_result_count %} {% admin_actions %} {% endif %} {% result_tree cl request %} {% if action_form and actions_on_bottom and cl.full_result_count %} {% admin_actions %} {% endif %} {% endblock %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/templates/admin/tree_change_list_results.html0000644000175100001770000000312014556473264027332 0ustar00runnerdocker{% if result_hidden_fields %}
{# DIV for HTML validation #} {% for item in result_hidden_fields %}{{ item }}{% endfor %}
{% endif %} {% if results %}
{% for header in result_headers %} {% if header.sortable %}{% endif %} {{ header.text|capfirst }} {% if header.sortable %}{% endif %}{% endfor %} {% for node_id, parent_id, node_level, children_num, result in results %} {% for item in result %} {% if forloop.counter == 1 %} {% for spacer in item.depth %}  {% endfor %} {% endif %} {{ item }} {% endfor %} {% endfor %}
{% endif %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/templates/admin/tree_list.html0000644000175100001770000000102114556473264024242 0ustar00runnerdocker{# Used for AL trees #} {% extends "admin/change_list.html" %} {% load admin_list admin_tree_list i18n %} {% block extrastyle %} {{ block.super }} {% endblock %} {% block extrahead %} {{ block.super }} {% endblock %} {% block result_list %} {% if action_form and actions_on_top and cl.full_result_count %} {% admin_actions %} {% endif %} {% result_tree cl request %} {% if action_form and actions_on_bottom and cl.full_result_count %} {% admin_actions %} {% endif %} {% endblock %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/templates/admin/tree_list_results.html0000644000175100001770000000025514556473264026033 0ustar00runnerdocker{% if results %}
    {% for result in results %}
  • {{ result }}
  • {% endfor %}
{% endif %} ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9066186 django-treebeard-4.7.1/treebeard/templatetags/0000755000175100001770000000000014556473276020777 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/templatetags/__init__.py0000644000175100001770000000040314556473264023102 0ustar00runnerdockerfrom django.template import Variable, VariableDoesNotExist action_form_var = Variable('action_form') def needs_checkboxes(context): try: return action_form_var.resolve(context) is not None except VariableDoesNotExist: return False ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/templatetags/admin_tree.py0000644000175100001770000001756414556473264023472 0ustar00runnerdocker""" Templatetags for django-treebeard to add drag and drop capabilities to the nodes change list - @jjdelc """ import datetime from django.db import models from django.contrib.admin.templatetags.admin_list import ( result_headers, result_hidden_fields) from django.contrib.admin.utils import ( lookup_field, display_for_field, display_for_value) from django.core.exceptions import ObjectDoesNotExist from django.template import Library from django.utils.encoding import force_str from django.utils.html import conditional_escape, format_html from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from treebeard.templatetags import needs_checkboxes register = Library() def get_result_and_row_class(cl, field_name, result): empty_value_display = cl.model_admin.get_empty_value_display() row_classes = ['field-%s' % field_name] try: f, attr, value = lookup_field(field_name, result, cl.model_admin) except ObjectDoesNotExist: result_repr = empty_value_display else: empty_value_display = getattr(attr, 'empty_value_display', empty_value_display) if f is None: if field_name == 'action_checkbox': row_classes = ['action-checkbox'] allow_tags = getattr(attr, 'allow_tags', False) boolean = getattr(attr, 'boolean', False) result_repr = display_for_value(value, empty_value_display, boolean) # Strip HTML tags in the resulting text, except if the # function has an "allow_tags" attribute set to True. # WARNING: this will be deprecated in Django 2.0 if allow_tags: result_repr = mark_safe(result_repr) if isinstance(value, (datetime.date, datetime.time)): row_classes.append('nowrap') else: if isinstance(getattr(f, 'remote_field'), models.ManyToOneRel): field_val = getattr(result, f.name) if field_val is None: result_repr = empty_value_display else: result_repr = field_val else: result_repr = display_for_field(value, f, empty_value_display) if isinstance(f, (models.DateField, models.TimeField, models.ForeignKey)): row_classes.append('nowrap') if force_str(result_repr) == '': result_repr = mark_safe(' ') row_class = mark_safe(' class="%s"' % ' '.join(row_classes)) return result_repr, row_class def get_spacer(first, result): if first: spacer = ' ' * ( result.get_depth() - 1) else: spacer = '' return spacer def get_collapse(result): if result.get_children_count(): collapse = ('' '-') else: collapse = ' ' return collapse def get_drag_handler(first): drag_handler = '' if first: drag_handler = ('' ' ') return drag_handler def items_for_result(cl, result, form): """ Generates the actual list of data. @jjdelc: This has been shamelessly copied from original django.contrib.admin.templatetags.admin_list.items_for_result in order to alter the dispay for the first element """ first = True pk = cl.lookup_opts.pk.attname for field_name in cl.list_display: result_repr, row_class = get_result_and_row_class(cl, field_name, result) # If list_display_links not defined, add the link tag to the # first field if (first and not cl.list_display_links) or \ field_name in cl.list_display_links: table_tag = {True: 'th', False: 'td'}[first] # This spacer indents the nodes based on their depth spacer = get_spacer(first, result) # This shows a collapse or expand link for nodes with childs collapse = get_collapse(result) # Add a before the first col to show the drag handler drag_handler = get_drag_handler(first) first = False url = cl.url_for_result(result) # Convert the pk to something that can be used in Javascript. # Problem cases are long ints (23L) and non-ASCII strings. if cl.to_field: attr = str(cl.to_field) else: attr = pk value = result.serializable_value(attr) result_id = "'%s'" % force_str(value) onclickstr = ( ' onclick="opener.dismissRelatedLookupPopup(window, %s);' ' return false;"') yield mark_safe( '%s<%s%s>%s %s %s' % ( drag_handler, table_tag, row_class, spacer, collapse, url, (cl.is_popup and onclickstr % result_id or ''), conditional_escape(result_repr), table_tag)) else: # By default the fields come from ModelAdmin.list_editable, but if # we pull the fields out of the form instead of list_editable # custom admins can provide fields on a per request basis if ( form and field_name in form.fields and not ( field_name == cl.model._meta.pk.name and form[cl.model._meta.pk.name].is_hidden ) ): bf = form[field_name] result_repr = mark_safe(force_str(bf.errors) + force_str(bf)) yield format_html('{1}', row_class, result_repr) if form and not form[cl.model._meta.pk.name].is_hidden: yield format_html('{0}', force_str(form[cl.model._meta.pk.name])) def get_parent_id(node): """Return the node's parent id or 0 if node is a root node.""" if node.is_root(): return 0 return node.get_parent().pk def results(cl): if cl.formset: for res, form in zip(cl.result_list, cl.formset.forms): yield (res.pk, get_parent_id(res), res.get_depth(), res.get_children_count(), list(items_for_result(cl, res, form))) else: for res in cl.result_list: yield (res.pk, get_parent_id(res), res.get_depth(), res.get_children_count(), list(items_for_result(cl, res, None))) def check_empty_dict(GET_dict): """ Returns True if the GET query string contains on values, but it can contain empty keys. This is better than doing not bool(request.GET) as an empty key will return True """ empty = True for k, v in GET_dict.items(): # Don't disable on p(age) or 'all' GET param if v and k != 'p' and k != 'all': empty = False return empty @register.inclusion_tag( 'admin/tree_change_list_results.html', takes_context=True) def result_tree(context, cl, request): """ Added 'filtered' param, so the template's js knows whether the results have been affected by a GET param or not. Only when the results are not filtered you can drag and sort the tree """ # Here I'm adding an extra col on pos 2 for the drag handlers headers = list(result_headers(cl)) headers.insert(1 if needs_checkboxes(context) else 0, { 'text': '+', 'sortable': True, 'url': request.path, 'tooltip': _('Return to ordered tree'), 'class_attrib': mark_safe(' class="oder-grabber"') }) return { 'filtered': not check_empty_dict(request.GET), 'result_hidden_fields': list(result_hidden_fields(cl)), 'result_headers': headers, 'results': list(results(cl)), } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/templatetags/admin_tree_list.py0000644000175100001770000000305514556473264024513 0ustar00runnerdockerfrom django.template import Library from django.utils.html import format_html from django.utils.safestring import mark_safe from django.contrib.admin.options import TO_FIELD_VAR from treebeard.templatetags import needs_checkboxes register = Library() CHECKBOX_TMPL = ('') def _line(context, node, request): pk_field = node._meta.model._meta.pk.attname if TO_FIELD_VAR in request.GET and request.GET[TO_FIELD_VAR] == pk_field: raw_id_fields = format_html(""" onclick="opener.dismissRelatedLookupPopup(window, '{}'); return false;" """, node.pk) else: raw_id_fields = '' output = '' if needs_checkboxes(context): output += format_html(CHECKBOX_TMPL, node.pk) return output + format_html( '{}', node.pk, mark_safe(raw_id_fields), str(node)) def _subtree(context, node, request): tree = '' for subnode in node.get_children(): tree += format_html( '
  • {}
  • ', mark_safe(_subtree(context, subnode, request))) if tree: tree = format_html('
      {}
    ', mark_safe(tree)) return _line(context, node, request) + tree @register.simple_tag(takes_context=True) def result_tree(context, cl, request): tree = '' for root_node in cl.model.get_root_nodes(): tree += format_html( '
  • {}
  • ', mark_safe(_subtree(context, root_node, request))) return format_html("
      {}
    ", mark_safe(tree)) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9066186 django-treebeard-4.7.1/treebeard/tests/0000755000175100001770000000000014556473276017447 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/tests/__init__.py0000644000175100001770000000000014556473264021543 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/tests/admin.py0000644000175100001770000000103414556473264021104 0ustar00runnerdockerimport itertools from django.contrib import admin from treebeard.admin import admin_factory from treebeard.forms import movenodeform_factory from treebeard.tests.models import BASE_MODELS, UNICODE_MODELS, DEP_MODELS def register(admin_site, model): form_class = movenodeform_factory(model) admin_class = admin_factory(form_class) admin_site.register(model, admin_class) def register_all(admin_site=admin.site): for model in itertools.chain(BASE_MODELS, UNICODE_MODELS, DEP_MODELS): register(admin_site, model) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/tests/conftest.py0000644000175100001770000000040114556473264021636 0ustar00runnerdocker"""Pytest configuration file """ import os os.environ["DJANGO_SETTINGS_MODULE"] = "treebeard.tests.settings" import django def pytest_report_header(config): return "Django: " + django.get_version() def pytest_configure(config): django.setup() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/tests/manage.py0000644000175100001770000000040214556473264021242 0ustar00runnerdocker#!/usr/bin/env python import os import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "treebeard.tests.settings") from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1706718909.9066186 django-treebeard-4.7.1/treebeard/tests/migrations/0000755000175100001770000000000014556473276021623 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/tests/migrations/0001_initial.py0000644000175100001770000004007014556473264024264 0ustar00runnerdocker# Generated by Django 3.1.2 on 2021-02-24 20:44 from django.conf import settings from django.db import migrations, models import django.db.models.deletion import uuid class Migration(migrations.Migration): initial = True dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='AL_TestNode', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('sib_order', models.PositiveIntegerField()), ('desc', models.CharField(max_length=255)), ('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children_set', to='tests.al_testnode')), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='MP_TestNode', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('path', models.CharField(max_length=255, unique=True)), ('depth', models.PositiveIntegerField()), ('numchild', models.PositiveIntegerField(default=0)), ('desc', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='MP_TestNodeAlphabet', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('path', models.CharField(max_length=255, unique=True)), ('depth', models.PositiveIntegerField()), ('numchild', models.PositiveIntegerField(default=0)), ('numval', models.IntegerField()), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='MP_TestNodeCustomId', fields=[ ('path', models.CharField(max_length=255, unique=True)), ('depth', models.PositiveIntegerField()), ('numchild', models.PositiveIntegerField(default=0)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('desc', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='MP_TestNodeShortPath', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('path', models.CharField(max_length=4, unique=True)), ('depth', models.PositiveIntegerField()), ('numchild', models.PositiveIntegerField(default=0)), ('desc', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='MP_TestNodeSmallStep', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('path', models.CharField(max_length=255, unique=True)), ('depth', models.PositiveIntegerField()), ('numchild', models.PositiveIntegerField(default=0)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='MP_TestNodeSorted', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('path', models.CharField(max_length=255, unique=True)), ('depth', models.PositiveIntegerField()), ('numchild', models.PositiveIntegerField(default=0)), ('val1', models.IntegerField()), ('val2', models.IntegerField()), ('desc', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='MP_TestNodeSortedAutoNow', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('path', models.CharField(max_length=255, unique=True)), ('depth', models.PositiveIntegerField()), ('numchild', models.PositiveIntegerField(default=0)), ('desc', models.CharField(max_length=255)), ('created', models.DateTimeField(auto_now_add=True)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='MP_TestNodeUuid', fields=[ ('path', models.CharField(max_length=255, unique=True)), ('depth', models.PositiveIntegerField()), ('numchild', models.PositiveIntegerField(default=0)), ('custom_id', models.UUIDField(default=uuid.uuid1, editable=False, primary_key=True, serialize=False)), ('desc', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='MP_TestSortedNodeShortPath', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('path', models.CharField(max_length=4, unique=True)), ('depth', models.PositiveIntegerField()), ('numchild', models.PositiveIntegerField(default=0)), ('desc', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='MP_UnicodeNode', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('path', models.CharField(max_length=255, unique=True)), ('depth', models.PositiveIntegerField()), ('numchild', models.PositiveIntegerField(default=0)), ('desc', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='NS_TestNode', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('lft', models.PositiveIntegerField(db_index=True)), ('rgt', models.PositiveIntegerField(db_index=True)), ('tree_id', models.PositiveIntegerField(db_index=True)), ('depth', models.PositiveIntegerField(db_index=True)), ('desc', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='NS_TestNodeSorted', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('lft', models.PositiveIntegerField(db_index=True)), ('rgt', models.PositiveIntegerField(db_index=True)), ('tree_id', models.PositiveIntegerField(db_index=True)), ('depth', models.PositiveIntegerField(db_index=True)), ('val1', models.IntegerField()), ('val2', models.IntegerField()), ('desc', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='NS_UnicodetNode', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('lft', models.PositiveIntegerField(db_index=True)), ('rgt', models.PositiveIntegerField(db_index=True)), ('tree_id', models.PositiveIntegerField(db_index=True)), ('depth', models.PositiveIntegerField(db_index=True)), ('desc', models.CharField(max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='RelatedModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('desc', models.CharField(max_length=255)), ], ), migrations.CreateModel( name='AL_TestNodeInherited', fields=[ ('al_testnode_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.al_testnode')), ('extra_desc', models.CharField(max_length=255)), ], options={ 'abstract': False, }, bases=('tests.al_testnode',), ), migrations.CreateModel( name='MP_TestNodeInherited', fields=[ ('mp_testnode_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.mp_testnode')), ('extra_desc', models.CharField(max_length=255)), ], options={ 'abstract': False, }, bases=('tests.mp_testnode',), ), migrations.CreateModel( name='NS_TestNodeInherited', fields=[ ('ns_testnode_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.ns_testnode')), ('extra_desc', models.CharField(max_length=255)), ], options={ 'abstract': False, }, bases=('tests.ns_testnode',), ), migrations.CreateModel( name='NS_TestNodeSomeDep', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('node', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.ns_testnode')), ], ), migrations.CreateModel( name='NS_TestNodeRelated', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('lft', models.PositiveIntegerField(db_index=True)), ('rgt', models.PositiveIntegerField(db_index=True)), ('tree_id', models.PositiveIntegerField(db_index=True)), ('depth', models.PositiveIntegerField(db_index=True)), ('desc', models.CharField(max_length=255)), ('related', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.relatedmodel')), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='MP_TestNodeSomeDep', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('node', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.mp_testnode')), ], ), migrations.CreateModel( name='MP_TestNodeRelated', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('path', models.CharField(max_length=255, unique=True)), ('depth', models.PositiveIntegerField()), ('numchild', models.PositiveIntegerField(default=0)), ('desc', models.CharField(max_length=255)), ('related', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.relatedmodel')), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='MP_TestManyToManyWithUser', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('path', models.CharField(max_length=255, unique=True)), ('depth', models.PositiveIntegerField()), ('numchild', models.PositiveIntegerField(default=0)), ('name', models.CharField(max_length=255)), ('users', models.ManyToManyField(to=settings.AUTH_USER_MODEL)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='AL_UnicodeNode', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('sib_order', models.PositiveIntegerField()), ('desc', models.CharField(max_length=255)), ('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children_set', to='tests.al_unicodenode')), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='AL_TestNodeSorted', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('val1', models.IntegerField()), ('val2', models.IntegerField()), ('desc', models.CharField(max_length=255)), ('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children_set', to='tests.al_testnodesorted')), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='AL_TestNodeSomeDep', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('node', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.al_testnode')), ], ), migrations.CreateModel( name='AL_TestNodeRelated', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('sib_order', models.PositiveIntegerField()), ('desc', models.CharField(max_length=255)), ('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children_set', to='tests.al_testnoderelated')), ('related', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.relatedmodel')), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='AL_TestNode_Proxy', fields=[ ], options={ 'proxy': True, 'indexes': [], 'constraints': [], }, bases=('tests.al_testnode',), ), migrations.CreateModel( name='MP_TestNode_Proxy', fields=[ ], options={ 'proxy': True, 'indexes': [], 'constraints': [], }, bases=('tests.mp_testnode',), ), migrations.CreateModel( name='NS_TestNode_Proxy', fields=[ ], options={ 'proxy': True, 'indexes': [], 'constraints': [], }, bases=('tests.ns_testnode',), ), ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/tests/migrations/__init__.py0000644000175100001770000000000014556473264023717 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/tests/models.py0000644000175100001770000001635014556473264021306 0ustar00runnerdockerimport uuid from django.db import models from django.contrib.auth.models import User from treebeard.mp_tree import MP_Node from treebeard.al_tree import AL_Node from treebeard.ns_tree import NS_Node class RelatedModel(models.Model): desc = models.CharField(max_length=255) def __str__(self): return self.desc class MP_TestNode(MP_Node): steplen = 3 desc = models.CharField(max_length=255) def __str__(self): # pragma: no cover return "Node %d" % self.pk class MP_UnicodeNode(MP_Node): steplen = 3 desc = models.CharField(max_length=255) def __str__(self): # pragma: no cover return self.desc class MP_TestNodeSomeDep(models.Model): node = models.ForeignKey(MP_TestNode, on_delete=models.CASCADE) def __str__(self): # pragma: no cover return "Node %d" % self.pk class MP_TestNodeRelated(MP_Node): steplen = 3 desc = models.CharField(max_length=255) related = models.ForeignKey(RelatedModel, on_delete=models.CASCADE) def __str__(self): # pragma: no cover return "Node %d" % self.pk class MP_TestNodeInherited(MP_TestNode): extra_desc = models.CharField(max_length=255) class MP_TestNodeCustomId(MP_Node): steplen = 3 id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) desc = models.CharField(max_length=255) def __str__(self): # pragma: no cover return "Node %d" % self.pk class NS_TestNode(NS_Node): desc = models.CharField(max_length=255) def __str__(self): # pragma: no cover return "Node %d" % self.pk class NS_UnicodetNode(NS_Node): desc = models.CharField(max_length=255) def __str__(self): # pragma: no cover return self.desc class NS_TestNodeSomeDep(models.Model): node = models.ForeignKey(NS_TestNode, on_delete=models.CASCADE) def __str__(self): # pragma: no cover return "Node %d" % self.pk class NS_TestNodeRelated(NS_Node): desc = models.CharField(max_length=255) related = models.ForeignKey(RelatedModel, on_delete=models.CASCADE) def __str__(self): # pragma: no cover return "Node %d" % self.pk class NS_TestNodeInherited(NS_TestNode): extra_desc = models.CharField(max_length=255) class AL_TestNode(AL_Node): parent = models.ForeignKey( "self", related_name="children_set", null=True, db_index=True, on_delete=models.CASCADE, ) sib_order = models.PositiveIntegerField() desc = models.CharField(max_length=255) def __str__(self): # pragma: no cover return "Node %d" % self.pk class AL_UnicodeNode(AL_Node): parent = models.ForeignKey( "self", related_name="children_set", null=True, db_index=True, on_delete=models.CASCADE, ) sib_order = models.PositiveIntegerField() desc = models.CharField(max_length=255) def __str__(self): # pragma: no cover return self.desc class AL_TestNodeSomeDep(models.Model): node = models.ForeignKey(AL_TestNode, on_delete=models.CASCADE) def __str__(self): # pragma: no cover return "Node %d" % self.pk class AL_TestNodeRelated(AL_Node): parent = models.ForeignKey( "self", related_name="children_set", null=True, db_index=True, on_delete=models.CASCADE, ) sib_order = models.PositiveIntegerField() desc = models.CharField(max_length=255) related = models.ForeignKey(RelatedModel, on_delete=models.CASCADE) def __str__(self): # pragma: no cover return "Node %d" % self.pk class AL_TestNodeInherited(AL_TestNode): extra_desc = models.CharField(max_length=255) class MP_TestNodeSorted(MP_Node): steplen = 1 node_order_by = ["val1", "val2", "desc"] val1 = models.IntegerField() val2 = models.IntegerField() desc = models.CharField(max_length=255) def __str__(self): # pragma: no cover return "Node %d" % self.pk class NS_TestNodeSorted(NS_Node): node_order_by = ["val1", "val2", "desc"] val1 = models.IntegerField() val2 = models.IntegerField() desc = models.CharField(max_length=255) def __str__(self): # pragma: no cover return "Node %d" % self.pk class AL_TestNodeSorted(AL_Node): parent = models.ForeignKey( "self", related_name="children_set", null=True, db_index=True, on_delete=models.CASCADE, ) node_order_by = ["val1", "val2", "desc"] val1 = models.IntegerField() val2 = models.IntegerField() desc = models.CharField(max_length=255) def __str__(self): # pragma: no cover return "Node %d" % self.pk class MP_TestNodeAlphabet(MP_Node): steplen = 2 numval = models.IntegerField() def __str__(self): # pragma: no cover return "Node %d" % self.pk class MP_TestNodeSmallStep(MP_Node): steplen = 1 alphabet = "0123456789" def __str__(self): # pragma: no cover return "Node %d" % self.pk class MP_TestNodeSortedAutoNow(MP_Node): desc = models.CharField(max_length=255) created = models.DateTimeField(auto_now_add=True) node_order_by = ["created"] def __str__(self): # pragma: no cover return "Node %d" % self.pk class MP_TestNodeShortPath(MP_Node): steplen = 1 alphabet = "012345678" desc = models.CharField(max_length=255) def __str__(self): # pragma: no cover return "Node %d" % self.pk class MP_TestNodeUuid(MP_Node): steplen = 1 custom_id = models.UUIDField(primary_key=True, default=uuid.uuid1, editable=False) desc = models.CharField(max_length=255) def __str__(self): # pragma: no cover return "Node %s" % self.pk # This is how you change the default fields defined in a Django abstract class # (in this case, MP_Node), since Django doesn't allow overriding fields, only # mehods and attributes MP_TestNodeShortPath._meta.get_field("path").max_length = 4 class MP_TestNode_Proxy(MP_TestNode): class Meta: proxy = True class NS_TestNode_Proxy(NS_TestNode): class Meta: proxy = True class AL_TestNode_Proxy(AL_TestNode): class Meta: proxy = True class MP_TestSortedNodeShortPath(MP_Node): steplen = 1 alphabet = "012345678" desc = models.CharField(max_length=255) node_order_by = ["desc"] def __str__(self): # pragma: no cover return "Node %d" % self.pk MP_TestSortedNodeShortPath._meta.get_field("path").max_length = 4 class MP_TestManyToManyWithUser(MP_Node): name = models.CharField(max_length=255) users = models.ManyToManyField(User) BASE_MODELS = ( AL_TestNode, MP_TestNode, NS_TestNode, MP_TestNodeUuid, MP_TestNodeCustomId, ) PROXY_MODELS = AL_TestNode_Proxy, MP_TestNode_Proxy, NS_TestNode_Proxy SORTED_MODELS = AL_TestNodeSorted, MP_TestNodeSorted, NS_TestNodeSorted DEP_MODELS = AL_TestNodeSomeDep, MP_TestNodeSomeDep, NS_TestNodeSomeDep MP_SHORTPATH_MODELS = MP_TestNodeShortPath, MP_TestSortedNodeShortPath RELATED_MODELS = AL_TestNodeRelated, MP_TestNodeRelated, NS_TestNodeRelated UNICODE_MODELS = AL_UnicodeNode, MP_UnicodeNode, NS_UnicodetNode INHERITED_MODELS = (AL_TestNodeInherited, MP_TestNodeInherited, NS_TestNodeInherited) def empty_models_tables(models): for model in models: model.objects.all().delete() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/tests/settings.py0000644000175100001770000000554114556473264021663 0ustar00runnerdocker""" Django settings for testing treebeard """ import os def get_db_conf(): """ Configures database according to the DATABASE_ENGINE environment variable. Defaults to SQlite. This method is used to run tests against different database backends. """ database_engine = os.environ.get('DATABASE_ENGINE', 'sqlite') if database_engine == 'sqlite': return { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:' } elif database_engine == 'psql': return { 'ENGINE': 'django.db.backends.postgresql', 'NAME': 'treebeard', 'USER': os.environ.get('DATABASE_USER_POSTGRES', 'treebeard'), 'PASSWORD': os.environ.get('DATABASE_PASSWORD', ''), 'HOST': os.environ.get('DATABASE_HOST', 'localhost'), 'PORT': os.environ.get('DATABASE_PORT_POSTGRES', ''), } elif database_engine == "mysql": return { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'treebeard', 'USER': os.environ.get('DATABASE_USER_MYSQL', 'treebeard'), 'PASSWORD': os.environ.get('DATABASE_PASSWORD', ''), 'HOST': os.environ.get('DATABASE_HOST', 'localhost'), 'PORT': os.environ.get('DATABASE_PORT_MYSQL', ''), } elif database_engine == "mssql": return { 'ENGINE': 'mssql', 'NAME': 'master', 'USER': 'sa', 'PASSWORD': 'Password12!', 'HOST': '(local)\\SQL2019', 'PORT': '', 'OPTIONS': { 'driver': 'SQL Server Native Client 11.0', }, } DATABASES = {'default': get_db_conf()} SECRET_KEY = '7r33b34rd' INSTALLED_APPS = [ 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.admin', 'django.contrib.messages', 'treebeard', 'treebeard.tests' ] MIDDLEWARE = [ 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware' ] ROOT_URLCONF = 'treebeard.tests.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.contrib.auth.context_processors.auth', 'django.template.context_processors.debug', 'django.template.context_processors.i18n', 'django.template.context_processors.media', 'django.template.context_processors.static', 'django.template.context_processors.tz', 'django.template.context_processors.request', 'django.contrib.messages.context_processors.messages', ], }, }, ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/tests/test_migrations.py0000644000175100001770000000420714556473264023234 0ustar00runnerdocker""" Check that all changes to Treebeard models have had migrations created in our test app. If there are outstanding model changes that need migrations, fail the tests. This module is taken from https://github.com/wagtail/wagtail/blob/master/wagtail/core/tests/test_migrations.py. """ from django.apps import apps from django.db.migrations.autodetector import MigrationAutodetector from django.db.migrations.loader import MigrationLoader from django.db.migrations.questioner import MigrationQuestioner from django.db.migrations.state import ProjectState from django.test import TestCase class TestForMigrations(TestCase): def test__migrations(self): app_labels = set(app.label for app in apps.get_app_configs() if app.name.startswith('tests.')) for app_label in app_labels: apps.get_app_config(app_label.split('.')[-1]) loader = MigrationLoader(None, ignore_no_migrations=True) conflicts = dict( (app_label, conflict) for app_label, conflict in loader.detect_conflicts().items() if app_label in app_labels ) if conflicts: name_str = "; ".join("%s in %s" % (", ".join(names), app) for app, names in conflicts.items()) self.fail("Conflicting migrations detected (%s)." % name_str) autodetector = MigrationAutodetector( loader.project_state(), ProjectState.from_apps(apps), MigrationQuestioner(specified_apps=app_labels, dry_run=True), ) changes = autodetector.changes( graph=loader.graph, trim_to_apps=app_labels or None, convert_apps=app_labels or None, ) if changes: migrations = '\n'.join(( ' {migration}\n{changes}'.format( migration=migration, changes='\n'.join(' {0}'.format(operation.describe()) for operation in migration.operations)) for (_, migrations) in changes.items() for migration in migrations)) self.fail('Model changes with no migrations detected:\n%s' % migrations) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1706718900.0 django-treebeard-4.7.1/treebeard/tests/test_treebeard.py0000644000175100001770000032031314556473264023014 0ustar00runnerdocker"""Unit/Functional tests""" import datetime import os from django.contrib.admin.sites import AdminSite from django.contrib.admin.views.main import ChangeList from django.contrib.auth.models import User, AnonymousUser from django.contrib.messages.storage.fallback import FallbackStorage from django.db.models import Q from django.db.models.signals import post_save from django.dispatch import receiver from django.template import Template, Context from django.test import TestCase from django.test.client import RequestFactory from django.templatetags.static import static from django.contrib.admin.options import TO_FIELD_VAR from django import VERSION as DJANGO_VERSION import pytest from treebeard import numconv from treebeard.admin import admin_factory from treebeard.exceptions import ( InvalidPosition, InvalidMoveToDescendant, PathOverflow, MissingNodeOrderBy, NodeAlreadySaved, ) from treebeard.forms import movenodeform_factory from treebeard.tests import models from treebeard.tests.admin import register_all as admin_register_all admin_register_all() BASE_DATA = [ {"data": {"desc": "1"}}, { "data": {"desc": "2"}, "children": [ {"data": {"desc": "21"}}, {"data": {"desc": "22"}}, { "data": {"desc": "23"}, "children": [ {"data": {"desc": "231"}}, ], }, {"data": {"desc": "24"}}, ], }, {"data": {"desc": "3"}}, { "data": {"desc": "4"}, "children": [ {"data": {"desc": "41"}}, ], }, ] UNCHANGED = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] @pytest.fixture(scope="function", params=models.BASE_MODELS + models.PROXY_MODELS) def model(request): request.param.load_bulk(BASE_DATA) return request.param @pytest.fixture(scope="function", params=models.BASE_MODELS + models.PROXY_MODELS) def model_without_data(request): return request.param @pytest.fixture(scope="function", params=models.BASE_MODELS) def model_without_proxy(request): request.param.load_bulk(BASE_DATA) return request.param @pytest.fixture(scope="function", params=models.UNICODE_MODELS) def model_with_unicode(request): return request.param @pytest.fixture(scope="function", params=models.SORTED_MODELS) def sorted_model(request): return request.param @pytest.fixture(scope="function", params=models.RELATED_MODELS) def related_model(request): return request.param @pytest.fixture(scope="function", params=models.INHERITED_MODELS) def inherited_model(request): return request.param @pytest.fixture(scope="function", params=models.MP_SHORTPATH_MODELS) def mpshort_model(request): return request.param @pytest.fixture(scope="function", params=[models.MP_TestNodeShortPath]) def mpshortnotsorted_model(request): return request.param @pytest.fixture(scope="function", params=[models.MP_TestNodeAlphabet]) def mpalphabet_model(request): return request.param @pytest.fixture(scope="function", params=[models.MP_TestNodeSortedAutoNow]) def mpsortedautonow_model(request): return request.param @pytest.fixture(scope="function", params=[models.MP_TestNodeSmallStep]) def mpsmallstep_model(request): return request.param @pytest.fixture(scope="function", params=[models.MP_TestManyToManyWithUser]) def mpm2muser_model(request): return request.param # Compat helper, and be dropped after Django 3.2 is dropped def get_changelist_args(*args): new_args = list(args) if DJANGO_VERSION > (4,): new_args.append("") # New search_help_text arg return new_args class TestTreeBase: def got(self, model): if model in [models.NS_TestNode, models.NS_TestNode_Proxy]: # this slows down nested sets tests quite a bit, but it has the # advantage that we'll check the node edges are correct d = {} for tree_id, lft, rgt in model.objects.values_list("tree_id", "lft", "rgt"): d.setdefault(tree_id, []).extend([lft, rgt]) for tree_id, got_edges in d.items(): assert len(got_edges) == max(got_edges) good_edges = list(range(1, len(got_edges) + 1)) assert sorted(got_edges) == good_edges return [ (o.desc, o.get_depth(), o.get_children_count()) for o in model.get_tree() ] def _assert_get_annotated_list(self, model, expected, parent=None): results = model.get_annotated_list(parent) got = [ (obj[0].desc, obj[1]["open"], obj[1]["close"], obj[1]["level"]) for obj in results ] assert expected == got assert all([type(obj[0]) == model for obj in results]) @pytest.mark.django_db class TestEmptyTree(TestTreeBase): def test_load_bulk_empty(self, model_without_data): ids = model_without_data.load_bulk(BASE_DATA) got_descs = [obj.desc for obj in model_without_data.objects.filter(pk__in=ids)] expected_descs = [x[0] for x in UNCHANGED] assert sorted(got_descs) == sorted(expected_descs) assert self.got(model_without_data) == UNCHANGED def test_dump_bulk_empty(self, model_without_data): assert model_without_data.dump_bulk() == [] def test_add_root_empty(self, model_without_data): model_without_data.add_root(desc="1") expected = [("1", 1, 0)] assert self.got(model_without_data) == expected def test_get_root_nodes_empty(self, model_without_data): got = model_without_data.get_root_nodes() expected = [] assert [node.desc for node in got] == expected def test_get_first_root_node_empty(self, model_without_data): got = model_without_data.get_first_root_node() assert got is None def test_get_last_root_node_empty(self, model_without_data): got = model_without_data.get_last_root_node() assert got is None def test_get_tree(self, model_without_data): got = list(model_without_data.get_tree()) assert got == [] def test_get_annotated_list(self, model_without_data): expected = [] self._assert_get_annotated_list(model_without_data, expected) def test_add_multiple_root_nodes_adds_sibling_leaves(self, model_without_data): model_without_data.add_root(desc="1") model_without_data.add_root(desc="2") model_without_data.add_root(desc="3") model_without_data.add_root(desc="4") # these are all sibling root nodes (depth=1), and leaf nodes (children=0) expected = [("1", 1, 0), ("2", 1, 0), ("3", 1, 0), ("4", 1, 0)] assert self.got(model_without_data) == expected class TestNonEmptyTree(TestTreeBase): pass @pytest.mark.django_db class TestClassMethods(TestNonEmptyTree): def test_load_bulk_existing(self, model): # inserting on an existing node node = model.objects.get(desc="231") ids = model.load_bulk(BASE_DATA, node) expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 4), ("1", 4, 0), ("2", 4, 4), ("21", 5, 0), ("22", 5, 0), ("23", 5, 1), ("231", 6, 0), ("24", 5, 0), ("3", 4, 0), ("4", 4, 1), ("41", 5, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] expected_descs = ["1", "2", "21", "22", "23", "231", "24", "3", "4", "41"] got_descs = [obj.desc for obj in model.objects.filter(pk__in=ids)] assert sorted(got_descs) == sorted(expected_descs) assert self.got(model) == expected def test_get_tree_all(self, model): nodes = model.get_tree() got = [(o.desc, o.get_depth(), o.get_children_count()) for o in nodes] assert got == UNCHANGED assert all([type(o) == model for o in nodes]) def test_dump_bulk_all(self, model): assert model.dump_bulk(keep_ids=False) == BASE_DATA def test_get_tree_node(self, model): node = model.objects.get(desc="231") model.load_bulk(BASE_DATA, node) # the tree was modified by load_bulk, so we reload our node object node = model.objects.get(pk=node.pk) nodes = model.get_tree(node) got = [(o.desc, o.get_depth(), o.get_children_count()) for o in nodes] expected = [ ("231", 3, 4), ("1", 4, 0), ("2", 4, 4), ("21", 5, 0), ("22", 5, 0), ("23", 5, 1), ("231", 6, 0), ("24", 5, 0), ("3", 4, 0), ("4", 4, 1), ("41", 5, 0), ] assert got == expected assert all([type(o) == model for o in nodes]) def test_get_tree_leaf(self, model): node = model.objects.get(desc="1") assert 0 == node.get_children_count() nodes = model.get_tree(node) got = [(o.desc, o.get_depth(), o.get_children_count()) for o in nodes] expected = [("1", 1, 0)] assert got == expected assert all([type(o) == model for o in nodes]) def test_get_annotated_list_all(self, model): expected = [ ("1", True, [], 0), ("2", False, [], 0), ("21", True, [], 1), ("22", False, [], 1), ("23", False, [], 1), ("231", True, [0], 2), ("24", False, [0], 1), ("3", False, [], 0), ("4", False, [], 0), ("41", True, [0, 1], 1), ] self._assert_get_annotated_list(model, expected) def test_get_annotated_list_node(self, model): node = model.objects.get(desc="2") expected = [ ("2", True, [], 0), ("21", True, [], 1), ("22", False, [], 1), ("23", False, [], 1), ("231", True, [0], 2), ("24", False, [0, 1], 1), ] self._assert_get_annotated_list(model, expected, node) def test_get_annotated_list_leaf(self, model): node = model.objects.get(desc="1") expected = [("1", True, [0], 0)] self._assert_get_annotated_list(model, expected, node) def test_dump_bulk_node(self, model): node = model.objects.get(desc="231") model.load_bulk(BASE_DATA, node) # the tree was modified by load_bulk, so we reload our node object node = model.objects.get(pk=node.pk) got = model.dump_bulk(node, False) expected = [{"data": {"desc": "231"}, "children": BASE_DATA}] assert got == expected def test_load_and_dump_bulk_keeping_ids(self, model): exp = model.dump_bulk(keep_ids=True) model.objects.all().delete() model.load_bulk(exp, None, True) got = model.dump_bulk(keep_ids=True) assert got == exp # do we really have an unchanged tree after the dump/delete/load? got = [ (o.desc, o.get_depth(), o.get_children_count()) for o in model.get_tree() ] assert got == UNCHANGED def test_load_and_dump_bulk_with_fk(self, related_model): # https://bitbucket.org/tabo/django-treebeard/issue/48/ related_model.objects.all().delete() related, created = models.RelatedModel.objects.get_or_create( desc="Test %s" % related_model.__name__ ) related_data = [ {"data": {"desc": "1", "related": related.pk}}, { "data": {"desc": "2", "related": related.pk}, "children": [ {"data": {"desc": "21", "related": related.pk}}, {"data": {"desc": "22", "related": related.pk}}, { "data": {"desc": "23", "related": related.pk}, "children": [ {"data": {"desc": "231", "related": related.pk}}, ], }, {"data": {"desc": "24", "related": related.pk}}, ], }, {"data": {"desc": "3", "related": related.pk}}, { "data": {"desc": "4", "related": related.pk}, "children": [ {"data": {"desc": "41", "related": related.pk}}, ], }, ] related_model.load_bulk(related_data) got = related_model.dump_bulk(keep_ids=False) assert got == related_data def test_get_root_nodes(self, model): got = model.get_root_nodes() expected = ["1", "2", "3", "4"] assert [node.desc for node in got] == expected assert all([type(node) == model for node in got]) def test_get_first_root_node(self, model): got = model.get_first_root_node() assert got.desc == "1" assert type(got) == model def test_get_last_root_node(self, model): got = model.get_last_root_node() assert got.desc == "4" assert type(got) == model def test_add_root(self, model): obj = model.add_root(desc="5") assert obj.get_depth() == 1 got = model.get_last_root_node() assert got.desc == "5" assert type(got) == model def test_add_root_with_passed_instance(self, model): obj = model(desc="5") result = model.add_root(instance=obj) assert result == obj got = model.get_last_root_node() assert got.desc == "5" assert type(got) == model def test_add_root_with_already_saved_instance(self, model): obj = model.objects.get(desc="4") with pytest.raises(NodeAlreadySaved): model.add_root(instance=obj) @pytest.mark.django_db class TestSimpleNodeMethods(TestNonEmptyTree): def test_is_root(self, model): data = [ ("2", True), ("1", True), ("4", True), ("21", False), ("24", False), ("22", False), ("231", False), ] for desc, expected in data: got = model.objects.get(desc=desc).is_root() assert got == expected def test_is_leaf(self, model): data = [ ("2", False), ("23", False), ("231", True), ] for desc, expected in data: got = model.objects.get(desc=desc).is_leaf() assert got == expected def test_get_root(self, model): data = [ ("2", "2"), ("1", "1"), ("4", "4"), ("21", "2"), ("24", "2"), ("22", "2"), ("231", "2"), ] for desc, expected in data: node = model.objects.get(desc=desc).get_root() assert node.desc == expected assert type(node) == model def test_get_parent(self, model): data = [ ("2", None), ("1", None), ("4", None), ("21", "2"), ("24", "2"), ("22", "2"), ("231", "23"), ] data = dict(data) objs = {} for desc, expected in data.items(): node = model.objects.get(desc=desc) parent = node.get_parent() if expected: assert parent.desc == expected assert type(parent) == model else: assert parent is None objs[desc] = node # corrupt the objects' parent cache node._parent_obj = "CORRUPTED!!!" for desc, expected in data.items(): node = objs[desc] # asking get_parent to not use the parent cache (since we # corrupted it in the previous loop) parent = node.get_parent(True) if expected: assert parent.desc == expected assert type(parent) == model else: assert parent is None def test_get_children(self, model): data = [ ("2", ["21", "22", "23", "24"]), ("23", ["231"]), ("231", []), ] for desc, expected in data: children = model.objects.get(desc=desc).get_children() assert [node.desc for node in children] == expected assert all([type(node) == model for node in children]) def test_get_children_count(self, model): data = [ ("2", 4), ("23", 1), ("231", 0), ] for desc, expected in data: got = model.objects.get(desc=desc).get_children_count() assert got == expected def test_get_siblings(self, model): data = [ ("2", ["1", "2", "3", "4"]), ("21", ["21", "22", "23", "24"]), ("231", ["231"]), ] for desc, expected in data: siblings = model.objects.get(desc=desc).get_siblings() assert [node.desc for node in siblings] == expected assert all([type(node) == model for node in siblings]) def test_get_first_sibling(self, model): data = [ ("2", "1"), ("1", "1"), ("4", "1"), ("21", "21"), ("24", "21"), ("22", "21"), ("231", "231"), ] for desc, expected in data: node = model.objects.get(desc=desc).get_first_sibling() assert node.desc == expected assert type(node) == model def test_get_prev_sibling(self, model): data = [ ("2", "1"), ("1", None), ("4", "3"), ("21", None), ("24", "23"), ("22", "21"), ("231", None), ] for desc, expected in data: node = model.objects.get(desc=desc).get_prev_sibling() if expected is None: assert node is None else: assert node.desc == expected assert type(node) == model def test_get_next_sibling(self, model): data = [ ("2", "3"), ("1", "2"), ("4", None), ("21", "22"), ("24", None), ("22", "23"), ("231", None), ] for desc, expected in data: node = model.objects.get(desc=desc).get_next_sibling() if expected is None: assert node is None else: assert node.desc == expected assert type(node) == model def test_get_last_sibling(self, model): data = [ ("2", "4"), ("1", "4"), ("4", "4"), ("21", "24"), ("24", "24"), ("22", "24"), ("231", "231"), ] for desc, expected in data: node = model.objects.get(desc=desc).get_last_sibling() assert node.desc == expected assert type(node) == model def test_get_first_child(self, model): data = [ ("2", "21"), ("21", None), ("23", "231"), ("231", None), ] for desc, expected in data: node = model.objects.get(desc=desc).get_first_child() if expected is None: assert node is None else: assert node.desc == expected assert type(node) == model def test_get_last_child(self, model): data = [ ("2", "24"), ("21", None), ("23", "231"), ("231", None), ] for desc, expected in data: node = model.objects.get(desc=desc).get_last_child() if expected is None: assert node is None else: assert node.desc == expected assert type(node) == model def test_get_ancestors(self, model): data = [ ("2", []), ("21", ["2"]), ("231", ["2", "23"]), ] for desc, expected in data: nodes = model.objects.get(desc=desc).get_ancestors() assert [node.desc for node in nodes] == expected assert all([type(node) == model for node in nodes]) def test_get_descendants(self, model): data = [ ("2", ["21", "22", "23", "231", "24"]), ("23", ["231"]), ("231", []), ("1", []), ("4", ["41"]), ] for desc, expected in data: nodes = model.objects.get(desc=desc).get_descendants() assert [node.desc for node in nodes] == expected assert all([type(node) == model for node in nodes]) def test_get_descendant_count(self, model): data = [ ("2", 5), ("23", 1), ("231", 0), ("1", 0), ("4", 1), ] for desc, expected in data: got = model.objects.get(desc=desc).get_descendant_count() assert got == expected def test_is_sibling_of(self, model): data = [ ("2", "2", True), ("2", "1", True), ("21", "2", False), ("231", "2", False), ("22", "23", True), ("231", "23", False), ("231", "231", True), ] for desc1, desc2, expected in data: node1 = model.objects.get(desc=desc1) node2 = model.objects.get(desc=desc2) assert node1.is_sibling_of(node2) == expected def test_is_child_of(self, model): data = [ ("2", "2", False), ("2", "1", False), ("21", "2", True), ("231", "2", False), ("231", "23", True), ("231", "231", False), ] for desc1, desc2, expected in data: node1 = model.objects.get(desc=desc1) node2 = model.objects.get(desc=desc2) assert node1.is_child_of(node2) == expected def test_is_descendant_of(self, model): data = [ ("2", "2", False), ("2", "1", False), ("21", "2", True), ("231", "2", True), ("231", "23", True), ("231", "231", False), ] for desc1, desc2, expected in data: node1 = model.objects.get(desc=desc1) node2 = model.objects.get(desc=desc2) assert node1.is_descendant_of(node2) == expected @pytest.mark.django_db class TestAddChild(TestNonEmptyTree): def test_add_child_to_leaf(self, model): model.objects.get(desc="231").add_child(desc="2311") expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 1), ("2311", 4, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_add_child_to_node(self, model): model.objects.get(desc="2").add_child(desc="25") expected = [ ("1", 1, 0), ("2", 1, 5), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("25", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_add_child_with_passed_instance(self, model): child = model(desc="2311") result = model.objects.get(desc="231").add_child(instance=child) assert result == child expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 1), ("2311", 4, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_add_child_with_already_saved_instance(self, model): child = model.objects.get(desc="21") with pytest.raises(NodeAlreadySaved): model.objects.get(desc="2").add_child(instance=child) def test_add_child_with_pk_set(self, model): """ If the model is using a natural primary key then it will be already set when the instance is inserted. """ child = model(pk=999999, desc="natural key") result = model.objects.get(desc="2").add_child(instance=child) assert result == child def test_add_child_post_save(self, model): try: @receiver(post_save, dispatch_uid="test_add_child_post_save") def on_post_save(instance, **kwargs): parent = instance.get_parent() parent.refresh_from_db() assert parent.get_descendant_count() == 1 # It's important that we're testing a leaf node parent = model.objects.get(desc="231") assert parent.is_leaf() parent.add_child(desc="2311") finally: post_save.disconnect(dispatch_uid="test_add_child_post_save") @pytest.mark.django_db class TestAddSibling(TestNonEmptyTree): def test_add_sibling_invalid_pos(self, model): with pytest.raises(InvalidPosition): model.objects.get(desc="231").add_sibling("invalid_pos") def test_add_sibling_missing_nodeorderby(self, model): node_wchildren = model.objects.get(desc="2") with pytest.raises(MissingNodeOrderBy): node_wchildren.add_sibling("sorted-sibling", desc="aaa") def test_add_sibling_last_root(self, model): node_wchildren = model.objects.get(desc="2") obj = node_wchildren.add_sibling("last-sibling", desc="5") assert obj.get_depth() == 1 assert node_wchildren.get_last_sibling().desc == "5" def test_add_sibling_last(self, model): node = model.objects.get(desc="231") obj = node.add_sibling("last-sibling", desc="232") assert obj.get_depth() == 3 assert node.get_last_sibling().desc == "232" def test_add_sibling_first_root(self, model): node_wchildren = model.objects.get(desc="2") obj = node_wchildren.add_sibling("first-sibling", desc="new") assert obj.get_depth() == 1 expected = [ ("new", 1, 0), ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_add_sibling_first(self, model): node_wchildren = model.objects.get(desc="23") obj = node_wchildren.add_sibling("first-sibling", desc="new") assert obj.get_depth() == 2 expected = [ ("1", 1, 0), ("2", 1, 5), ("new", 2, 0), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_add_sibling_left_root(self, model): node_wchildren = model.objects.get(desc="2") obj = node_wchildren.add_sibling("left", desc="new") assert obj.get_depth() == 1 expected = [ ("1", 1, 0), ("new", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_add_sibling_left(self, model): node_wchildren = model.objects.get(desc="23") obj = node_wchildren.add_sibling("left", desc="new") assert obj.get_depth() == 2 expected = [ ("1", 1, 0), ("2", 1, 5), ("21", 2, 0), ("22", 2, 0), ("new", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_add_sibling_left_noleft_root(self, model): node = model.objects.get(desc="1") obj = node.add_sibling("left", desc="new") assert obj.get_depth() == 1 expected = [ ("new", 1, 0), ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_add_sibling_left_noleft(self, model): node = model.objects.get(desc="231") obj = node.add_sibling("left", desc="new") assert obj.get_depth() == 3 expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 2), ("new", 3, 0), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_add_sibling_right_root(self, model): node_wchildren = model.objects.get(desc="2") obj = node_wchildren.add_sibling("right", desc="new") assert obj.get_depth() == 1 expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("new", 1, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_add_sibling_right(self, model): node_wchildren = model.objects.get(desc="23") obj = node_wchildren.add_sibling("right", desc="new") assert obj.get_depth() == 2 expected = [ ("1", 1, 0), ("2", 1, 5), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("new", 2, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_add_sibling_right_noright_root(self, model): node = model.objects.get(desc="4") obj = node.add_sibling("right", desc="new") assert obj.get_depth() == 1 expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ("new", 1, 0), ] assert self.got(model) == expected def test_add_sibling_right_noright(self, model): node = model.objects.get(desc="231") obj = node.add_sibling("right", desc="new") assert obj.get_depth() == 3 expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 2), ("231", 3, 0), ("new", 3, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_add_sibling_with_passed_instance(self, model): node_wchildren = model.objects.get(desc="2") obj = model(desc="5") result = node_wchildren.add_sibling("last-sibling", instance=obj) assert result == obj assert obj.get_depth() == 1 assert node_wchildren.get_last_sibling().desc == "5" def test_add_sibling_already_saved_instance(self, model): node_wchildren = model.objects.get(desc="2") existing_node = model.objects.get(desc="4") with pytest.raises(NodeAlreadySaved): node_wchildren.add_sibling("last-sibling", instance=existing_node) def test_add_child_with_pk_set(self, model): """ If the model is using a natural primary key then it will be already set when the instance is inserted. """ child = model(pk=999999, desc="natural key") result = model.objects.get(desc="2").add_child(instance=child) assert result == child @pytest.mark.django_db class TestDelete(TestTreeBase): @staticmethod @pytest.fixture( scope="function", params=zip(models.BASE_MODELS, models.DEP_MODELS), ids=lambda fv: f"base={fv[0].__name__} dep={fv[1].__name__}", ) def delete_dep_model_pair(request): base_model, dep_model = request.param base_model.load_bulk(BASE_DATA) for node in base_model.objects.all(): dep_model(node=node).save() return base_model, dep_model def test_delete_leaf(self, delete_dep_model_pair): delete_model, dep_model = delete_dep_model_pair result = delete_model.objects.get(desc="231").delete() expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(delete_model) == expected assert result == (2, {delete_model._meta.label: 1, dep_model._meta.label: 1}) def test_delete_node(self, delete_dep_model_pair): delete_model, dep_model = delete_dep_model_pair result = delete_model.objects.get(desc="23").delete() expected = [ ("1", 1, 0), ("2", 1, 3), ("21", 2, 0), ("22", 2, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(delete_model) == expected assert result == (4, {delete_model._meta.label: 2, dep_model._meta.label: 2}) def test_delete_root(self, delete_dep_model_pair): delete_model, dep_model = delete_dep_model_pair result = delete_model.objects.get(desc="2").delete() expected = [("1", 1, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0)] assert self.got(delete_model) == expected assert result == (12, {delete_model._meta.label: 6, dep_model._meta.label: 6}) def test_delete_filter_root_nodes(self, delete_dep_model_pair): delete_model, dep_model = delete_dep_model_pair result = delete_model.objects.filter(desc__in=("2", "3")).delete() expected = [("1", 1, 0), ("4", 1, 1), ("41", 2, 0)] assert self.got(delete_model) == expected assert result == (14, {delete_model._meta.label: 7, dep_model._meta.label: 7}) def test_delete_filter_children(self, delete_dep_model_pair): delete_model, dep_model = delete_dep_model_pair result = delete_model.objects.filter(desc__in=("2", "23", "231")).delete() expected = [("1", 1, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0)] assert self.got(delete_model) == expected assert result == (12, {delete_model._meta.label: 6, dep_model._meta.label: 6}) def test_delete_nonexistant_nodes(self, delete_dep_model_pair): delete_model, dep_model = delete_dep_model_pair result = delete_model.objects.filter(desc__in=("ZZZ", "XXX")).delete() assert self.got(delete_model) == UNCHANGED assert result == (0, {}) def test_delete_same_node_twice(self, delete_dep_model_pair): delete_model, dep_model = delete_dep_model_pair result = delete_model.objects.filter(desc__in=("2", "2")).delete() expected = [("1", 1, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0)] assert self.got(delete_model) == expected assert result == (12, {delete_model._meta.label: 6, dep_model._meta.label: 6}) def test_delete_all_root_nodes(self, delete_dep_model_pair): delete_model, dep_model = delete_dep_model_pair result = delete_model.get_root_nodes().delete() assert result == (20, {delete_model._meta.label: 10, dep_model._meta.label: 10}) assert delete_model.objects.count() == 0 def test_delete_all_nodes(self, delete_dep_model_pair): delete_model, dep_model = delete_dep_model_pair result = delete_model.objects.all().delete() assert result == (20, {delete_model._meta.label: 10, dep_model._meta.label: 10}) assert delete_model.objects.count() == 0 @pytest.mark.django_db class TestMoveErrors(TestNonEmptyTree): def test_move_invalid_pos(self, model): node = model.objects.get(desc="231") with pytest.raises(InvalidPosition): node.move(node, "invalid_pos") def test_move_to_descendant(self, model): node = model.objects.get(desc="2") target = model.objects.get(desc="231") with pytest.raises(InvalidMoveToDescendant): node.move(target, "first-sibling") def test_move_missing_nodeorderby(self, model): node = model.objects.get(desc="231") with pytest.raises(MissingNodeOrderBy): node.move(node, "sorted-child") with pytest.raises(MissingNodeOrderBy): node.move(node, "sorted-sibling") @pytest.mark.django_db class TestMoveSortedErrors(TestTreeBase): def test_nonsorted_move_in_sorted(self, sorted_model): node = sorted_model.add_root(val1=3, val2=3, desc="zxy") with pytest.raises(InvalidPosition): node.move(node, "left") @pytest.mark.django_db class TestMoveLeafRoot(TestNonEmptyTree): def test_move_leaf_last_sibling_root(self, model): target = model.objects.get(desc="2") model.objects.get(desc="231").move(target, "last-sibling") expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ("231", 1, 0), ] assert self.got(model) == expected def test_move_leaf_first_sibling_root(self, model): target = model.objects.get(desc="2") model.objects.get(desc="231").move(target, "first-sibling") expected = [ ("231", 1, 0), ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_move_leaf_left_sibling_root(self, model): target = model.objects.get(desc="2") model.objects.get(desc="231").move(target, "left") expected = [ ("1", 1, 0), ("231", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_move_leaf_right_sibling_root(self, model): target = model.objects.get(desc="2") model.objects.get(desc="231").move(target, "right") expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 0), ("24", 2, 0), ("231", 1, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_move_leaf_last_child_root(self, model): target = model.objects.get(desc="2") model.objects.get(desc="231").move(target, "last-child") expected = [ ("1", 1, 0), ("2", 1, 5), ("21", 2, 0), ("22", 2, 0), ("23", 2, 0), ("24", 2, 0), ("231", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_move_leaf_first_child_root(self, model): target = model.objects.get(desc="2") model.objects.get(desc="231").move(target, "first-child") expected = [ ("1", 1, 0), ("2", 1, 5), ("231", 2, 0), ("21", 2, 0), ("22", 2, 0), ("23", 2, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected @pytest.mark.django_db class TestMoveLeaf(TestNonEmptyTree): def test_move_leaf_last_sibling(self, model): target = model.objects.get(desc="22") model.objects.get(desc="231").move(target, "last-sibling") expected = [ ("1", 1, 0), ("2", 1, 5), ("21", 2, 0), ("22", 2, 0), ("23", 2, 0), ("24", 2, 0), ("231", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_move_leaf_first_sibling(self, model): target = model.objects.get(desc="22") model.objects.get(desc="231").move(target, "first-sibling") expected = [ ("1", 1, 0), ("2", 1, 5), ("231", 2, 0), ("21", 2, 0), ("22", 2, 0), ("23", 2, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_move_leaf_left_sibling(self, model): target = model.objects.get(desc="22") model.objects.get(desc="231").move(target, "left") expected = [ ("1", 1, 0), ("2", 1, 5), ("21", 2, 0), ("231", 2, 0), ("22", 2, 0), ("23", 2, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_move_leaf_right_sibling(self, model): target = model.objects.get(desc="22") model.objects.get(desc="231").move(target, "right") expected = [ ("1", 1, 0), ("2", 1, 5), ("21", 2, 0), ("22", 2, 0), ("231", 2, 0), ("23", 2, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_move_leaf_left_sibling_itself(self, model): target = model.objects.get(desc="231") model.objects.get(desc="231").move(target, "left") assert self.got(model) == UNCHANGED def test_move_leaf_last_child(self, model): target = model.objects.get(desc="22") model.objects.get(desc="231").move(target, "last-child") expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 1), ("231", 3, 0), ("23", 2, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_move_leaf_first_child(self, model): target = model.objects.get(desc="22") model.objects.get(desc="231").move(target, "first-child") expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 1), ("231", 3, 0), ("23", 2, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected @pytest.mark.django_db class TestMoveBranchRoot(TestNonEmptyTree): def test_move_branch_first_sibling_root(self, model): target = model.objects.get(desc="2") model.objects.get(desc="4").move(target, "first-sibling") expected = [ ("4", 1, 1), ("41", 2, 0), ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ] assert self.got(model) == expected def test_move_branch_last_sibling_root(self, model): target = model.objects.get(desc="2") model.objects.get(desc="4").move(target, "last-sibling") expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_move_branch_left_sibling_root(self, model): target = model.objects.get(desc="2") model.objects.get(desc="4").move(target, "left") expected = [ ("1", 1, 0), ("4", 1, 1), ("41", 2, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ] assert self.got(model) == expected def test_move_branch_right_sibling_root(self, model): target = model.objects.get(desc="2") model.objects.get(desc="4").move(target, "right") expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("4", 1, 1), ("41", 2, 0), ("3", 1, 0), ] assert self.got(model) == expected def test_move_branch_left_noleft_sibling_root(self, model): target = model.objects.get(desc="2").get_first_sibling() model.objects.get(desc="4").move(target, "left") expected = [ ("4", 1, 1), ("41", 2, 0), ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ] assert self.got(model) == expected def test_move_branch_right_noright_sibling_root(self, model): target = model.objects.get(desc="2").get_last_sibling() model.objects.get(desc="4").move(target, "right") expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ("4", 1, 1), ("41", 2, 0), ] assert self.got(model) == expected def test_move_branch_first_child_root(self, model): target = model.objects.get(desc="2") model.objects.get(desc="4").move(target, "first-child") expected = [ ("1", 1, 0), ("2", 1, 5), ("4", 2, 1), ("41", 3, 0), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ] assert self.got(model) == expected def test_move_branch_last_child_root(self, model): target = model.objects.get(desc="2") model.objects.get(desc="4").move(target, "last-child") expected = [ ("1", 1, 0), ("2", 1, 5), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("4", 2, 1), ("41", 3, 0), ("3", 1, 0), ] assert self.got(model) == expected @pytest.mark.django_db class TestMoveBranch(TestNonEmptyTree): def test_move_branch_first_sibling(self, model): target = model.objects.get(desc="23") model.objects.get(desc="4").move(target, "first-sibling") expected = [ ("1", 1, 0), ("2", 1, 5), ("4", 2, 1), ("41", 3, 0), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ] assert self.got(model) == expected def test_move_branch_last_sibling(self, model): target = model.objects.get(desc="23") model.objects.get(desc="4").move(target, "last-sibling") expected = [ ("1", 1, 0), ("2", 1, 5), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("4", 2, 1), ("41", 3, 0), ("3", 1, 0), ] assert self.got(model) == expected def test_move_branch_left_sibling(self, model): target = model.objects.get(desc="23") model.objects.get(desc="4").move(target, "left") expected = [ ("1", 1, 0), ("2", 1, 5), ("21", 2, 0), ("22", 2, 0), ("4", 2, 1), ("41", 3, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ] assert self.got(model) == expected def test_move_branch_right_sibling(self, model): target = model.objects.get(desc="23") model.objects.get(desc="4").move(target, "right") expected = [ ("1", 1, 0), ("2", 1, 5), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("4", 2, 1), ("41", 3, 0), ("24", 2, 0), ("3", 1, 0), ] assert self.got(model) == expected def test_move_branch_left_noleft_sibling(self, model): target = model.objects.get(desc="23").get_first_sibling() model.objects.get(desc="4").move(target, "left") expected = [ ("1", 1, 0), ("2", 1, 5), ("4", 2, 1), ("41", 3, 0), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ] assert self.got(model) == expected def test_move_branch_right_noright_sibling(self, model): target = model.objects.get(desc="23").get_last_sibling() model.objects.get(desc="4").move(target, "right") expected = [ ("1", 1, 0), ("2", 1, 5), ("21", 2, 0), ("22", 2, 0), ("23", 2, 1), ("231", 3, 0), ("24", 2, 0), ("4", 2, 1), ("41", 3, 0), ("3", 1, 0), ] assert self.got(model) == expected def test_move_branch_left_itself_sibling(self, model): target = model.objects.get(desc="4") model.objects.get(desc="4").move(target, "left") assert self.got(model) == UNCHANGED def test_move_branch_first_child(self, model): target = model.objects.get(desc="23") model.objects.get(desc="4").move(target, "first-child") expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 2), ("4", 3, 1), ("41", 4, 0), ("231", 3, 0), ("24", 2, 0), ("3", 1, 0), ] assert self.got(model) == expected def test_move_branch_last_child(self, model): target = model.objects.get(desc="23") model.objects.get(desc="4").move(target, "last-child") expected = [ ("1", 1, 0), ("2", 1, 4), ("21", 2, 0), ("22", 2, 0), ("23", 2, 2), ("231", 3, 0), ("4", 3, 1), ("41", 4, 0), ("24", 2, 0), ("3", 1, 0), ] assert self.got(model) == expected @pytest.mark.django_db class TestTreeSorted(TestTreeBase): def got(self, sorted_model): return [ (o.val1, o.val2, o.desc, o.get_depth(), o.get_children_count()) for o in sorted_model.get_tree() ] def test_add_root_sorted(self, sorted_model): sorted_model.add_root(val1=3, val2=3, desc="zxy") sorted_model.add_root(val1=1, val2=4, desc="bcd") sorted_model.add_root(val1=2, val2=5, desc="zxy") sorted_model.add_root(val1=3, val2=3, desc="abc") sorted_model.add_root(val1=4, val2=1, desc="fgh") sorted_model.add_root(val1=3, val2=3, desc="abc") sorted_model.add_root(val1=2, val2=2, desc="qwe") sorted_model.add_root(val1=3, val2=2, desc="vcx") expected = [ (1, 4, "bcd", 1, 0), (2, 2, "qwe", 1, 0), (2, 5, "zxy", 1, 0), (3, 2, "vcx", 1, 0), (3, 3, "abc", 1, 0), (3, 3, "abc", 1, 0), (3, 3, "zxy", 1, 0), (4, 1, "fgh", 1, 0), ] assert self.got(sorted_model) == expected def test_add_child_root_sorted(self, sorted_model): root = sorted_model.add_root(val1=0, val2=0, desc="aaa") root.add_child(val1=3, val2=3, desc="zxy") root.add_child(val1=1, val2=4, desc="bcd") root.add_child(val1=2, val2=5, desc="zxy") root.add_child(val1=3, val2=3, desc="abc") root.add_child(val1=4, val2=1, desc="fgh") root.add_child(val1=3, val2=3, desc="abc") root.add_child(val1=2, val2=2, desc="qwe") root.add_child(val1=3, val2=2, desc="vcx") expected = [ (0, 0, "aaa", 1, 8), (1, 4, "bcd", 2, 0), (2, 2, "qwe", 2, 0), (2, 5, "zxy", 2, 0), (3, 2, "vcx", 2, 0), (3, 3, "abc", 2, 0), (3, 3, "abc", 2, 0), (3, 3, "zxy", 2, 0), (4, 1, "fgh", 2, 0), ] assert self.got(sorted_model) == expected def test_add_child_nonroot_sorted(self, sorted_model): get_node = lambda node_id: sorted_model.objects.get(pk=node_id) root_id = sorted_model.add_root(val1=0, val2=0, desc="a").pk node_id = get_node(root_id).add_child(val1=0, val2=0, desc="ac").pk get_node(root_id).add_child(val1=0, val2=0, desc="aa") get_node(root_id).add_child(val1=0, val2=0, desc="av") get_node(node_id).add_child(val1=0, val2=0, desc="aca") get_node(node_id).add_child(val1=0, val2=0, desc="acc") get_node(node_id).add_child(val1=0, val2=0, desc="acb") expected = [ (0, 0, "a", 1, 3), (0, 0, "aa", 2, 0), (0, 0, "ac", 2, 3), (0, 0, "aca", 3, 0), (0, 0, "acb", 3, 0), (0, 0, "acc", 3, 0), (0, 0, "av", 2, 0), ] assert self.got(sorted_model) == expected def test_move_sorted(self, sorted_model): sorted_model.add_root(val1=3, val2=3, desc="zxy") sorted_model.add_root(val1=1, val2=4, desc="bcd") sorted_model.add_root(val1=2, val2=5, desc="zxy") sorted_model.add_root(val1=3, val2=3, desc="abc") sorted_model.add_root(val1=4, val2=1, desc="fgh") sorted_model.add_root(val1=3, val2=3, desc="abc") sorted_model.add_root(val1=2, val2=2, desc="qwe") sorted_model.add_root(val1=3, val2=2, desc="vcx") root_nodes = sorted_model.get_root_nodes() target = root_nodes[0] for node in root_nodes[1:]: # because raw queries don't update django objects node = sorted_model.objects.get(pk=node.pk) target = sorted_model.objects.get(pk=target.pk) node.move(target, "sorted-child") expected = [ (1, 4, "bcd", 1, 7), (2, 2, "qwe", 2, 0), (2, 5, "zxy", 2, 0), (3, 2, "vcx", 2, 0), (3, 3, "abc", 2, 0), (3, 3, "abc", 2, 0), (3, 3, "zxy", 2, 0), (4, 1, "fgh", 2, 0), ] assert self.got(sorted_model) == expected def test_move_sortedsibling(self, sorted_model): # https://bitbucket.org/tabo/django-treebeard/issue/27 sorted_model.add_root(val1=3, val2=3, desc="zxy") sorted_model.add_root(val1=1, val2=4, desc="bcd") sorted_model.add_root(val1=2, val2=5, desc="zxy") sorted_model.add_root(val1=3, val2=3, desc="abc") sorted_model.add_root(val1=4, val2=1, desc="fgh") sorted_model.add_root(val1=3, val2=3, desc="abc") sorted_model.add_root(val1=2, val2=2, desc="qwe") sorted_model.add_root(val1=3, val2=2, desc="vcx") root_nodes = sorted_model.get_root_nodes() target = root_nodes[0] for node in root_nodes[1:]: # because raw queries don't update django objects node = sorted_model.objects.get(pk=node.pk) target = sorted_model.objects.get(pk=target.pk) node.val1 -= 2 node.save() node.move(target, "sorted-sibling") expected = [ (0, 2, "qwe", 1, 0), (0, 5, "zxy", 1, 0), (1, 2, "vcx", 1, 0), (1, 3, "abc", 1, 0), (1, 3, "abc", 1, 0), (1, 3, "zxy", 1, 0), (1, 4, "bcd", 1, 0), (2, 1, "fgh", 1, 0), ] assert self.got(sorted_model) == expected @pytest.mark.django_db class TestInheritedModels(TestTreeBase): @staticmethod @pytest.fixture( scope="function", params=zip(models.BASE_MODELS, models.INHERITED_MODELS), ids=lambda fv: f"base={fv[0].__name__} inherited={fv[1].__name__}", ) def inherited_model(request): base_model, inherited_model = request.param base_model.add_root(desc="1") base_model.add_root(desc="2") node21 = inherited_model(desc="21") base_model.objects.get(desc="2").add_child(instance=node21) base_model.objects.get(desc="21").add_child(desc="211") base_model.objects.get(desc="21").add_child(desc="212") base_model.objects.get(desc="2").add_child(desc="22") node3 = inherited_model(desc="3") base_model.add_root(instance=node3) return inherited_model def test_get_tree_all(self, inherited_model): got = [ (o.desc, o.get_depth(), o.get_children_count()) for o in inherited_model.get_tree() ] expected = [ ("1", 1, 0), ("2", 1, 2), ("21", 2, 2), ("211", 3, 0), ("212", 3, 0), ("22", 2, 0), ("3", 1, 0), ] assert got == expected def test_get_tree_node(self, inherited_model): node = inherited_model.objects.get(desc="21") got = [ (o.desc, o.get_depth(), o.get_children_count()) for o in inherited_model.get_tree(node) ] expected = [ ("21", 2, 2), ("211", 3, 0), ("212", 3, 0), ] assert got == expected def test_get_root_nodes(self, inherited_model): got = inherited_model.get_root_nodes() expected = ["1", "2", "3"] assert [node.desc for node in got] == expected def test_get_first_root_node(self, inherited_model): got = inherited_model.get_first_root_node() assert got.desc == "1" def test_get_last_root_node(self, inherited_model): got = inherited_model.get_last_root_node() assert got.desc == "3" def test_is_root(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert node21.is_root() is False assert node3.is_root() is True def test_is_leaf(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert node21.is_leaf() is False assert node3.is_leaf() is True def test_get_root(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert node21.get_root().desc == "2" assert node3.get_root().desc == "3" def test_get_parent(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert node21.get_parent().desc == "2" assert node3.get_parent() is None def test_get_children(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert [node.desc for node in node21.get_children()] == ["211", "212"] assert [node.desc for node in node3.get_children()] == [] def test_get_children_count(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert node21.get_children_count() == 2 assert node3.get_children_count() == 0 def test_get_siblings(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert [node.desc for node in node21.get_siblings()] == ["21", "22"] assert [node.desc for node in node3.get_siblings()] == ["1", "2", "3"] def test_get_first_sibling(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert node21.get_first_sibling().desc == "21" assert node3.get_first_sibling().desc == "1" def test_get_prev_sibling(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert node21.get_prev_sibling() is None assert node3.get_prev_sibling().desc == "2" def test_get_next_sibling(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert node21.get_next_sibling().desc == "22" assert node3.get_next_sibling() is None def test_get_last_sibling(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert node21.get_last_sibling().desc == "22" assert node3.get_last_sibling().desc == "3" def test_get_first_child(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert node21.get_first_child().desc == "211" assert node3.get_first_child() is None def test_get_last_child(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert node21.get_last_child().desc == "212" assert node3.get_last_child() is None def test_get_ancestors(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert [node.desc for node in node21.get_ancestors()] == ["2"] assert [node.desc for node in node3.get_ancestors()] == [] def test_get_descendants(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert [node.desc for node in node21.get_descendants()] == ["211", "212"] assert [node.desc for node in node3.get_descendants()] == [] def test_get_descendant_count(self, inherited_model): node21 = inherited_model.objects.get(desc="21") node3 = inherited_model.objects.get(desc="3") assert node21.get_descendant_count() == 2 assert node3.get_descendant_count() == 0 def test_cascading_deletion(self, inherited_model): # Deleting a node by calling delete() on the inherited_model class # should delete descendants, even if those descendants are not # instances of inherited_model base_model = inherited_model.__bases__[0] node21 = inherited_model.objects.get(desc="21") node21.delete() node2 = base_model.objects.get(desc="2") for desc in ["21", "211", "212"]: assert not base_model.objects.filter(desc=desc).exists() assert [node.desc for node in node2.get_descendants()] == ["22"] @pytest.mark.django_db class TestMP_TreeAlphabet(TestTreeBase): @pytest.mark.skipif( not os.getenv("TREEBEARD_TEST_ALPHABET", False), reason="TREEBEARD_TEST_ALPHABET env variable not set.", ) def test_alphabet(self, mpalphabet_model): """This isn't actually a test, it's an informational routine.""" basealpha = numconv.BASE85 got_err = False last_good = None for alphabetlen in range(3, len(basealpha) + 1): alphabet = basealpha[0:alphabetlen] assert len(alphabet) >= 3 expected = [alphabet[0] + char for char in alphabet[1:]] expected.extend([alphabet[1] + char for char in alphabet]) expected.append(alphabet[2] + alphabet[0]) # remove all nodes mpalphabet_model.objects.all().delete() # change the model's alphabet mpalphabet_model.alphabet = alphabet mpalphabet_model.numconv_obj_ = None # insert root nodes for pos in range(len(alphabet) * 2): try: mpalphabet_model.add_root(numval=pos) except: got_err = True break if got_err: break got = [obj.path for obj in mpalphabet_model.objects.all()] if got != expected: break last_good = alphabet assert False, "Best BASE85 based alphabet for your setup: {} (base {})".format( last_good, len(last_good) ) @pytest.mark.django_db class TestHelpers(TestTreeBase): @staticmethod @pytest.fixture(scope="function", params=models.BASE_MODELS + models.PROXY_MODELS) def helpers_model(request): model = request.param model.load_bulk(BASE_DATA) for node in model.get_root_nodes(): model.load_bulk(BASE_DATA, node) model.add_root(desc="5") return model def test_descendants_group_count_root(self, helpers_model): expected = [ (o.desc, o.get_descendant_count()) for o in helpers_model.get_root_nodes() ] got = [ (o.desc, o.descendants_count) for o in helpers_model.get_descendants_group_count() ] assert got == expected def test_descendants_group_count_node(self, helpers_model): parent = helpers_model.get_root_nodes().get(desc="2") expected = [(o.desc, o.get_descendant_count()) for o in parent.get_children()] got = [ (o.desc, o.descendants_count) for o in helpers_model.get_descendants_group_count(parent) ] assert got == expected @pytest.mark.django_db class TestMP_TreeSortedAutoNow(TestTreeBase): """ The sorting mechanism used by treebeard when adding a node can fail if the ordering is using an "auto_now" field """ def test_sorted_by_autonow_workaround(self, mpsortedautonow_model): # workaround for i in range(1, 5): mpsortedautonow_model.add_root( desc="node%d" % (i,), created=datetime.datetime.now() ) def test_sorted_by_autonow_FAIL(self, mpsortedautonow_model): """ This test asserts that we have a problem. fix this, somehow """ mpsortedautonow_model.add_root(desc="node1") with pytest.raises(ValueError): mpsortedautonow_model.add_root(desc="node2") @pytest.mark.django_db class TestMP_TreeStepOverflow(TestTreeBase): def test_add_root(self, mpsmallstep_model): method = mpsmallstep_model.add_root for i in range(1, 10): method() with pytest.raises(PathOverflow): method() def test_add_child(self, mpsmallstep_model): root = mpsmallstep_model.add_root() method = root.add_child for i in range(1, 10): method() with pytest.raises(PathOverflow): method() def test_add_sibling(self, mpsmallstep_model): root = mpsmallstep_model.add_root() for i in range(1, 10): root.add_child() positions = ("first-sibling", "left", "right", "last-sibling") for pos in positions: with pytest.raises(PathOverflow): root.get_last_child().add_sibling(pos) def test_move(self, mpsmallstep_model): root = mpsmallstep_model.add_root() for i in range(1, 10): root.add_child() newroot = mpsmallstep_model.add_root() targets = [ (root, ["first-child", "last-child"]), ( root.get_first_child(), ["first-sibling", "left", "right", "last-sibling"], ), ] for target, positions in targets: for pos in positions: with pytest.raises(PathOverflow): newroot.move(target, pos) @pytest.mark.django_db class TestMP_TreeShortPath(TestTreeBase): """Test a tree with a very small path field (max_length=4) and a steplen of 1 """ def test_short_path(self, mpshortnotsorted_model): obj = mpshortnotsorted_model.add_root() obj = obj.add_child().add_child().add_child() with pytest.raises(PathOverflow): obj.add_child() @pytest.mark.django_db class TestMP_TreeFindProblems(TestTreeBase): def test_find_problems(self, mpalphabet_model): mpalphabet_model.alphabet = "01234" mpalphabet_model(path="01", depth=1, numchild=0, numval=0).save() mpalphabet_model(path="1", depth=1, numchild=0, numval=0).save() mpalphabet_model(path="111", depth=1, numchild=0, numval=0).save() mpalphabet_model(path="abcd", depth=1, numchild=0, numval=0).save() mpalphabet_model(path="qa#$%!", depth=1, numchild=0, numval=0).save() mpalphabet_model(path="0201", depth=2, numchild=0, numval=0).save() mpalphabet_model(path="020201", depth=3, numchild=0, numval=0).save() mpalphabet_model(path="03", depth=1, numchild=2, numval=0).save() mpalphabet_model(path="0301", depth=2, numchild=0, numval=0).save() mpalphabet_model(path="030102", depth=3, numchild=10, numval=0).save() mpalphabet_model(path="04", depth=10, numchild=1, numval=0).save() mpalphabet_model(path="0401", depth=20, numchild=0, numval=0).save() def got(ids): return [o.path for o in mpalphabet_model.objects.filter(pk__in=ids)] ( evil_chars, bad_steplen, orphans, wrong_depth, wrong_numchild, ) = mpalphabet_model.find_problems() assert ["abcd", "qa#$%!"] == got(evil_chars) assert ["1", "111"] == got(bad_steplen) assert ["0201", "020201"] == got(orphans) assert ["03", "0301", "030102"] == got(wrong_numchild) assert ["04", "0401"] == got(wrong_depth) @pytest.mark.django_db class TestMP_TreeFix(TestTreeBase): expected_no_holes = { models.MP_TestNodeShortPath: [ ("1", "b", 1, 2), ("11", "u", 2, 1), ("111", "i", 3, 1), ("1111", "e", 4, 0), ("12", "o", 2, 0), ("2", "d", 1, 0), ("3", "g", 1, 0), ("4", "a", 1, 4), ("41", "a", 2, 0), ("42", "a", 2, 0), ("43", "u", 2, 1), ("431", "i", 3, 1), ("4311", "e", 4, 0), ("44", "o", 2, 0), ], models.MP_TestSortedNodeShortPath: [ ("1", "a", 1, 4), ("11", "a", 2, 0), ("12", "a", 2, 0), ("13", "o", 2, 0), ("14", "u", 2, 1), ("141", "i", 3, 1), ("1411", "e", 4, 0), ("2", "b", 1, 2), ("21", "o", 2, 0), ("22", "u", 2, 1), ("221", "i", 3, 1), ("2211", "e", 4, 0), ("3", "d", 1, 0), ("4", "g", 1, 0), ], } expected_with_holes = { models.MP_TestNodeShortPath: [ ("1", "b", 1, 2), ("13", "u", 2, 1), ("134", "i", 3, 1), ("1343", "e", 4, 0), ("14", "o", 2, 0), ("2", "d", 1, 0), ("3", "g", 1, 0), ("4", "a", 1, 4), ("41", "a", 2, 0), ("42", "a", 2, 0), ("43", "u", 2, 1), ("434", "i", 3, 1), ("4343", "e", 4, 0), ("44", "o", 2, 0), ], models.MP_TestSortedNodeShortPath: [ ("1", "b", 1, 2), ("13", "u", 2, 1), ("134", "i", 3, 1), ("1343", "e", 4, 0), ("14", "o", 2, 0), ("2", "d", 1, 0), ("3", "g", 1, 0), ("4", "a", 1, 4), ("41", "a", 2, 0), ("42", "a", 2, 0), ("43", "u", 2, 1), ("434", "i", 3, 1), ("4343", "e", 4, 0), ("44", "o", 2, 0), ], } def got(self, model): return [ (o.path, o.desc, o.get_depth(), o.get_children_count()) for o in model.get_tree() ] def add_broken_test_data(self, model): model(path="4", depth=2, numchild=2, desc="a").save() model(path="13", depth=1000, numchild=0, desc="u").save() model(path="14", depth=4, numchild=500, desc="o").save() model(path="134", depth=321, numchild=543, desc="i").save() model(path="1343", depth=321, numchild=543, desc="e").save() model(path="42", depth=1, numchild=1, desc="a").save() model(path="43", depth=1000, numchild=0, desc="u").save() model(path="44", depth=4, numchild=500, desc="o").save() model(path="434", depth=321, numchild=543, desc="i").save() model(path="4343", depth=321, numchild=543, desc="e").save() model(path="41", depth=1, numchild=1, desc="a").save() model(path="3", depth=221, numchild=322, desc="g").save() model(path="1", depth=10, numchild=3, desc="b").save() model(path="2", depth=10, numchild=3, desc="d").save() def test_fix_tree_non_destructive(self, mpshort_model): self.add_broken_test_data(mpshort_model) mpshort_model.fix_tree(destructive=False) got = self.got(mpshort_model) expected = self.expected_with_holes[mpshort_model] assert got == expected mpshort_model.find_problems() def test_fix_tree_destructive(self, mpshort_model): self.add_broken_test_data(mpshort_model) mpshort_model.fix_tree(destructive=True) got = self.got(mpshort_model) expected = self.expected_no_holes[mpshort_model] assert got == expected mpshort_model.find_problems() def test_fix_tree_with_fix_paths(self, mpshort_model): self.add_broken_test_data(mpshort_model) mpshort_model.fix_tree(fix_paths=True) got = self.got(mpshort_model) expected = self.expected_no_holes[mpshort_model] assert got == expected mpshort_model.find_problems() @pytest.mark.django_db class TestIssues(TestTreeBase): # test for http://code.google.com/p/django-treebeard/issues/detail?id=14 def test_many_to_many_django_user_anonymous(self, mpm2muser_model): # Using AnonymousUser() in the querysets will expose non-treebeard # related problems in Django 1.0 # # Postgres: # ProgrammingError: can't adapt # SQLite: # InterfaceError: Error binding parameter 4 - probably unsupported # type. # MySQL compared a string to an integer field: # `treebeard_mp_testissue14_users`.`user_id` = 'AnonymousUser' # # Using a None field instead works (will be translated to IS NULL). # # anonuserobj = AnonymousUser() anonuserobj = None def qs_check(qs, expected): assert [o.name for o in qs] == expected def qs_check_first_or_user(expected, root, user): qs_check( root.get_children().filter(Q(name="first") | Q(users=user)), expected ) user = User.objects.create_user("test_user", "test@example.com", "testpasswd") user.save() root = mpm2muser_model.add_root(name="the root node") root.add_child(name="first") second = root.add_child(name="second") qs_check(root.get_children(), ["first", "second"]) qs_check(root.get_children().filter(Q(name="first")), ["first"]) qs_check(root.get_children().filter(Q(users=user)), []) qs_check_first_or_user(["first"], root, user) qs_check_first_or_user(["first", "second"], root, anonuserobj) user = User.objects.get(username="test_user") second.users.add(user) qs_check_first_or_user(["first", "second"], root, user) qs_check_first_or_user(["first"], root, anonuserobj) @pytest.mark.django_db class TestMoveNodeForm(TestNonEmptyTree): def _get_nodes_list(self, nodes): return [ (pk, "%s%s" % (" " * 4 * (depth - 1), str)) for pk, str, depth in nodes ] def _assert_nodes_in_choices(self, form, nodes): choices = form.fields["_ref_node_id"].choices assert choices.pop(0)[0] is None assert nodes == [(choice[0], choice[1]) for choice in choices] def _move_node_helper(self, node, safe_parent_nodes): form_class = movenodeform_factory(type(node)) form = form_class(instance=node) assert ["desc", "_position", "_ref_node_id"] == list(form.base_fields.keys()) got = [choice[0] for choice in form.fields["_position"].choices] assert ["first-child", "left", "right"] == got nodes = self._get_nodes_list(safe_parent_nodes) self._assert_nodes_in_choices(form, nodes) def _get_node_ids_strs_and_depths(self, nodes): return [(node.pk, str(node), node.get_depth()) for node in nodes] def test_form_root_node(self, model): nodes = list(model.get_tree()) node = nodes.pop(0) safe_parent_nodes = self._get_node_ids_strs_and_depths(nodes) self._move_node_helper(node, safe_parent_nodes) def test_form_leaf_node(self, model): nodes = list(model.get_tree()) safe_parent_nodes = self._get_node_ids_strs_and_depths(nodes) node = nodes.pop() self._move_node_helper(node, safe_parent_nodes) def test_form_admin(self, model): request = None nodes = list(model.get_tree()) safe_parent_nodes = self._get_node_ids_strs_and_depths(nodes) for node in model.objects.all(): site = AdminSite() form_class = movenodeform_factory(model) admin_class = admin_factory(form_class) ma = admin_class(model, site) got = list(ma.get_form(request).base_fields.keys()) desc_pos_refnodeid = ["desc", "_position", "_ref_node_id"] assert desc_pos_refnodeid == got got = ma.get_fieldsets(request) expected = [(None, {"fields": desc_pos_refnodeid})] assert got == expected got = ma.get_fieldsets(request, node) assert got == expected form = ma.get_form(request)() nodes = self._get_nodes_list(safe_parent_nodes) self._assert_nodes_in_choices(form, nodes) @pytest.mark.django_db class TestModelAdmin(TestNonEmptyTree): def test_default_fields(self, model): site = AdminSite() form_class = movenodeform_factory(model) admin_class = admin_factory(form_class) ma = admin_class(model, site) assert list(ma.get_form(None).base_fields.keys()) == [ "desc", "_position", "_ref_node_id", ] @pytest.mark.django_db class TestSortedForm(TestTreeSorted): def test_sorted_form(self, sorted_model): sorted_model.add_root(val1=3, val2=3, desc="zxy") sorted_model.add_root(val1=1, val2=4, desc="bcd") sorted_model.add_root(val1=2, val2=5, desc="zxy") sorted_model.add_root(val1=3, val2=3, desc="abc") sorted_model.add_root(val1=4, val2=1, desc="fgh") sorted_model.add_root(val1=3, val2=3, desc="abc") sorted_model.add_root(val1=2, val2=2, desc="qwe") sorted_model.add_root(val1=3, val2=2, desc="vcx") form_class = movenodeform_factory(sorted_model) form = form_class() assert list(form.fields.keys()) == [ "val1", "val2", "desc", "_position", "_ref_node_id", ] form = form_class(instance=sorted_model.objects.get(desc="bcd")) assert list(form.fields.keys()) == [ "val1", "val2", "desc", "_position", "_ref_node_id", ] assert "id__position" in str(form) assert "id__ref_node_id" in str(form) @pytest.mark.django_db class TestForm(TestNonEmptyTree): def test_form(self, model): form_class = movenodeform_factory(model) form = form_class() assert list(form.fields.keys()) == ["desc", "_position", "_ref_node_id"] form = form_class(instance=model.objects.get(desc="1")) assert list(form.fields.keys()) == ["desc", "_position", "_ref_node_id"] assert "id__position" in str(form) assert "id__ref_node_id" in str(form) def test_move_node_form(self, model): form_class = movenodeform_factory(model) bad_node = model.objects.get(desc="1").add_child( desc='Benign' ) form = form_class(instance=bad_node) rendered_html = form.as_p() assert "Benign" in rendered_html assert "