pax_global_header00006660000000000000000000000064151456120520014513gustar00rootroot0000000000000052 comment=0a554030413ab8c39b8f353adca375b927c5fe10 django-treebeard-django-treebeard-0a55403/000077500000000000000000000000001514561205200203465ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/.coveragerc000066400000000000000000000006211514561205200224660ustar00rootroot00000000000000[run] branch = True source = treebeard parallel = True data_file = .tests/coverage [paths] source = ./ *\workspace\django-treebeard\tox_db\*\tox_django\*\tox_python\*\os\windows/ */jobs/django-treebeard/workspace/TOX_DB/*/TOX_DJANGO/*/TOX_PYTHON/*/os/osx/ */workspace/django-treebeard/TOX_DB/*/TOX_DJANGO/*/TOX_PYTHON/*/os/linux/ [report] omit = */tests/* */numconv.py precision = 2 django-treebeard-django-treebeard-0a55403/.github/000077500000000000000000000000001514561205200217065ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/.github/workflows/000077500000000000000000000000001514561205200237435ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/.github/workflows/publish.yml000066400000000000000000000013611514561205200261350ustar00rootroot00000000000000name: Publish django-treebeard on: push: tags: - '*' jobs: publish: name: "Publish release" runs-on: "ubuntu-latest" environment: name: deploy steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install build --user - name: Build 🐍 Python 📦 Package run: python -m build --sdist --wheel --outdir dist/ - name: Publish 🐍 Python 📦 Package to PyPI if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@v1.13.0 with: password: ${{ secrets.PYPI_API_TOKEN_TREEBEARD }} django-treebeard-django-treebeard-0a55403/.github/workflows/test.yml000066400000000000000000000133721514561205200254530ustar00rootroot00000000000000name: Test django-treebeard on: push: branches: - master pull_request: branches: - master concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: lint-python: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 with: ref: ${{ github.ref }} - uses: astral-sh/ruff-action@v3 with: version: "0.14.x" - run: ruff check --fix - run: ruff format --check --diff test-sqlite: name: SQlite, Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} runs-on: ubuntu-latest strategy: fail-fast: true matrix: python-version: [3.12, 3.14] django-version: [52, 60] steps: - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install dependencies run: pip install tox - name: Run tests run: tox -e "py-dj${{ matrix.django-version }}-sqlite" test-postgres: name: Postgres, Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} runs-on: ubuntu-latest strategy: fail-fast: true matrix: python-version: [3.12, 3.14] django-version: [52, 60] services: postgres: image: postgres:15 ports: - 5432/tcp options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 env: POSTGRES_USER: root POSTGRES_PASSWORD: treebeard POSTGRES_DB: treebeard steps: - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install dependencies run: pip install tox - name: Run tests env: DATABASE_USER_POSTGRES: root DATABASE_PASSWORD: treebeard DATABASE_HOST: 127.0.0.1 DATABASE_PORT_POSTGRES: ${{ job.services.postgres.ports[5432] }} # get randomly assigned published port # Postgres covers the entire codebase, so we enforce a coverage check run: tox -e "py-dj${{ matrix.django-version }}-postgres" -- --cov-fail-under 96 test-mysql: name: MySQL, Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} runs-on: ubuntu-latest strategy: fail-fast: true matrix: python-version: [3.12, 3.14] django-version: [52, 60] services: mysql: image: mysql:9.5 options: --health-cmd="mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5 env: MYSQL_ROOT_PASSWORD: treebeard ports: - 3306/tcp steps: - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install dependencies run: pip install tox - name: Run tests env: DATABASE_USER_MYSQL: root DATABASE_PASSWORD: treebeard DATABASE_HOST: 127.0.0.1 DATABASE_PORT_MYSQL: ${{ job.services.mysql.ports[3306] }} # get randomly assigned published port run: tox -e "py-dj${{ matrix.django-version }}-mysql" test-mariadb: name: MariaDB, Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} runs-on: ubuntu-latest strategy: fail-fast: true matrix: python-version: [3.12, 3.14] django-version: [52, 60] services: mysql: image: mariadb:10.6 options: --health-cmd="healthcheck.sh --connect" --health-interval 10s --health-timeout 5s --health-retries 5 env: MYSQL_ROOT_PASSWORD: treebeard ports: - 3306/tcp steps: - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install dependencies run: pip install tox - name: Run tests env: DATABASE_USER_MYSQL: root DATABASE_PASSWORD: treebeard DATABASE_HOST: 127.0.0.1 DATABASE_PORT_MYSQL: ${{ job.services.mysql.ports[3306] }} # get randomly assigned published port run: tox -e "py-dj${{ matrix.django-version }}-mysql" test-mssql: name: MSSQL, Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} runs-on: ubuntu-latest strategy: fail-fast: true matrix: python-version: [3.12, 3.14] django-version: [52] # mssql-django doesn't yet support Django 6 services: mssql: image: mcr.microsoft.com/mssql/server:2019-latest env: SA_PASSWORD: Password12! ACCEPT_EULA: 'Y' ports: - 1433/tcp steps: - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install dependencies run: pip install tox - name: Install ODBC driver for MSSQL run: | curl -sSL -O https://packages.microsoft.com/config/ubuntu/$(grep VERSION_ID /etc/os-release | cut -d '"' -f 2)/packages-microsoft-prod.deb sudo dpkg -i packages-microsoft-prod.deb sudo apt-get update sudo ACCEPT_EULA=Y apt-get install msodbcsql18 mssql-tools18 -y - name: Run tests env: DATABASE_PORT_MSSQL: ${{ job.services.mssql.ports[1433] }} # get randomly assigned published port run: tox -e "py-dj${{ matrix.django-version }}-mssql"django-treebeard-django-treebeard-0a55403/.gitignore000066400000000000000000000004451514561205200223410ustar00rootroot00000000000000.DS_Store .buildinfo .tests *.pyc *.orig *.swp .coverage build dist _build MANIFEST .project .pydevproject .settings htmlcov .tox *.egg-info .cache .idea .ropeproject *.coverage *~ *.sqlite *coverage.xml *egg-info* docs/build test-reports bin/ include/ lib/ distribute* share/ venv/ .DS_store django-treebeard-django-treebeard-0a55403/.pre-commit-config.yaml000066400000000000000000000003251514561205200246270ustar00rootroot00000000000000repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: v0.14.0 hooks: # Run the linter. - id: ruff-check args: [ --fix ] # Run the formatter. - id: ruff-formatdjango-treebeard-django-treebeard-0a55403/.readthedocs.yaml000066400000000000000000000003141514561205200235730ustar00rootroot00000000000000version: 2 build: os: ubuntu-24.04 tools: python: "3.12" apt_packages: - graphviz sphinx: configuration: docs/source/conf.py python: install: - requirements: docs/requirements.txtdjango-treebeard-django-treebeard-0a55403/AUTHORS000066400000000000000000000010341514561205200214140ustar00rootroot00000000000000Treebeard was created in 2008 by Gustavo Picon. Contributions made by: * Aureal * Jean-Matthieu Barbier * Jesus del Carpio * chembervint * Matt Hoskins * Rob Hudson * Alexey Kinyov * Martin Koistinen * omad * Oregon Center for Applied Science * Alejandro Peralta * Jaap Roes * Alexei Vlasov * moberley * czare1 * Fernando Gutierrez (xbito) * Jens Diemer * Jacob Rief * Maik Hoepfel * Samir Shah * Johannes Wilm * Awais Qureshi * Julian Wachholz django-treebeard-django-treebeard-0a55403/CHANGES.md000066400000000000000000000320211514561205200217360ustar00rootroot00000000000000Release 5.0.5 (Feb 19, 2026) ------------------------------ Treebeard 5.0.5 is a bugfix release. * Reverted change to lock root nodes when adding a new root, which had unwanted performance implications. Release 5.0.4 (Feb 19, 2026) ------------------------------ Treebeard 5.0.4 is a bugfix release. * Fixed `TypeError` when adding root nodes for MP and LT trees with `node_order_by` set. Release 5.0.3 (Feb 18, 2026) ------------------------------ Treebeard 5.0.3 is a bugfix release. * Added row locks to prevent potential race conditions when concurrently calling `add_child()` on the same node, or when concurrently adding root nodes. Release 5.0.2 (Feb 13, 2026) ------------------------------ Treebeard 5.0.2 is a bugfix release. * MP and NS nodes are refreshed from the database after a move, for a better developer experience. Previously it was left to the developer to refresh manually if they needed to use the node, and this was the source of numerous issues. * Fixed handling of reverse ordering in `node_order_by`. * Fixed handling of inherited models in `TreeAdmin`. * Fixed adding root nodes for inherited models. * Handled null values of fields specified in `node_order_by` more gracefully: ignore the field for the purpose of ordering and log a warning to indicate that the value likely needs to be provided manually. * Modified `dump_bulk()` methods to use a queryset iterator to avoid loading large datasets into memory. * Fixed import error with Django 6 if `pyscopg` was not installed. Release 5.0.1 (Feb 11, 2026) ---------------------------- Treebeard 5.0.1 is a bugfix release. * Fixed count aggregations not working on MariaDB. Release 5.0.0 (Feb 11, 2026) ---------------------------- Treebeard 5.0 is a major release with a number of significant changes: * All operations that previously used raw SQL queries were rewritten to use the Django ORM. This provides better security, portability across backends, and compability for multiple database setups. * An experimental implementation using PostgreSQL Ltree was added. Backward incompatible changes: * Internal fields used by Treebeard's `MoveNodeForm` have been renamed from `_position` to `treebeard_position` and `_ref_node_id` to `treebeard_ref_node`. * Changed initialisation signatures for the internal `MP_AddChildHandler` and `MP_AddSiblingHandler` to avoid collisions with model field names. Both constructors now expect a mapping of model creation arguments as a single parameter, instead of keywords arguments passed to the constructor. * `MoveNodeForm` has been refactored to use a `ModelChoiceField` for selecting the relative node. This field can be used by projects, e.g., for foreign keys in the Django admin. * Removed the `Node.get_database_vendor()` helper function which is no longer used. * The deprecated `destructive` argument was removed from `MP_Node.fix_tree()` use `fix_paths` instead. Other changes: * Added support for Python 3.14. * Added support for Django 6.0. * Dropped support for Django 4.2 and 5.1. * All node create and update operations are now run in a transaction to mitigate against race conditions. * Added a `parent` argument to `MP_Node.fix_tree` to allow fixing only a portion of a tree. Release 4.8.0 (Dec 3, 2025) ---------------------------- * Add support for Django 5.2, and Python 3.13. * Drop support for Django 4.1 and 5.0. * Refactor Django admin integration to be simpler, and more resilient to upstream changes. * Add `include_self` option to `get_descendants` method. * Fix KeyError in MP_Node.dump_bulk if ordering differs from depth, path. * Exclude tests from packaged wheel distribution of django-treebeard Release 4.7.1 (Jan 31, 2024) ---------------------------- * Fix: Allow usage of CSRF_COOKIE_HTTPONLY setting. * Add support for Django-5.0. Release 4.7 (Apr 7, 2023) ---------------------------- * Drop support for Django 4.0. * Add support for Django 4.2. Release 4.6.1 (Feb 5, 2023) ---------------------------- * Fix unescaped string representation of `AL_Node` models in the Django admin. Thanks to goodguyandy for reporting the issue. * Optimise `MP_Node.get_descendants` to avoid database queries when called on a leaf node. Release 4.6 (Jan 2, 2023) ---------------------------- * Drop support for Django 3.1 and lower. * Add support for Django 4.0 and 4.1. * Drop support for Python 3.7 and lower. * Add support for Python 3.10 and Python 3.11. * Change the return value of `delete()` for all node classes to be consistent with Django, and return a tuple of the number of objects deleted and a dictionary with the number of deletions per object type. * Change the `delete()` methods for all node classes to accept arbitrary positional and keyword arguments which are passed to the parent method. * Set `alters_data` and `queryset_only` attributes on the `delete()` methods for all node classes to prevent them being used in an unwanted context (e.g., in Django templates). * Drop dependency on jQuery UI in the admin. Release 4.5.1 (Feb 22, 2021) ---------------------------- * Removed unnecessary default in MP's depth field. Release 4.5 (Feb 17, 2021) -------------------------- * Add support for custom primary key fields with custom names. * Add support for Python 3.9. * Add support for MSSQL 2019. * Add Code of conduct * Removed outdated Sqlite workaround code * Remove last remains of Python 2.7 code * Use Pytest-django and fixtures for testing Release 4.4 (Jan 13, 2021) ---------------------------- * Implement a non-destructive path-fixing algorithm for `MP_Node.fix_tree`. * Ensure `post_save` is triggered *after* the parent node is updated in `MP_AddChildHandler`. * Fix static URL generation to use `static` template tag instead of constructing the URL manually. * Declare support for Django 2.2, 3.0 and 3.1. * Drop support for Django 2.1 and lower. * Drop support for Python 2.7 and Python 3.5. * Increase performance for `MoveNodeForm` when using large trees. Release 4.3.1 (Dec 25, 2019) ---------------------------- * Added check to avoid unnecessary database query for `MP_Node.get_ancestors()` if the node is a root node. * Drop support for Python-3.4. * Play more nicely with other form classes, that implement `__init__(self, *args, **kwargs)`, e.g. django-parler's `TranslatableModelForm`, where `kwargs.get('instance')` is `None` when called from here. * Sorting on path on necessary queries, fixes some issues and stabilizes the whole MP section. * Add German translation strings. Release 4.3 (Apr 16, 2018) -------------------------- * Support for Django-2.0 Release 4.2.2 (Mar 11, 2018) ---------------------------- * Bugfix issues #97: UnboundLocalError raised on treebeard admin Release 4.2.1 (Mar 9, 2018) ---------------------------- * Bugfix issues #90: admin change list view and jsi18n load for Django-1.11 Release 4.2.0 (Dec 8, 2017) ---------------------------- * Support for Django-2.0 Release 4.1.2 (Jun 22, 2017) ---------------------------- * Fixed MANIFEST.in for Debian packaging. Release 4.1.1 (May 24, 2017) ---------------------------- * Removed deprecated templatetag inclusion * Added support for Python-3.6 * Added support for MS-SQL Release 4.1.0 (Nov 24, 2016) ---------------------------- * Add support for Django-1.10 * Drop support for Django-1.7 * Moved Repository from Bitbucket to GitHub * Moved documentation to https://django-treebeard.readthedocs.io/ * Moved continuous integration to https://travis-ci.org/django-treebeard/django-treebeard Release 4.0.1 (May 1, 2016) --------------------------- * Escape input in forms (Martin Koistinen / Divio) * Clarification on model detail pages (Michael Huang) Release 4.0 (Dec 28, 2015) -------------------------- * Added support for 3.5 and Django 1.7, 1.8 and 1.9 * Django 1.6 is no longer supported. * Remove deprecated backports needed for now unsupported Django versions * Fixed a bug with queryset deletion not handling inheritance correctly. * Assorted documentation fixes Release 3.0 (Jan 18, 2015) -------------------------- * Limited tests (and hence support) to Python 2.7+/3.4+ and Django 1.6+ * Removed usage of deprecated Django functions. * Fixed documentation issues. * Fixed issues in MoveNodeForm * Added get_annotated_list_qs and max_depth for get_annotated_list Release 2.0 (April 2, 2014) --------------------------- * Stable release. Release 2.0rc2 (March, 2014) ---------------------------- * Support models that use multi-table inheritance (Matt Wescott) * Tree methods called on proxy models should consistently return instances of that proxy model (Matt Wescott) Release 2.0rc1 (February, 2014) ------------------------------- * Fixed unicode related issue in the template tags. * Major documentation cleanup. * More warnings on the use of managers. * Faster MP's is_root() method. Release 2.0b2 (December, 2013) ------------------------------ * Dropped support for Python 2.5 Release 2.0b1 (May 29, 2013) ---------------------------- This is a beta release. * Added support for Django 1.5 and Python 3.X * Updated docs: the library supports python 2.5+ and Django 1.4+. Dropped support for older versions * Revamped admin interface for MP and NS trees, supporting drag&drop to reorder nodes. Work on this patch was sponsored by the `Oregon Center for Applied Science`_, inspired by `FeinCMS`_ developed by `Jesús del Carpio`_ with tests from `Fernando Gutierrez`_. Thanks ORCAS! * Updated setup.py to use distribute/setuptools instead of distutils * Now using pytest for testing * Small optimization to ns_tree.is_root * Moved treebeard.tests to it's own directory (instead of tests.py) * Added the runtests.py test runner * Added tox support * Fixed drag&drop bug in the admin * Fixed a bug when moving MP_Nodes * Using .pk instead of .id when accessing nodes. * Removed the Benchmark (tbbench) and example (tbexample) apps. * Fixed url parts join issues in the admin. * Fixed: Now installing the static resources * Fixed ManyToMany form field save handling * In the admin, the node is now saved when moving so it can trigger handlers and/or signals. * Improved translation files, including javascript. * Renamed Node.get_database_engine() to Node.get_database_vendor(). As the name implies, it returns the database vendor instead of the engine used. Treebeard will get the value from Django, but you can subclass the method if needed. Release 1.61 (Jul 24, 2010) --------------------------- * Added admin i18n. Included translations: es, ru * Fixed a bug when trying to introspect the database engine used in Django 1.2+ while using new style db settings (DATABASES). Added Node.get_database_engine to deal with this. Release 1.60 (Apr 18, 2010) --------------------------- * Added get_annotated_list * Complete revamp of the documentation. It's now divided in sections for easier reading, and the package includes .rst files instead of the html build. * Added raw id fields support in the admin * Fixed setup.py to make it work in 2.4 again * The correct ordering in NS/MP trees is now enforced in the queryset. * Cleaned up code, removed some unnecessary statements. * Tests refactoring, to make it easier to spot the model being tested. * Fixed support of trees using proxied models. It was broken due to a bug in Django. * Fixed a bug in add_child when adding nodes to a non-leaf in sorted MP. * There are now 648 unit tests. Test coverage is 96% * This will be the last version compatible with Django 1.0. There will be a a 1.6.X branch maintained for urgent bug fixes, but the main development will focus on recent Django versions. Release 1.52 (Dec 18, 2009) --------------------------- * Really fixed the installation of templates. Release 1.51 (Dec 16, 2009) --------------------------- * Forgot to include treebeard/tempates/\*.html in MANIFEST.in Release 1.5 (Dec 15, 2009) -------------------------- New features added ~~~~~~~~~~~~~~~~~~ * Forms - Added MoveNodeForm * Django Admin - Added TreeAdmin * MP_Node - Added 2 new checks in MP_Node.find_problems(): 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 - Added a new (safer and faster but less comprehensive) MP_Node.fix_tree() approach. * Documentation - Added warnings in the documentation when subclassing MP_Node or NS_Node and adding a new Meta. - HTML documentation is now included in the package. - CHANGES file and section in the docs. * Other changes: - script to build documentation - updated numconv.py Bugs fixed ~~~~~~~~~~ * Added table quoting to all the sql queries that bypass the ORM. Solves bug in postgres when the table isn't created by syncdb. * Removing unused method NS_Node._find_next_node * Fixed MP_Node.get_tree to include the given parent when given a leaf node Release 1.1 (Nov 20, 2008) -------------------------- Bugs fixed ~~~~~~~~~~ * Added exceptions.py Release 1.0 (Nov 19, 2008) -------------------------- * First public release. .. _Oregon Center for Applied Science: http://www.orcasinc.com/ .. _FeinCMS: http://www.feincms.org .. _Jesús del Carpio: http://www.isgeek.net .. _Fernando Gutierrez: http://xbito.pe django-treebeard-django-treebeard-0a55403/CODE_OF_CONDUCT.md000066400000000000000000000064251514561205200231540ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at treebeard@tabo.pe. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq django-treebeard-django-treebeard-0a55403/LICENSE000066400000000000000000000261371514561205200213640ustar00rootroot00000000000000 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. django-treebeard-django-treebeard-0a55403/MANIFEST.in000066400000000000000000000004531514561205200221060ustar00rootroot00000000000000include CHANGES LICENSE 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 recursive-include tests *.pydjango-treebeard-django-treebeard-0a55403/README.md000066400000000000000000000027471514561205200216370ustar00rootroot00000000000000# django-treebeard **django-treebeard** is a library that implements efficient tree implementations for the Django Web Framework. It was 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/actions/workflows/test.yml/badge.svg)]() [![PyPI](https://img.shields.io/pypi/pyversions/django-treebeard.svg)]() ![PyPI - Django Version](https://img.shields.io/pypi/frameworkversions/django/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 4. PostgreSQL ltree (experimental) - **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 at ### Supported versions **django-treebeard** officially supports - Django 5.2 and higher - Python 3.10 and higher - PostgreSQL, MySQL, MSSQL, SQLite database back-ends. django-treebeard-django-treebeard-0a55403/SECURITY.md000066400000000000000000000005111514561205200221340ustar00rootroot00000000000000# Security Policy ## Reporting a Vulnerability To report a vulnerability with this project, please submit details of the issue [using this form](https://forms.gle/gdcjc9yDcPyugVx97). If you provide your contact information, a member of the team will acknlowledge your submission and let you know how it is being investigated. django-treebeard-django-treebeard-0a55403/UPDATING000066400000000000000000000007421514561205200215070ustar00rootroot00000000000000This 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. django-treebeard-django-treebeard-0a55403/docs/000077500000000000000000000000001514561205200212765ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/docs/Makefile000066400000000000000000000164421514561205200227450ustar00rootroot00000000000000# 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." django-treebeard-django-treebeard-0a55403/docs/README.md000066400000000000000000000010061514561205200225520ustar00rootroot00000000000000This 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 ```django-treebeard-django-treebeard-0a55403/docs/make.bat000066400000000000000000000161511514561205200227070ustar00rootroot00000000000000@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 django-treebeard-django-treebeard-0a55403/docs/requirements.txt000066400000000000000000000000041514561205200245540ustar00rootroot00000000000000-e .django-treebeard-django-treebeard-0a55403/docs/source/000077500000000000000000000000001514561205200225765ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/docs/source/_ext/000077500000000000000000000000001514561205200235355ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/docs/source/_ext/djangodocs.py000066400000000000000000000003601514561205200262210ustar00rootroot00000000000000# 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", ) django-treebeard-django-treebeard-0a55403/docs/source/_static/000077500000000000000000000000001514561205200242245ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/docs/source/_static/treebeard-admin-advanced.png000066400000000000000000003434631514561205200315350ustar00rootroot00000000000000PNG  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`django-treebeard-django-treebeard-0a55403/docs/source/_static/treebeard-admin-basic.png000066400000000000000000002735151514561205200310510ustar00rootroot00000000000000PNG  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`django-treebeard-django-treebeard-0a55403/docs/source/admin.rst000066400000000000000000000050611514561205200244220ustar00rootroot00000000000000Admin ===== 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 'treebeard_position' and 'treebeard_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', 'treebeard_position', 'treebeard_ref_node_id',) form = movenodeform_factory(MyNode) admin.site.register(MyNode, MyAdmin) Foreign keys and One-to-one relationships ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If your project contains models that have a foreign key or one-to-one relationship with a tree model, you can leverage ``TreeNodeChoiceField`` to display the choices nicely in the Django admin. Given the following models: .. code-block:: python class TreeNode(MP_Node): ... class RelatedModel(models.Model): tree_node = models.ForeignKey("TreeNode") You can configure the admin form for ``RelatedModel`` as follows for it to render the choices for ``tree_node`` in a nested list: .. code-block:: python class RelatedModelAdminForm(forms.ModelForm): tree_node = TreeNodeChoiceField(queryset=TreeNode.objects.all()) class RelatedModelAdmin(admin.ModelAdmin): form = RelatedModelAdminForm admin.site.register(MyNode, MyAdmin) .. warning:: ``TreeNodeChoiceField`` should not be used with AL nodes, because they cannot be queried efficiently in this context.django-treebeard-django-treebeard-0a55403/docs/source/al_tree.rst000066400000000000000000000074301514561205200247470ustar00rootroot00000000000000Adjacency 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'] .. warning:: ``node_order_by`` values are used to determine correct node ordering *before* an object is inserted/moved. This means any fields that are auto-populated at a database level, e.g., ``AutoField()``, or ``DateTimeField(auto_now=True)`` will be ignored for the purpose of ordering if a value isn't provided manually. .. 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: django-treebeard-django-treebeard-0a55403/docs/source/api.rst000066400000000000000000000177461514561205200241200ustar00rootroot00000000000000API === .. 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 .. _django-mptt: https://github.com/django-mptt/django-mptt/django-treebeard-django-treebeard-0a55403/docs/source/caveats.rst000066400000000000000000000043351514561205200247630ustar00rootroot00000000000000Known Caveats ============= Updating objects ---------------- The nature of tree data means that many operations (e.g., adding a child to a tree node) affect related objects (e.g., the parent node). For add/move operations, Treebeard will refresh the node being moved/created and its target. If you have other potentially affected nodes (e.g., siblings) in memory and plan to use them after a tree modification, you may need to reload them with ``object_instance.refresh_from_db()``. Inconsistent state ------------------ The nature of tree implementations means that updating one object in a tree frequently requires making updates to several other objects (e.g., parents, siblings, children) in order to ensure efficiency of querying. Treebeard wraps all create/update operations in a database transaction to minimise the impact of race conditions, but it is still possible for data to end up in an inconsistent state in cases where large numbers of concurrent writes take place. Projects may wish to consider overriding Treebeard methods to apply additional locks (e.g., lock the whole table when performing a move) to further reduce the chance of inconsistencies. This comes with a potentially significant performance penalty. ``MP_Node`` ships with a ``fix_tree()`` method that can be used to find and correct inconsistencies in Materialized Path trees. 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 *MUST* 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 *MUST* subclass the manager from the base manager class for the tree you are using. Read the documentation in each tree type for details. django-treebeard-django-treebeard-0a55403/docs/source/changes.rst000066400000000000000000000000631514561205200247370ustar00rootroot00000000000000Changelog ========= .. include:: ../../CHANGES.md django-treebeard-django-treebeard-0a55403/docs/source/conf.py000066400000000000000000000232161514561205200241010ustar00rootroot00000000000000# # 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 = "django-treebeard" copyright = "2016, Gustavo Picón" author = "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", "django-treebeard Documentation", "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", "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", "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.13", None), } django-treebeard-django-treebeard-0a55403/docs/source/exceptions.rst000066400000000000000000000003651514561205200255150ustar00rootroot00000000000000Exceptions ========== .. module:: treebeard.exceptions .. autoexception:: InvalidPosition .. autoexception:: InvalidMoveToDescendant .. autoexception:: NodeAlreadySaved .. autoexception:: PathOverflow .. autoexception:: MissingNodeOrderBy django-treebeard-django-treebeard-0a55403/docs/source/forms.rst000066400000000000000000000011401514561205200244520ustar00rootroot00000000000000Forms ===== .. 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') django-treebeard-django-treebeard-0a55403/docs/source/index.rst000066400000000000000000000023301514561205200244350ustar00rootroot00000000000000django-treebeard ================ `django-treebeard `_ is a library that implements efficient tree implementations for the `Django Web Framework `_, originally written by `Gustavo Picón `_ and licensed under the Apache License 2.0. ``django-treebeard`` is: - **Flexible**: Includes 4 different tree implementations with the same API: 1. :doc:`Adjacency List ` 2. :doc:`Materialized Path ` 3. :doc:`Nested Sets ` 4. :doc:`PostgreSQL Ltree ` (experimental) - **Fast**: Optimized non-naive tree operations - **Easy**: Uses Django's :ref:`model-inheritance` to define your own models. - **Clean**: Testable and well tested code base. Code test coverage is above 96%. Overview -------- .. toctree:: install tutorial caveats .. toctree:: :titlesonly: changes Reference --------- .. toctree:: api mp_tree ns_tree al_tree ltree exceptions Additional features ------------------- .. toctree:: admin forms Development ----------- .. toctree:: tests Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` django-treebeard-django-treebeard-0a55403/docs/source/install.rst000066400000000000000000000016031514561205200247760ustar00rootroot00000000000000Installation ============ Prerequisites ------------- ``django-treebeard`` needs at least **Python 3.10** to run, and **Django 5.2 or later**. Installing ---------- You can install the release versions from `django-treebeard's PyPI page`_ using ``pip``: .. code-block:: console $ pip install django-treebeard .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. .. _`django-treebeard's PyPI page`: https://pypi.org/project/django-treebeard/ django-treebeard-django-treebeard-0a55403/docs/source/ltree.rst000066400000000000000000000064551514561205200244550ustar00rootroot00000000000000PostgreSQL Ltree trees (experimental) ===================================== .. module:: treebeard.ltree This is an efficient tree implementation using PostgreSQL's `ltree`_ module. It requires a PostgreSQL database. This is currently an experimental implementation, open for testing and feedback from the community. Treebeard uses a simple alphabet to generate path hierarchies for objects in the database. In order to ensure efficient ordering, it uses an approach similar to the Materialized Path implementation to use path values that match the desired order of nodes in the database. To use the ``ltree`` module, you need to create the extension in your database: .. code-block:: psql CREATE EXTENSION IF NOT EXISTS ltree; .. warning:: As with all tree implementations, please be aware of the :doc:`caveats`. .. inheritance-diagram:: LT_Node .. autoclass:: LT_Node :show-inheritance: .. warning:: Do not change the values of :attr:`path` directly. Use one of the included methods instead. Consider these values *read-only*. .. warning:: Do not change the values of :attr:`node_order_by` after saving your first object. Doing so will result in objects being ordered incorrectly. .. warning:: If you need to define your own :py:class:`~django.db.models.Manager` class, you'll need to subclass :py:class:`~LT_NodeManager`. Also, if in your manager you need to change the default queryset handler, you'll need to subclass :py:class:`~LT_NodeQuerySet`. Example: .. code-block:: python class SortedNode(LT_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:: 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. This takes precedence over drag and drop ordering in the Django admin. Example: .. code-block:: python node_order_by = ['field1', 'field2', 'field3'] .. warning:: ``node_order_by`` values are used to determine correct node ordering *before* an object is inserted/moved. This means any fields that are auto-populated at a database level, e.g., ``AutoField()``, or ``DateTimeField(auto_now=True)`` will be ignored for the purpose of ordering if a value isn't provided manually. .. attribute:: path ``ltree`` field, stores an ltree hierarchy for the node. The values are auto-generated by Treebeard from a simple alphabet. .. 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` .. autoclass:: LT_NodeManager :show-inheritance: .. autoclass:: LT_NodeQuerySet :show-inheritance: .. _`ltree`: https://www.postgresql.org/docs/18/ltree.htmldjango-treebeard-django-treebeard-0a55403/docs/source/mp_tree.rst000066400000000000000000000223771514561205200247760ustar00rootroot00000000000000Materialized 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 will attempt to suggest an 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-Z 36 ================ ================ ==== The default value is MySQL's since it will work for 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`. .. warning:: If you use a custom alphabet, you must ensure that the order of characters in the alphabet matches the sort order of your database collation. Also note that PostgreSQL relies on the collation provided by the underlying operating system, yielding inconsistent results on different systems if both upper and lower case characters are included in the alphabet. See https://github.com/PostgresApp/PostgresApp/issues/216 and https://dba.stackexchange.com/questions/106964/why-is-my-postgresql-order-by-case-insensitive. .. 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. This takes precedence over drag and drop ordering in the Django admin. Example: .. code-block:: python node_order_by = ['field1', 'field2', 'field3'] .. warning:: ``node_order_by`` values are used to determine correct node ordering *before* an object is inserted/moved. This means any fields that are auto-populated at a database level, e.g., ``AutoField()``, or ``DateTimeField(auto_now=True)`` will be ignored for the purpose of ordering if a value isn't provided manually. .. 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/ django-treebeard-django-treebeard-0a55403/docs/source/ns_tree.rst000066400000000000000000000042151514561205200247710ustar00rootroot00000000000000Nested 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'] .. warning:: ``node_order_by`` values are used to determine correct node ordering *before* an object is inserted/moved. This means any fields that are auto-populated at a database level, e.g., ``AutoField()``, or ``DateTimeField(auto_now=True)`` will be ignored for the purpose of ordering if a value isn't provided manually. .. 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 django-treebeard-django-treebeard-0a55403/docs/source/tests.rst000066400000000000000000000026471514561205200245030ustar00rootroot00000000000000Running 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.10 - 3.13 - Django 5.2 and above - 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 py313-dj52-postgres .. _pytest: http://pytest.org/ .. _tox: https://tox.readthedocs.io/en/latest/index.html django-treebeard-django-treebeard-0a55403/docs/source/tutorial.rst000066400000000000000000000062321514561205200251760ustar00rootroot00000000000000Tutorial ======== 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) 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. django-treebeard-django-treebeard-0a55403/pyproject.toml000066400000000000000000000026251514561205200232670ustar00rootroot00000000000000[build-system] requires = ["setuptools>=62.4.0", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = ["treebeard"] [tool.setuptools.dynamic] version = {attr = "treebeard.__version__"} [project] name = "django-treebeard" description = "Efficient tree implementations for Django" readme = "README.md" license = "Apache-2.0" requires-python = ">=3.10" dynamic = ["version"] authors = [ {name = "Gustavo Picon", email = "tabo@tabo.pe"} ] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Environment :: Web Environment", "Framework :: Django", "Framework :: Django :: 5.2", "Framework :: Django :: 6.0", "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries", "Topic :: Utilities" ] dependencies = [ "django>=5.2", ] [project.optional-dependencies] test = [ "pytest-django>=4.0,<5.0", "pytest-pythonpath>=0.7,<1.0", ] [tool.ruff] line-length = 120 lint.select = [ "PLE", # pylint errors "UP", # pyupgrade "I", # isort "F", # pyflakes "E", # pycodestyle errors ]django-treebeard-django-treebeard-0a55403/pytest.ini000066400000000000000000000001721514561205200223770ustar00rootroot00000000000000[pytest] DJANGO_SETTINGS_MODULE = tests.settings python_files = tests.py test_*.py *_tests.py django_find_project = false django-treebeard-django-treebeard-0a55403/requirements.txt000066400000000000000000000002271514561205200236330ustar00rootroot00000000000000# These requirements are only necessary when developing on treebeard. # Developing ipython ipdb django_extensions psycopg[binary] # Testing tox>=4.0 django-treebeard-django-treebeard-0a55403/tests/000077500000000000000000000000001514561205200215105ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/tests/__init__.py000066400000000000000000000000001514561205200236070ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/tests/admin.py000066400000000000000000000010101514561205200231420ustar00rootroot00000000000000from django.contrib import admin from tests.models import BASE_MODELS, DEP_MODELS from treebeard.admin import admin_factory from treebeard.forms import movenodeform_factory 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 BASE_MODELS: register(admin_site, model) for _, model in DEP_MODELS: register(admin_site, model) django-treebeard-django-treebeard-0a55403/tests/conftest.py000066400000000000000000000013741514561205200237140ustar00rootroot00000000000000"""Pytest configuration file""" import os import pytest os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" import django from django.apps import apps from django.db import connection from django.db.models.signals import pre_migrate def pytest_report_header(config): return "Django: " + django.get_version() def pytest_configure(config): django.setup() def _pre_migration(*args, **kwargs): if os.environ.get("DATABASE_ENGINE", "") == "psql": with connection.cursor() as cursor: cursor.execute("CREATE EXTENSION IF NOT EXISTS ltree;") @pytest.fixture(autouse=True, scope="session") def django_test_environment(django_test_environment): pre_migrate.connect(_pre_migration, sender=apps.get_app_config("treebeard")) django-treebeard-django-treebeard-0a55403/tests/manage.py000066400000000000000000000003701514561205200233120ustar00rootroot00000000000000#!/usr/bin/env python import os import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) django-treebeard-django-treebeard-0a55403/tests/migrations/000077500000000000000000000000001514561205200236645ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/tests/migrations/0001_initial.py000066400000000000000000000460641514561205200263410ustar00rootroot00000000000000# Generated by Django 3.1.2 on 2021-02-24 20:44 import os import uuid import django.db.models.deletion from django.db import migrations, models import treebeard.ltree.fields class Migration(migrations.Migration): initial = True dependencies = [] operations = [ migrations.CreateModel( name="AL_TestNode", fields=[ ("id", models.BigAutoField(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.BigAutoField(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.BigAutoField(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.BigAutoField(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.BigAutoField(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.BigAutoField(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_TestNodeInheritedSorted", fields=[ ( "mp_testnodesorted_ptr", models.OneToOneField( auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="tests.mp_testnodesorted", ), ), ], options={ "abstract": False, }, bases=("tests.mp_testnodesorted",), ), migrations.CreateModel( name="MP_TestNodeSortedAutoNow", fields=[ ("id", models.BigAutoField(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.BigAutoField(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="NS_TestNode", fields=[ ("id", models.BigAutoField(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.BigAutoField(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="RelatedModel", fields=[ ("id", models.BigAutoField(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.BigAutoField(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.BigAutoField(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.BigAutoField(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.BigAutoField(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="AL_TestNodeSorted", fields=[ ("id", models.BigAutoField(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.BigAutoField(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.BigAutoField(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",), ), ] if os.environ.get("DATABASE_ENGINE") == "psql": Migration.operations.extend( [ migrations.CreateModel( name="LT_TestNode", fields=[ ( "id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"), ), ("path", treebeard.ltree.fields.PathField(unique=True)), ("desc", models.CharField(max_length=255)), ], options={ "abstract": False, }, ), migrations.CreateModel( name="LT_TestNodeInherited", fields=[ ( "lt_testnode_ptr", models.OneToOneField( auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="tests.lt_testnode", ), ), ("extra_desc", models.CharField(max_length=255)), ], options={ "abstract": False, }, bases=("tests.lt_testnode",), ), migrations.CreateModel( name="LT_TestNodeSomeDep", fields=[ ( "id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"), ), ("node", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="tests.lt_testnode")), ], ), migrations.CreateModel( name="LT_TestNode_Proxy", fields=[], options={ "proxy": True, "indexes": [], "constraints": [], }, bases=("tests.lt_testnode",), ), migrations.CreateModel( name="LT_TestNodeSorted", fields=[ ( "id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"), ), ("path", treebeard.ltree.fields.PathField(unique=True)), ("val1", models.IntegerField()), ("val2", models.IntegerField()), ("desc", models.CharField(max_length=255)), ], options={ "abstract": False, }, ), migrations.CreateModel( name="LT_TestNodeInheritedSorted", fields=[ ( "lt_testnodesorted_ptr", models.OneToOneField( auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="tests.lt_testnodesorted", ), ), ], options={ "abstract": False, }, bases=("tests.lt_testnodesorted",), ), ] ) django-treebeard-django-treebeard-0a55403/tests/migrations/__init__.py000066400000000000000000000000001514561205200257630ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/tests/models.py000066400000000000000000000135551514561205200233560ustar00rootroot00000000000000import os import uuid from django.db import models from treebeard.al_tree import AL_Node from treebeard.ltree import LT_Node from treebeard.mp_tree import MP_Node from treebeard.ns_tree import NS_Node class DescMixin(models.Model): """ Model with desc field, handy for identifying objects in tests """ desc = models.CharField(max_length=255) def __str__(self): return self.desc class Meta: abstract = True class RelatedModel(DescMixin): ... class MP_TestNode(MP_Node, DescMixin): steplen = 3 class MP_TestNodeSomeDep(models.Model): node = models.ForeignKey(MP_TestNode, on_delete=models.CASCADE) class MP_TestNodeRelated(MP_Node, DescMixin): steplen = 3 related = models.ForeignKey(RelatedModel, on_delete=models.CASCADE) class MP_TestNodeInherited(MP_TestNode): extra_desc = models.CharField(max_length=255) class MP_TestNodeCustomId(MP_Node, DescMixin): steplen = 3 id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) class NS_TestNode(NS_Node, DescMixin): ... class NS_TestNodeSomeDep(models.Model): node = models.ForeignKey(NS_TestNode, on_delete=models.CASCADE) class NS_TestNodeRelated(NS_Node, DescMixin): related = models.ForeignKey(RelatedModel, on_delete=models.CASCADE) class NS_TestNodeInherited(NS_TestNode): extra_desc = models.CharField(max_length=255) class AL_TestNode(AL_Node, DescMixin): parent = models.ForeignKey( "self", related_name="children_set", null=True, db_index=True, on_delete=models.CASCADE, ) sib_order = models.PositiveIntegerField() class AL_TestNodeSomeDep(models.Model): node = models.ForeignKey(AL_TestNode, on_delete=models.CASCADE) class AL_TestNodeRelated(AL_Node, DescMixin): parent = models.ForeignKey( "self", related_name="children_set", null=True, db_index=True, on_delete=models.CASCADE, ) sib_order = models.PositiveIntegerField() related = models.ForeignKey(RelatedModel, on_delete=models.CASCADE) class AL_TestNodeInherited(AL_TestNode): extra_desc = models.CharField(max_length=255) class MP_TestNodeSorted(MP_Node, DescMixin): steplen = 1 node_order_by = ["val1", "val2", "-desc"] val1 = models.IntegerField() val2 = models.IntegerField() class MP_TestNodeInheritedSorted(MP_TestNodeSorted): ... class NS_TestNodeSorted(NS_Node, DescMixin): node_order_by = ["val1", "val2", "-desc"] val1 = models.IntegerField() val2 = models.IntegerField() class AL_TestNodeSorted(AL_Node, DescMixin): 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() class MP_TestNodeAlphabet(MP_Node): steplen = 2 numval = models.IntegerField() class MP_TestNodeSmallStep(MP_Node): steplen = 1 alphabet = "0123456789" class MP_TestNodeSortedAutoNow(MP_Node, DescMixin): created = models.DateTimeField(auto_now_add=True) node_order_by = ["created"] class MP_TestNodeShortPath(MP_Node, DescMixin): steplen = 1 alphabet = "012345678" class MP_TestNodeUuid(MP_Node, DescMixin): steplen = 1 custom_id = models.UUIDField(primary_key=True, default=uuid.uuid1, editable=False) # 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, DescMixin): steplen = 1 alphabet = "012345678" node_order_by = ["desc"] MP_TestSortedNodeShortPath._meta.get_field("path").max_length = 4 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] MP_SHORTPATH_MODELS = [MP_TestNodeShortPath, MP_TestSortedNodeShortPath] RELATED_MODELS = [AL_TestNodeRelated, MP_TestNodeRelated, NS_TestNodeRelated] # Pairs of dependent models and base models that they depend on DEP_MODELS = [(AL_TestNode, AL_TestNodeSomeDep), (MP_TestNode, MP_TestNodeSomeDep), (NS_TestNode, NS_TestNodeSomeDep)] # Pairs of base models and models that inherit from them INHERITED_MODELS = [ (AL_TestNode, AL_TestNodeInherited), (MP_TestNode, MP_TestNodeInherited), (NS_TestNode, NS_TestNodeInherited), ] INHERITED_MODELS_WITH_SORT = [ (MP_TestNodeSorted, MP_TestNodeInheritedSorted), ] if os.environ.get("DATABASE_ENGINE", "") == "psql": class LT_TestNode(LT_Node, DescMixin): ... class LT_TestNode_Proxy(LT_TestNode): class Meta: proxy = True class LT_TestNodeSorted(LT_Node, DescMixin): node_order_by = ["val1", "val2", "-desc"] val1 = models.IntegerField() val2 = models.IntegerField() class LT_TestNodeInherited(LT_TestNode): extra_desc = models.CharField(max_length=255) class LT_TestNodeInheritedSorted(LT_TestNodeSorted): ... class LT_TestNodeSomeDep(models.Model): node = models.ForeignKey(LT_TestNode, on_delete=models.CASCADE) BASE_MODELS.append(LT_TestNode) PROXY_MODELS.append(LT_TestNode_Proxy) DEP_MODELS.append((LT_TestNode, LT_TestNodeSomeDep)) INHERITED_MODELS.append((LT_TestNode, LT_TestNodeInherited)) SORTED_MODELS.append(LT_TestNodeSorted) INHERITED_MODELS_WITH_SORT.append((LT_TestNodeSorted, LT_TestNodeInheritedSorted)) django-treebeard-django-treebeard-0a55403/tests/settings.py000066400000000000000000000057271514561205200237350ustar00rootroot00000000000000""" 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": "localhost", "PORT": os.environ.get("DATABASE_PORT_MSSQL", ""), "OPTIONS": { "driver": "ODBC Driver 18 for SQL Server", "extra_params": "Trusted_Connection=no;TrustServerCertificate=yes", }, } 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", "tests", ] MIDDLEWARE = [ "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", ] ROOT_URLCONF = "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", ], }, }, ] DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" django-treebeard-django-treebeard-0a55403/tests/test_ltree_utils.py000066400000000000000000000041021514561205200254510ustar00rootroot00000000000000import time import pytest from treebeard.ltree import InvalidLabelConstraints, generate_label class TestGenerateLabel: def test_invalid_before_after(self): with pytest.raises(InvalidLabelConstraints): # Before is less than after generate_label(before="A", after="B", skip=set()) with pytest.raises(InvalidLabelConstraints): # Before is equal to after generate_label(before="A", after="A", skip=set()) def test_no_constraint(self): assert generate_label(skip=set()) == "A" def test_no_after(self): assert generate_label(before="A", skip=set()) == "0" assert generate_label(before="ABCDE", skip=set()) == "0" def test_no_before(self): assert generate_label(after="A", skip=set()) == "B" def test_reduces_char_count_where_possible(self): assert generate_label(after="ABB", skip=set()) == "B" assert generate_label(after="ZYX", skip=set()) == "ZZ" assert generate_label(after="ZYX", before="ZZ", skip=set()) == "ZYY" def test_adds_char_if_needed(self): assert generate_label(before="AA", after="A", skip=set()) == "A0" assert generate_label(before="B", after="AZ", skip=set()) == "AZ0" def test_respects_skip(self): assert generate_label(before="AA", after="A", skip={"A0"}) == "A1" assert generate_label(before="B", after="AZ", skip={"AZ0"}) == "AZ1" assert generate_label(after="ZYX", skip={"ZZ"}) == "ZYY" def test_large_string(self): # Check that we don't have exponential time for large strings start = time.perf_counter() res = generate_label(before="A" * 59999 + "B", after="A" * 60000, skip=set()) time_taken = time.perf_counter() - start assert time_taken < 0.5 # Conservative to allow for flakiness on CI assert res == "A" * 60000 + "0" start = time.perf_counter() res = generate_label(before="B", after="A" * 60000, skip=set()) time_taken = time.perf_counter() - start assert time_taken < 0.05 assert res == "AB" django-treebeard-django-treebeard-0a55403/tests/test_migrations.py000066400000000000000000000041531514561205200253000ustar00rootroot00000000000000""" 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(f"{', '.join(names)} in {app}" for app, names in conflicts.items()) self.fail(f"Conflicting migrations detected ({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(f" {operation.describe()}" for operation in migration.operations), ) for (_, migrations) in changes.items() for migration in migrations ) self.fail(f"Model changes with no migrations detected:\n{migrations}") django-treebeard-django-treebeard-0a55403/tests/test_numconv.py000066400000000000000000000006651514561205200246150ustar00rootroot00000000000000import string import pytest from treebeard.numconv import NumConv @pytest.mark.parametrize( ("num", "chars"), [ (0, "0"), (1, "1"), (26, "Q"), (27, "R"), (46, "1A"), (999, "RR"), (10560, "85C"), ], ) def test_numconv(num, chars): conv = NumConv(string.digits + string.ascii_uppercase) assert conv.int2str(num) == chars assert conv.str2int(chars) == num django-treebeard-django-treebeard-0a55403/tests/test_treebeard.py000066400000000000000000003350271514561205200250700ustar00rootroot00000000000000"""Unit/Functional tests""" import os import threading from unittest import mock from unittest.mock import patch import pytest from django.contrib.admin.options import TO_FIELD_VAR from django.contrib.admin.sites import AdminSite from django.contrib.admin.views.main import ChangeList from django.contrib.auth.models import AnonymousUser, User from django.contrib.messages.storage.fallback import FallbackStorage from django.core.exceptions import PermissionDenied from django.db.models.signals import post_save from django.dispatch import receiver from django.forms import ValidationError from django.template import Context, Template from django.test.client import RequestFactory from django.utils.timezone import now from tests import models from tests.admin import register_all as admin_register_all from treebeard import numconv from treebeard.admin import admin_factory from treebeard.al_tree import AL_Node from treebeard.exceptions import ( InvalidMoveToDescendant, InvalidPosition, MissingNodeOrderBy, NodeAlreadySaved, PathOverflow, ) from treebeard.forms import movenodeform_factory from treebeard.mp_tree import MP_Node from treebeard.ns_tree import NS_Node from treebeard.templatetags.admin_tree import tree_context 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.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.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 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(isinstance(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, django_assert_max_num_queries): max_queries = 11 if issubclass(model, AL_Node) else 1 with django_assert_max_num_queries(max_queries): nodes = model.get_tree() got = [(o.desc, o.get_depth(), o.get_children_count()) for o in nodes] assert got == UNCHANGED assert all(isinstance(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, django_assert_max_num_queries): 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) max_queries = 13 if issubclass(model, AL_Node) else 1 with django_assert_max_num_queries(max_queries): 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(isinstance(o, model) for o in nodes) def test_get_tree_leaf(self, model, django_assert_max_num_queries): node = model.objects.get(desc="1") assert 0 == node.get_children_count() with django_assert_max_num_queries(1): 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(isinstance(o, model) for o in nodes) def test_get_annotated_list_all(self, model, django_assert_max_num_queries): 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), ] max_queries = 11 if issubclass(model, AL_Node) else 1 with django_assert_max_num_queries(max_queries): self._assert_get_annotated_list(model, expected) def test_get_annotated_list_node(self, model, django_assert_max_num_queries): 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), ] max_queries = 6 if issubclass(model, AL_Node) else 1 with django_assert_max_num_queries(max_queries): self._assert_get_annotated_list(model, expected, node) def test_get_annotated_list_leaf(self, model, django_assert_max_num_queries): node = model.objects.get(desc="1") expected = [("1", True, [0], 0)] with django_assert_max_num_queries(1): 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, _ = models.RelatedModel.objects.get_or_create(desc=f"Test {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, django_assert_max_num_queries): with django_assert_max_num_queries(1): got = model.get_root_nodes() expected = ["1", "2", "3", "4"] assert [node.desc for node in got] == expected assert all(isinstance(node, model) for node in got) def test_get_first_root_node(self, model, django_assert_max_num_queries): with django_assert_max_num_queries(1): got = model.get_first_root_node() assert got.desc == "1" assert isinstance(got, model) def test_get_last_root_node(self, model, django_assert_max_num_queries): with django_assert_max_num_queries(1): got = model.get_last_root_node() assert got.desc == "4" assert isinstance(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 isinstance(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 isinstance(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, django_assert_max_num_queries): data = [ ("2", True), ("1", True), ("4", True), ("21", False), ("24", False), ("22", False), ("231", False), ] for desc, expected in data: node = model.objects.get(desc=desc) max_queries = 2 if issubclass(model, AL_Node) else 0 with django_assert_max_num_queries(max_queries): got = node.is_root() assert got == expected def test_is_leaf(self, model, django_assert_max_num_queries): data = [ ("2", False), ("23", False), ("231", True), ] for desc, expected in data: node = model.objects.get(desc=desc) max_queries = 0 if issubclass(model, (MP_Node, NS_Node)) else 1 with django_assert_max_num_queries(max_queries): got = node.is_leaf() assert got == expected def test_get_root(self, model, django_assert_max_num_queries): data = [ ("2", "2"), ("1", "1"), ("4", "4"), ("21", "2"), ("24", "2"), ("22", "2"), ("231", "2"), ] for desc, expected in data: descendant = model.objects.get(desc=desc) max_queries = 2 if issubclass(model, AL_Node) else 1 with django_assert_max_num_queries(max_queries): node = descendant.get_root() assert node.desc == expected assert isinstance(node, model) def test_get_parent(self, model, django_assert_max_num_queries): 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) with django_assert_max_num_queries(1): parent = node.get_parent() if expected: assert parent.desc == expected assert isinstance(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 isinstance(parent, model) else: assert parent is None def test_get_children(self, model, django_assert_max_num_queries): data = [ ("2", ["21", "22", "23", "24"]), ("23", ["231"]), ("231", []), ] for desc, expected in data: node = model.objects.get(desc=desc) with django_assert_max_num_queries(1): children = node.get_children() assert [node.desc for node in children] == expected assert all(isinstance(node, model) for node in children) def test_get_children_count(self, model, django_assert_max_num_queries): data = [ ("2", 4), ("23", 1), ("231", 0), ] for desc, expected in data: node = model.objects.get(desc=desc) max_queries = 0 if issubclass(model, MP_Node) else 1 with django_assert_max_num_queries(max_queries): got = node.get_children_count() assert got == expected def test_get_siblings(self, model, django_assert_max_num_queries): data = [ ("2", ["1", "2", "3", "4"]), ("21", ["21", "22", "23", "24"]), ("231", ["231"]), ] for desc, expected in data: node = model.objects.get(desc=desc) with django_assert_max_num_queries(1): siblings = node.get_siblings() assert [node.desc for node in siblings] == expected assert all(isinstance(node, model) for node in siblings) def test_get_first_sibling(self, model, django_assert_max_num_queries): 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) max_queries = 2 if issubclass(model, NS_Node) else 1 with django_assert_max_num_queries(max_queries): sibling = node.get_first_sibling() assert sibling.desc == expected assert isinstance(sibling, model) def test_get_prev_sibling(self, model, django_assert_max_num_queries): 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) max_queries = 4 if issubclass(model, NS_Node) else 1 with django_assert_max_num_queries(max_queries): sibling = node.get_prev_sibling() if expected is None: assert sibling is None else: assert sibling.desc == expected assert isinstance(sibling, model) def test_get_next_sibling(self, model, django_assert_max_num_queries): 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) max_queries = 4 if issubclass(model, NS_Node) else 1 # TODO can NS be made more efficient? with django_assert_max_num_queries(max_queries): sibling = node.get_next_sibling() if expected is None: assert sibling is None else: assert sibling.desc == expected assert isinstance(sibling, model) def test_get_last_sibling(self, model, django_assert_max_num_queries): 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) max_queries = 4 if issubclass(model, NS_Node) else 1 # TODO can NS be made more efficient? with django_assert_max_num_queries(max_queries): sibling = node.get_last_sibling() assert sibling.desc == expected assert isinstance(sibling, model) def test_get_first_child(self, model, django_assert_max_num_queries): data = [ ("2", "21"), ("21", None), ("23", "231"), ("231", None), ] for desc, expected in data: parent = model.objects.get(desc=desc) with django_assert_max_num_queries(1): node = parent.get_first_child() if expected is None: assert node is None else: assert node.desc == expected assert isinstance(node, model) def test_get_last_child(self, model, django_assert_max_num_queries): data = [ ("2", "24"), ("21", None), ("23", "231"), ("231", None), ] for desc, expected in data: parent = model.objects.get(desc=desc) with django_assert_max_num_queries(1): node = parent.get_last_child() if expected is None: assert node is None else: assert node.desc == expected assert isinstance(node, model) def test_get_ancestors(self, model, django_assert_max_num_queries): data = [ ("2", []), ("21", ["2"]), ("231", ["2", "23"]), ] for desc, expected in data: descendant = model.objects.get(desc=desc) max_queries = 2 if issubclass(model, AL_Node) else 1 with django_assert_max_num_queries(max_queries): nodes = descendant.get_ancestors() assert [node.desc for node in nodes] == expected assert all(isinstance(node, model) for node in nodes) def test_get_descendants(self, model, django_assert_max_num_queries): data = [ ("2", ["21", "22", "23", "231", "24"]), ("23", ["231"]), ("231", []), ("1", []), ("4", ["41"]), ] for desc, expected in data: parent = model.objects.get(desc=desc) max_queries = 6 if issubclass(model, AL_Node) else 1 with django_assert_max_num_queries(max_queries): nodes = parent.get_descendants() assert [node.desc for node in nodes] == expected assert all(isinstance(node, model) for node in nodes) def test_get_descendants_include_self(self, model, django_assert_max_num_queries): data = [ ("2", ["2", "21", "22", "23", "231", "24"]), ("23", ["23", "231"]), ("231", ["231"]), ("1", ["1"]), ("4", ["4", "41"]), ] for desc, expected in data: parent = model.objects.get(desc=desc) max_queries = 6 if issubclass(model, AL_Node) else 1 with django_assert_max_num_queries(max_queries): nodes = parent.get_descendants(include_self=True) assert [node.desc for node in nodes] == expected assert all(isinstance(node, model) for node in nodes) def test_get_descendant_count(self, model, django_assert_max_num_queries): data = [ ("2", 5), ("23", 1), ("231", 0), ("1", 0), ("4", 1), ] for desc, expected in data: parent = model.objects.get(desc=desc) max_queries = 6 if issubclass(model, AL_Node) else 1 with django_assert_max_num_queries(max_queries): got = parent.get_descendant_count() assert got == expected def test_is_sibling_of(self, model, django_assert_max_num_queries): 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) max_queries = 2 if issubclass(model, (NS_Node, AL_Node)) else 0 with django_assert_max_num_queries(max_queries): assert node1.is_sibling_of(node2) == expected def test_is_child_of(self, model, django_assert_max_num_queries): 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) max_queries = 1 if issubclass(model, (NS_Node, AL_Node)) else 0 with django_assert_max_num_queries(max_queries): assert node1.is_child_of(node2) == expected def test_is_descendant_of(self, model, django_assert_max_num_queries): 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) max_queries = 6 if issubclass(model, AL_Node) else 0 with django_assert_max_num_queries(max_queries): 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_called_consecutively(self, model_without_data): # Regression test for https://github.com/django-treebeard/django-treebeard/issues/307 parent = model_without_data.add_root(desc="1") child1 = model_without_data(desc="11") child2 = model_without_data(desc="12") assert parent.get_children_count() == 0 parent.add_child(instance=child1) parent.add_child(instance=child2) assert list(parent.get_children().values_list("desc", flat=True)) == [child1.desc, child2.desc] @pytest.mark.django_db(transaction=True) @pytest.mark.skipif( os.getenv("DATABASE_ENGINE", "sqlite") == "sqlite", reason="SQLite doesn't support row-level locking", ) def test_add_child_concurrent(self, model_without_data): # 5 threads x 5 add_child each on same parent; no IntegrityError. num_threads = 5 per_thread = 5 parent = model_without_data.add_root(desc="parent") parent_pk = parent.pk errors = [] def add_children(thread_id): try: p = model_without_data.objects.get(pk=parent_pk) for i in range(per_thread): p.add_child(desc=f"t{thread_id}-{i}") except Exception as e: errors.append(e) threads = [threading.Thread(target=add_children, args=(t,)) for t in range(num_threads)] for t in threads: t.start() for t in threads: t.join() if errors: raise errors[0] parent.refresh_from_db() assert parent.get_children_count() == num_threads * per_thread 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() 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=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") @pytest.mark.parametrize("pos", ("first-child", "last-child")) def test_cannot_move_node_to_its_own_child(self, pos, model): # Test for non-leaf node node = model.objects.get(desc="22") with pytest.raises(InvalidMoveToDescendant, match="move node to itself"): node.move(node, pos) # Test for leaf node node = model.objects.get(desc="231") with pytest.raises(InvalidMoveToDescendant, match="move node to itself"): node.move(node, pos) 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") node = model.objects.get(desc="4") node.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), ] # Check that for MP, NS and LT nodes, the depth was updated on the in-memory instances assert node.get_depth() == 3 assert target.get_children_count() == 2 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, "zxy", 1, 0), (3, 3, "abc", 1, 0), (3, 3, "abc", 1, 0), (4, 1, "fgh", 1, 0), ] assert self.got(sorted_model) == expected def test_add_root_sorted_with_instances(self, sorted_model): sorted_model.add_root(instance=sorted_model(val1=3, val2=3, desc="zxy")) sorted_model.add_root(instance=sorted_model(val1=1, val2=4, desc="bcd")) expected = [ (1, 4, "bcd", 1, 0), (3, 3, "zxy", 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, "zxy", 2, 0), (3, 3, "abc", 2, 0), (3, 3, "abc", 2, 0), (4, 1, "fgh", 2, 0), ] assert self.got(sorted_model) == expected def test_add_child_nonroot_sorted(self, sorted_model): def get_node(node_id): return 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, "av", 2, 0), (0, 0, "ac", 2, 3), (0, 0, "acc", 3, 0), (0, 0, "acb", 3, 0), (0, 0, "aca", 3, 0), (0, 0, "aa", 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, "zxy", 2, 0), (3, 3, "abc", 2, 0), (3, 3, "abc", 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, "zxy", 1, 0), (1, 3, "abc", 1, 0), (1, 3, "abc", 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=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 @staticmethod @pytest.fixture( scope="function", params=models.INHERITED_MODELS_WITH_SORT, ids=lambda fv: f"base={fv[0].__name__} inherited={fv[1].__name__}", ) def inherited_model_with_sort(request): return request.param 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"] def test_move_with_children(self, inherited_model): base_model = inherited_model.__bases__[0] node1 = base_model.objects.get(desc="1") node21 = inherited_model.objects.get(desc="21") node21.move(node1, "first-child") assert [node.desc for node in node1.get_children()] == ["21"] assert [node.desc for node in node21.get_children()] == ["211", "212"] def test_get_descendants_group_count(self, inherited_model): base_model = inherited_model.__bases__[0] for node in base_model.get_descendants_group_count(): assert node.descendants_count == node.get_descendant_count() for node in inherited_model.get_descendants_group_count(): assert node.descendants_count == node.get_descendant_count() def test_add_root_with_node_order_by(self, inherited_model_with_sort): """ Regression test for https://github.com/django-treebeard/django-treebeard/issues/301 Ensure that adding a second inherited root node with node_order_by does not delegate to the parent class. """ _, inherited_model = inherited_model_with_sort inherited_model.add_root(val1=2, val2=3, desc="A") inherited_model.add_root(val1=2, val2=3, desc="B") assert list(inherited_model.objects.values_list("desc", flat=True)) == ["B", "A"] @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: added = mpalphabet_model.add_root(numval=pos) except Exception: got_err = True break # Check for case-insensitive LIKE that would break querying even if the object was inserted correctly if mpalphabet_model.objects.filter(path__startswith=added.path).count() != 1: got_err = True break if got_err: break got = list(mpalphabet_model.objects.values_list("path", flat=True)) if got != expected: break last_good = alphabet assert False, f"Best BASE85 based alphabet for your setup: {last_good} (base {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): """ Auto-populated fields cannot be used with node_order_by, because we need to be able to run queries against the value before creating the object. Treebeard should log a warning and skip ordering by that field if no value is found on the object. """ def test_sorted_by_autonow_ignores_and_warns(self, mpsortedautonow_model): mpsortedautonow_model.add_root(desc="node1") with pytest.warns(RuntimeWarning, match="Received a null value for field 'created'"): mpsortedautonow_model.add_root(desc="node2") # Object was still created, but `created` order isn't respected assert list(mpsortedautonow_model.objects.values_list("desc", flat=True)) == ["node2", "node1"] def test_sorted_by_autonow_value_added_manually(self, mpsortedautonow_model): mpsortedautonow_model.add_root(desc="node1", created=now()) mpsortedautonow_model.add_root(desc="node2", created=now()) assert list(mpsortedautonow_model.objects.values_list("desc", flat=True)) == ["node1", "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_no_fix_paths(self, mpshort_model): self.add_broken_test_data(mpshort_model) mpshort_model.fix_tree(fix_paths=False) got = self.got(mpshort_model) expected = self.expected_with_holes[mpshort_model] assert got == expected assert all(not group for group in mpshort_model.find_problems()) def test_fix_tree_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 assert all(not group for group in mpshort_model.find_problems()) def test_fix_tree_with_parent(self, mpshort_model): """ Fix the tree only for parent with path 4 and everything inside it. All other bad data should be left intact. """ self.add_broken_test_data(mpshort_model) mpshort_model.fix_tree(parent=mpshort_model.objects.get(path="4")) got = self.got(mpshort_model) expected = self.expected_with_holes[mpshort_model] assert got[7:] == expected[7:] # Only compare items from path 4 onwards problems = mpshort_model.find_problems() assert not any([problems[0], problems[1], problems[2], problems[4]]) assert set(problems[3]) == set(mpshort_model.objects.exclude(path__startswith="4").values_list("pk", flat=True)) def test_fix_tree_with_parent_fix_paths(self, mpshort_model): """ Fix the tree only for parent with path 4 and everything inside it. All other bad data should be left intact. """ self.add_broken_test_data(mpshort_model) parent = mpshort_model.objects.get(path="4") # Fix the depth on the parent - we need a valid parent to operate on only part of a tree parent.depth = 1 parent.save() mpshort_model.fix_tree(parent=parent, fix_paths=True) got = self.got(mpshort_model) expected_partial = { models.MP_TestNodeShortPath: [ ("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: [ ("4", "a", 1, 4), ("41", "a", 2, 0), ("42", "a", 2, 0), ("43", "o", 2, 0), ("44", "u", 2, 1), ("441", "i", 3, 1), ("4411", "e", 4, 0), ], } assert got[7:] == expected_partial[mpshort_model] # Only compare items from path 4 onwards problems = mpshort_model.find_problems() assert not any([problems[0], problems[1], problems[2], problems[4]]) assert set(problems[3]) == set(mpshort_model.objects.exclude(path__startswith="4").values_list("pk", flat=True)) @pytest.mark.django_db class TestMoveNodeForm(TestNonEmptyTree): def _get_nodes_list(self, nodes): return [(str(pk), f"{' ' * 4 * (depth - 1)}{_str}") for pk, _str, depth in nodes] def _assert_nodes_in_choices(self, form, nodes): choices = list(form.fields["treebeard_ref_node"].choices) assert choices.pop(0)[0] == "" # Empty choice assert nodes == [(str(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", "treebeard_position", "treebeard_ref_node"] == list(form.base_fields.keys()) got = [choice[0] for choice in form.fields["treebeard_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_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", "treebeard_position", "treebeard_ref_node"] 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 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()) == [ "desc", "val1", "val2", "treebeard_position", "treebeard_ref_node", ] form = form_class(instance=sorted_model.objects.get(desc="bcd")) assert list(form.fields.keys()) == [ "desc", "val1", "val2", "treebeard_position", "treebeard_ref_node", ] assert "id_treebeard_position" in str(form) assert "id_treebeard_ref_node" 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", "treebeard_position", "treebeard_ref_node"] form = form_class(instance=model.objects.get(desc="1")) assert list(form.fields.keys()) == ["desc", "treebeard_position", "treebeard_ref_node"] assert "id_treebeard_position" in str(form) assert "id_treebeard_ref_node" 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 "' in content request = RequestFactory().get("/?desc=foo") request.user = user admin_obj = self._get_admin_obj(models.MP_TestNode) with patch.object(admin_obj, "has_change_permission", return_value=False): response = admin_obj.changelist_view(request) response.render() content = response.content.decode() assert '' in content assert '' in content def test_get_node(self, model): admin_obj = self._get_admin_obj(model) target = model.objects.get(desc="2") assert admin_obj.get_node(target.pk) == target def test_move_node_validate_keyerror(self, model): admin_obj = self._get_admin_obj(model) request = self._mocked_request(data={}) response = admin_obj.move_node(request) assert response.status_code == 400 assert response.content.decode() == "Malformed POST params" request = self._mocked_request(data={"node_id": 1}) response = admin_obj.move_node(request) assert response.status_code == 400 assert response.content.decode() == "Malformed POST params" def test_move_node_validate_valueerror(self, model): admin_obj = self._get_admin_obj(model) request = self._mocked_request(data={"node_id": 1, "sibling_id": 2, "as_child": "invalid"}) response = admin_obj.move_node(request) assert response.status_code == 400 assert response.content.decode() == "Malformed POST params" def test_move_validate_missing_nodeorderby(self, model): node = model.objects.get(desc="231") admin_obj = self._get_admin_obj(model) request = self._mocked_request(data={}) response = admin_obj.try_to_move_node(True, node, "sorted-child", request, target=node) assert response.status_code == 400 response = admin_obj.try_to_move_node(True, node, "sorted-sibling", request, target=node) assert response.status_code == 400 def test_move_validate_invalid_pos(self, model): node = model.objects.get(desc="231") admin_obj = self._get_admin_obj(model) request = self._mocked_request(data={}) response = admin_obj.try_to_move_node(True, node, "invalid_pos", request, target=node) assert response.status_code == 400 def test_move_validate_to_descendant(self, model): node = model.objects.get(desc="2") target = model.objects.get(desc="231") admin_obj = self._get_admin_obj(model) request = self._mocked_request(data={}) response = admin_obj.try_to_move_node(True, node, "first-sibling", request, target) assert response.status_code == 400 def test_move_requires_change_permission(self, model): node = model.objects.get(desc="231") target = model.objects.get(desc="2") admin_obj = self._get_admin_obj(model) request = self._mocked_request( data={"node_id": node.pk, "sibling_id": target.pk, "as_child": 0}, user=self._create_user("test_move_perm"), ) with patch.object(admin_obj, "has_change_permission", return_value=False): with pytest.raises(PermissionDenied): admin_obj.move_node(request) with patch.object(admin_obj, "has_change_permission", return_value=True): response = admin_obj.move_node(request) assert response.status_code == 200 def test_move_left(self, model): node = model.objects.get(desc="231") target = model.objects.get(desc="2") admin_obj = self._get_admin_obj(model) request = self._mocked_request( data={"node_id": node.pk, "sibling_id": target.pk, "as_child": 0}, user=self._create_user("tmp", is_superuser=True), ) response = admin_obj.move_node(request) assert response.status_code == 200 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_last_child(self, model): node = model.objects.get(desc="231") target = model.objects.get(desc="2") admin_obj = self._get_admin_obj(model) request = self._mocked_request( data={"node_id": node.pk, "sibling_id": target.pk, "as_child": 1}, user=self._create_user("tmp", is_superuser=True), ) response = admin_obj.move_node(request) assert response.status_code == 200 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 @pytest.mark.django_db class TestMPFormPerformance: def test_form_choices_no_of_queries(self, django_assert_num_queries): model = models.MP_TestNode model.load_bulk(BASE_DATA) form_class = movenodeform_factory(model) form = form_class() with django_assert_num_queries(2): list(form.fields["treebeard_ref_node"].choices) @pytest.mark.django_db class TestMP_TreeDescendantsPerformance(TestTreeBase): def test_get_descendants_no_of_queries(self, django_assert_num_queries): model = models.MP_TestNode model.load_bulk(BASE_DATA) data = [ ("2", 1), ("23", 1), ("231", 0), ("1", 0), ("4", 1), ] for desc, expected in data: node = model.objects.get(desc=desc) with django_assert_num_queries(expected): # converting to list to force queryset evaluation list(node.get_descendants()) django-treebeard-django-treebeard-0a55403/tests/urls.py000066400000000000000000000002141514561205200230440ustar00rootroot00000000000000from django.contrib import admin from django.urls import path admin.autodiscover() urlpatterns = [ path("admin/", admin.site.urls), ] django-treebeard-django-treebeard-0a55403/tox.ini000066400000000000000000000021131514561205200216560ustar00rootroot00000000000000# # tox.ini for django-treebeard # # Read docs/tests for help on how to use tox to run the test suite. # [tox] envlist = py{310,311,312,313}-dj{52}-{sqlite,postgres,mysql,mssql} py{312,313,314}-dj{60}-{sqlite,postgres,mysql} # mssql-django doesn't yet support Django 6 [testenv:docs] basepython = python changedir = docs deps = Sphinx Django commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html [testenv] deps = pytest>=9,<9.1 pytest-django>=4.0,<5.0 pytest-pythonpath>=0.7,<1.0 pytest-cov<8.0 psycopg[binary]>=3 dj52: Django>=5.2,<6 dj60: Django>=6,<6.1 mysql: mysqlclient>=2.1.1 mssql: mssql-django>=1.2 setenv = sqlite: DATABASE_ENGINE=sqlite postgres: DATABASE_ENGINE=psql mysql: DATABASE_ENGINE=mysql mssql: DATABASE_ENGINE=mssql passenv = DATABASE_USER DATABASE_PASSWORD DATABASE_HOST DATABASE_USER_POSTGRES DATABASE_PORT_POSTGRES DATABASE_USER_MYSQL DATABASE_PORT_MYSQL DATABASE_PORT_MSSQL commands = pytest --cov treebeard --no-migrations {posargs} django-treebeard-django-treebeard-0a55403/treebeard/000077500000000000000000000000001514561205200223035ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/__init__.py000066400000000000000000000010451514561205200244140ustar00rootroot00000000000000""" 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 build twine 9. python -m build 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__ = "5.0.5" django-treebeard-django-treebeard-0a55403/treebeard/admin.py000066400000000000000000000134331514561205200237510ustar00rootroot00000000000000"""Django admin support for treebeard""" import sys from django.contrib import admin, messages from django.core.exceptions import PermissionDenied from django.db import transaction from django.http import HttpResponse, HttpResponseBadRequest from django.urls import path from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ from treebeard.al_tree import AL_Node from treebeard.exceptions import InvalidMoveToDescendant, InvalidPosition, MissingNodeOrderBy, PathOverflow def check_empty_dict(GET_dict): """ Returns True if the GET query string contains no values, but it can contain empty keys. This is better than doing not bool(request.GET) as an empty key will return True """ for k, v in GET_dict.items(): # Don't disable on p(age) or 'all' GET param if v and (k not in ["p", "all"]): return False return True 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) # We deliberately don't use `get_tree()` here because we want the specific # model for inherited models. This assumes that all implementations # return the queryset in DFS order (except AL_Node which is handled above). return self.model.objects.all() 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 = {} extra_context["has_change_permission"] = self.has_change_permission(request) extra_context["filtered"] = not check_empty_dict(request.GET) return super().changelist_view(request, extra_context) def _changeform_view(self, *args, **kwargs): # Because Treebeard frequently needs to modify many objects in a tree when one node # is added/updated, the normal behaviour of relying on `commit=False` to create # unsaved objects before validating inlines etc doesn't work: Treebeard has already # made database changes to prepare to insert/move a node. # For this reason, if the form has error response = super()._changeform_view(*args, **kwargs) if getattr(response, "context_data", {}).get("errors", None): # There was an error somewhere, likely in an inline, so we'll need to roll back transaction.set_rollback(True) return response 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) if not self.has_change_permission(request, node): # The JS will trigger a page reload on error. This message will be displayed after reload. messages.error(request, _("You do not have permission to change this object.")) raise PermissionDenied 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)) django-treebeard-django-treebeard-0a55403/treebeard/al_tree.py000066400000000000000000000311261514561205200242730ustar00rootroot00000000000000"""Adjacency List""" from django.core import serializers from django.db import models, transaction from django.db.models import Max, Min from django.utils.translation import gettext_noop as _ from treebeard.exceptions import InvalidMoveToDescendant, NodeAlreadySaved from treebeard.models import Node 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 TREEBEARD_IDENTIFYING_FIELD = "parent" MOVENODE_FORM_EXCLUDED_FIELDS = ("sib_order", "parent") _cached_attributes = ( *Node._cached_attributes, "_cached_depth", ) @classmethod @transaction.atomic 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: max = cls.tree_model().objects.filter(parent__isnull=True).aggregate(max=Max("sib_order"))["max"] or 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 cls.tree_model().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 self.tree_model().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.""" # a list of nodes: not really a queryset, but it works objs = 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 @transaction.atomic def add_child(self, **kwargs): """Adds a child to the node.""" cls = self.tree_model() 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: max = cls.objects.filter(parent=self).aggregate(max=Max("sib_order"))["max"] or 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, include_self=False): """ :returns: A *list* of all the node's descendants, doesn't include the node itself if `include_self` is False """ if include_self: return self.__class__.get_tree(self) 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_id: return self.tree_model().objects.filter(parent_id=self.parent_id) return self.__class__.get_root_nodes() def get_prev_sibling(self): return self.get_siblings().filter(sib_order__lt=self.sib_order).last() def get_next_sibling(self): return self.get_siblings().filter(sib_order__gt=self.sib_order).first() @transaction.atomic 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 = self.tree_model()(**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 = cls.tree_model().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] min = siblings.aggregate(min=Min("sib_order"))["min"] or 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 @transaction.atomic 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"): if self == target: raise InvalidMoveToDescendant(_("Can't move node to itself.")) # 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 django-treebeard-django-treebeard-0a55403/treebeard/exceptions.py000066400000000000000000000014001514561205200250310ustar00rootroot00000000000000"""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) """ django-treebeard-django-treebeard-0a55403/treebeard/forms.py000066400000000000000000000165241514561205200240130ustar00rootroot00000000000000"""Forms for treebeard.""" from django import forms from django.forms.models import modelform_factory as django_modelform_factory from django.utils.html import conditional_escape from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from treebeard.al_tree import AL_Node class TreeNodeChoiceField(forms.ModelChoiceField): """ ModelChoiceField for use with tree models. Applies indentation to the label to reflect the depth of the node. The queryset/choices provided to the field must be ordered in order for the choices to appear in the order that the tree is organised. """ DEPTH_SEPARATOR = mark_safe("    ") def _get_indent(self, obj): return mark_safe(conditional_escape(self.DEPTH_SEPARATOR) * (obj.get_depth() - 1)) def label_from_instance(self, obj): label = super().label_from_instance(obj) return mark_safe(conditional_escape(self._get_indent(obj)) + conditional_escape(label)) 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")), ) treebeard_position = forms.ChoiceField(required=True, label=_("Position")) treebeard_ref_node = TreeNodeChoiceField( required=False, label=_("Relative to"), empty_label=_("-- root --"), queryset=None, # Populated in __init__ ) def _get_initial(self, instance): if self.is_sorted: position = "sorted-child" ref_node = instance.get_parent() else: prev_sibling = instance.get_prev_sibling() if prev_sibling: position = "right" ref_node = prev_sibling else: position = "first-child" if instance.is_root(): ref_node = None else: ref_node = instance.get_parent() return {"treebeard_ref_node": ref_node, "treebeard_position": position} def _set_ref_model_queryset(self, opts, instance): """ Sets the queryset (or choices) on the treebeard_ref_model field. For AL trees, this sets a list of nodes as the choices. For all other trees, sets a queryset. Excludes the instance and its descendants since a move relative to those would be invalid """ if issubclass(opts.model, AL_Node): choices = opts.model.get_tree() descendants = instance.get_descendants(include_self=True) if instance else [] field = self.fields["treebeard_ref_node"] self.fields["treebeard_ref_node"]._choices = [("", "--------")] + [ (field.prepare_value(node), field.label_from_instance(node)) for node in choices if node not in descendants ] # Must set queryset so that the manually defined choices are valid. # Must also do this *after* setting _choices above, otherwise the setter # for queryset will overwrite the choices. self.fields["treebeard_ref_node"].queryset = opts.model.objects.all() return queryset = opts.model.get_tree() descendants = instance.get_descendants(include_self=True) if instance else None if descendants: queryset = queryset.exclude(pk__in=descendants.values_list("pk", flat=True)) self.fields["treebeard_ref_node"].queryset = queryset def __init__(self, *args, initial=None, instance=None, **kwargs): opts = self._meta if opts.model is None: raise ValueError("ModelForm has no model class specified") self.is_sorted = getattr(opts.model, "node_order_by", False) # Set initial values for treebeard fields initial_ = self._get_initial(instance) if instance else {} if initial is not None: initial_.update(initial) super().__init__(*args, instance=instance, initial=initial_, **kwargs) # update the 'treebeard_position' field choices self.fields["treebeard_position"].choices = ( self.__position_choices_sorted if self.is_sorted else self.__position_choices_unsorted ) self._set_ref_model_queryset(opts, instance) def save(self, commit=True): """ Saves the model form. WARNING: Treebeard does not respect commit=False: other nodes that need to be modified to make space for the edited node will be updated in the database, and thus it is difficult to avoid writing any changes to the database. TreeAdmin handles this by rolling back the entire transaction if the form or any inlines report an error. If you use this form elsewhere, you will need to do the same. """ reference_node = self.cleaned_data.pop("treebeard_ref_node", None) position_type = self.cleaned_data.pop("treebeard_position") if self.instance._state.adding: if reference_node: 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: self.instance.move(reference_node, pos=position_type) else: pos = "sorted-sibling" if self.is_sorted else "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 def movenodeform_factory(model, form=MoveNodeForm, exclude=None, **kwargs): """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. Accepts all other kwargs that can be passed to Django's `modelform_factory`. :return: A :py:class:`MoveNodeForm` subclass """ if exclude is None: exclude = () exclude += getattr(model, "MOVENODE_FORM_EXCLUDED_FIELDS", ()) return django_modelform_factory(model, form, exclude=exclude, **kwargs) django-treebeard-django-treebeard-0a55403/treebeard/locale/000077500000000000000000000000001514561205200235425ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/de/000077500000000000000000000000001514561205200241325ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/de/LC_MESSAGES/000077500000000000000000000000001514561205200257175ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/de/LC_MESSAGES/django.mo000066400000000000000000000032051514561205200275160ustar00rootroot00000000000000x 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 Datenbankdjango-treebeard-django-treebeard-0a55403/treebeard/locale/de/LC_MESSAGES/django.po000066400000000000000000000045021514561205200275220ustar00rootroot00000000000000# 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" django-treebeard-django-treebeard-0a55403/treebeard/locale/de/LC_MESSAGES/djangojs.mo000066400000000000000000000010671514561205200300570ustar00rootroot00000000000000<\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-Elementdjango-treebeard-django-treebeard-0a55403/treebeard/locale/de/LC_MESSAGES/djangojs.po000066400000000000000000000016311514561205200300570ustar00rootroot00000000000000# 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" django-treebeard-django-treebeard-0a55403/treebeard/locale/es/000077500000000000000000000000001514561205200241515ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/es/LC_MESSAGES/000077500000000000000000000000001514561205200257365ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/es/LC_MESSAGES/django.mo000066400000000000000000000013341514561205200275360ustar00rootroot00000000000000 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 dedjango-treebeard-django-treebeard-0a55403/treebeard/locale/es/LC_MESSAGES/django.po000066400000000000000000000027731514561205200275510ustar00rootroot00000000000000msgid "" 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 "" django-treebeard-django-treebeard-0a55403/treebeard/locale/es/LC_MESSAGES/djangojs.mo000066400000000000000000000005661514561205200301010ustar00rootroot00000000000000$,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); django-treebeard-django-treebeard-0a55403/treebeard/locale/es/LC_MESSAGES/djangojs.po000066400000000000000000000011131514561205200300710ustar00rootroot00000000000000msgid "" 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 "" django-treebeard-django-treebeard-0a55403/treebeard/locale/fr/000077500000000000000000000000001514561205200241515ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/fr/LC_MESSAGES/000077500000000000000000000000001514561205200257365ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/fr/LC_MESSAGES/django.mo000066400000000000000000000031661514561205200275430ustar00rootroot00000000000000x 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éedjango-treebeard-django-treebeard-0a55403/treebeard/locale/fr/LC_MESSAGES/django.po000066400000000000000000000045471514561205200275520ustar00rootroot00000000000000# 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é" django-treebeard-django-treebeard-0a55403/treebeard/locale/fr/LC_MESSAGES/djangojs.mo000066400000000000000000000010131514561205200300650ustar00rootroot00000000000000<\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'enfantdjango-treebeard-django-treebeard-0a55403/treebeard/locale/fr/LC_MESSAGES/djangojs.po000066400000000000000000000016261514561205200301020ustar00rootroot00000000000000# 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" django-treebeard-django-treebeard-0a55403/treebeard/locale/hu/000077500000000000000000000000001514561205200241565ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/hu/LC_MESSAGES/000077500000000000000000000000001514561205200257435ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/hu/LC_MESSAGES/django.mo000077500000000000000000000032071514561205200275470ustar00rootroot00000000000000x 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.django-treebeard-django-treebeard-0a55403/treebeard/locale/hu/LC_MESSAGES/django.po000077500000000000000000000045551514561205200275610ustar00rootroot00000000000000# 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" django-treebeard-django-treebeard-0a55403/treebeard/locale/hu/LC_MESSAGES/djangojs.mo000077500000000000000000000010031514561205200300740ustar00rootroot00000000000000<\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éntdjango-treebeard-django-treebeard-0a55403/treebeard/locale/hu/LC_MESSAGES/djangojs.po000077500000000000000000000016161514561205200301110ustar00rootroot00000000000000# 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" django-treebeard-django-treebeard-0a55403/treebeard/locale/nl/000077500000000000000000000000001514561205200241535ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/nl/LC_MESSAGES/000077500000000000000000000000001514561205200257405ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/nl/LC_MESSAGES/django.mo000066400000000000000000000027501514561205200275430ustar00rootroot00000000000000x 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.django-treebeard-django-treebeard-0a55403/treebeard/locale/nl/LC_MESSAGES/django.po000066400000000000000000000034651514561205200275520ustar00rootroot00000000000000msgid "" 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" django-treebeard-django-treebeard-0a55403/treebeard/locale/nl/LC_MESSAGES/djangojs.mo000066400000000000000000000010241514561205200300710ustar00rootroot00000000000000<\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 subonderdeeldjango-treebeard-django-treebeard-0a55403/treebeard/locale/nl/LC_MESSAGES/djangojs.po000066400000000000000000000012241514561205200300760ustar00rootroot00000000000000msgid "" 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" django-treebeard-django-treebeard-0a55403/treebeard/locale/pl/000077500000000000000000000000001514561205200241555ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/pl/LC_MESSAGES/000077500000000000000000000000001514561205200257425ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/pl/LC_MESSAGES/django.mo000066400000000000000000000014171514561205200275440ustar00rootroot00000000000000 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 kategoriidjango-treebeard-django-treebeard-0a55403/treebeard/locale/pl/LC_MESSAGES/django.po000066400000000000000000000015601514561205200275460ustar00rootroot00000000000000msgid "" 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 --" django-treebeard-django-treebeard-0a55403/treebeard/locale/ru/000077500000000000000000000000001514561205200241705ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/ru/LC_MESSAGES/000077500000000000000000000000001514561205200257555ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/locale/ru/LC_MESSAGES/django.mo000066400000000000000000000015521514561205200275570ustar00rootroot00000000000000 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; -- корень --ПослеДоВложенныйПервый вложенныйПозицияОтносительноСоседний кdjango-treebeard-django-treebeard-0a55403/treebeard/locale/ru/LC_MESSAGES/django.po000066400000000000000000000032141514561205200275570ustar00rootroot00000000000000msgid "" 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 "" django-treebeard-django-treebeard-0a55403/treebeard/locale/ru/LC_MESSAGES/djangojs.mo000066400000000000000000000006751514561205200301210ustar00rootroot00000000000000$,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; django-treebeard-django-treebeard-0a55403/treebeard/locale/ru/LC_MESSAGES/djangojs.po000066400000000000000000000012251514561205200301140ustar00rootroot00000000000000msgid "" 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 "" django-treebeard-django-treebeard-0a55403/treebeard/ltree/000077500000000000000000000000001514561205200234165ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/ltree/__init__.py000066400000000000000000000563611514561205200255420ustar00rootroot00000000000000"""Postgres Ltree Trees""" import functools import itertools import operator import string from collections.abc import Iterable from typing import Any from django.core import serializers from django.db import models, transaction from django.db.models import F, Func, OuterRef, Q, Subquery, Value from django.db.models.functions import Concat from django.utils.translation import gettext_noop as _ from treebeard.exceptions import InvalidMoveToDescendant, NodeAlreadySaved, PathOverflow from treebeard.models import Node from .fields import Ltree2Text, PathField, PathValue, Subpath, Text2LTree class InvalidLabelConstraints(Exception): ... def generate_label( skip: set[str], before: str | None = None, after: str | None = None, ): """ Generate a new label value that will order the label before `before` and after `after`. Uses an alphabet of digits and ascii uppercase letters. If no `before` constraint is provided, then chooses only from letters: this allows room for digits to be used in future if nodes are inserted to the left of this one, without having to move large chunks of the tree. :raise InvalidLabelConstraints: when no label could be generated within the given constraints. """ char_choices = string.ascii_uppercase if not before and not after else string.digits + string.ascii_uppercase start = after or char_choices[0] if before and before <= start: raise InvalidLabelConstraints # Construct sets of characters for each position, appending one at the end if we need to extend the string char_lists = [char_choices[idx:] for idx in [char_choices.index(char) for char in start]] char_lists.append(char_choices) # There is no point testing portions of the strings that are identical start_from = 0 for ch1, ch2 in zip(before or "", after or ""): if ch1 != ch2: break start_from += 1 for i in range(start_from, len(char_lists)): iter = itertools.product(*char_lists[: i + 1]) for label_parts in iter: label = "".join(label_parts) if after and label <= after: continue if label in skip: continue if before and label >= before: break # No point looking any further return label # We should never reach here... right? raise ValueError("Failed to generate label. Please report this as a bug.") def generate_path( prefix: PathValue | None = None, before: str | None = None, after: str | None = None, skip: Iterable[PathValue] | None = None, ) -> PathValue: skip = {path[-1] for path in skip} if skip else set() label = generate_label(before=before, after=after, skip=skip) return PathValue((prefix or []) + [label]) class LT_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 """ # Construct the minimal list of nodes that need deleting along with their descendants paths_to_remove = set() for node in self.order_by("path").only("path").iterator(): found = False for depth in range(1, len(node.path)): path = node.path[0:depth] if str(path) in paths_to_remove: # we are already removing an ancestor of this node, so skip it found = True break if not found: paths_to_remove.add(str(node.path)) model = self.model.tree_model() if not paths_to_remove: return super(LT_NodeQuerySet, model.objects.none()).delete(*args, **kwargs) query = functools.reduce(operator.or_, [Q(path__descendants=path) for path in paths_to_remove]) return super(LT_NodeQuerySet, model.objects.filter(query)).delete(*args, **kwargs) delete.alters_data = True delete.queryset_only = True class LT_NodeManager(models.Manager): """Custom manager for nodes in a Materialized Path tree.""" def get_queryset(self): """Sets the custom queryset as the default.""" return LT_NodeQuerySet(self.model).order_by("path") class LT_ComplexAddMoveHandler: def _get_new_path(self, prefix, insert_before, insert_after, nodes_moved=False, num_iters=0): insert_before_path = insert_before.path[-1] if insert_before else None insert_after_path = insert_after.path[-1] if insert_after else None if insert_before_path and insert_after_path and insert_after_path > insert_before_path: raise ValueError("Invalid path constraints") siblings = self.node_cls.tree_model().objects.filter(path__descendants=prefix, path__depth=len(prefix) + 1) try: path = generate_path( prefix=prefix, before=insert_before_path, after=insert_after_path, skip=siblings.values_list("path", flat=True), ) except InvalidLabelConstraints: # We failed to find a path within the available range. That means we need to move the insert_before # node and everything after it to the right self._move_subtree_right(insert_before) insert_before.refresh_from_db() num_iters += 1 # We should never end up here... the move right operation should always succeed if num_iters > 2: raise PathOverflow("Failed to generate a new path for node. Please report this as a bug.") return self._get_new_path(prefix, insert_before, insert_after, nodes_moved=True, num_iters=num_iters) return path, nodes_moved def _move_subtree_right(self, start_node): """ Move the node and everything after it in the tree to the right. This is achieved simply by appending an extra character (A) to the topmost label in the path. """ result_class = self.node_cls.tree_model() node_depth = len(start_node.path) if node_depth > 1: result_class.objects.filter(path__gte=start_node.path, path__depth=node_depth).update( path=Concat( Subpath(F("path"), 0, node_depth - 1), Text2LTree(Concat(Ltree2Text(Subpath(F("path"), node_depth - 1, 1)), Value("A"))), ) ) result_class.objects.filter(path__gte=start_node.path, path__depth__gt=node_depth).update( path=Concat( Subpath(F("path"), 0, node_depth - 1), Text2LTree(Concat(Ltree2Text(Subpath(F("path"), node_depth - 1, 1)), Value("A"))), Subpath(F("path"), node_depth), ), ) else: # Moving root nodes result_class.objects.filter(path__gte=start_node.path, path__depth=1).update( path=Text2LTree(Concat(Ltree2Text(Subpath(F("path"), 0, 1)), Value("A"))) ) result_class.objects.filter(path__gte=start_node.path, path__depth__gt=1).update( path=Concat(Text2LTree(Concat(Ltree2Text(Subpath(F("path"), 0, 1)), Value("A"))), Subpath(F("path"), 1)) ) class LT_AddRootHandler: def __init__(self, cls, **kwargs): super().__init__() self.cls = cls self.kwargs = kwargs def process(self): # Lock all root node rows to avoid integrity errors. We must force evaluation of the queryset list(self.cls.get_root_nodes().select_for_update().only("pk")) # 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. # We must pass an instance here to ensure that the right object is created for # models with multi-table inheritance. return last_root.add_sibling( "sorted-sibling", instance=self.kwargs.get("instance") or self.cls(**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 the new object newobj = self.cls(**self.kwargs) if last_root: # adding the new root node as the last one newobj.path = generate_path(skip=self.cls.get_root_nodes().values_list("path", flat=True)) else: # adding the first root node newobj.path = generate_path() # saving the instance before returning it newobj.save() return newobj class LT_AddChildHandler: def __init__(self, node, creation_kwargs: dict[str, Any]): super().__init__() self.node = node self.node_cls = node.__class__ # These are deliberately not extracted in the function signature to avoid collision with model field names self.kwargs = creation_kwargs def process(self): # Lock parent row self.node_cls.tree_model().objects.filter(pk=self.node.pk).select_for_update().only("pk").get() 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 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) if self.node.is_leaf(): # the node had no children, adding the first child newobj.path = generate_path(prefix=self.node.path) else: # adding the new child as the last one newobj.path = generate_path( prefix=self.node.path, skip=self.node.get_children().values_list("path", flat=True), after=self.node.get_children().last().path[-1], ) # saving the instance before returning it newobj.save() return newobj class LT_AddSiblingHandler(LT_ComplexAddMoveHandler): def __init__(self, node, pos, creation_kwargs: dict[str, Any]): super().__init__() self.node = node self.node_cls = node.__class__ self.pos = pos # These are deliberately not extracted in the function signature to avoid collision with model field names self.kwargs = creation_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) insert_before = None insert_after = None if self.pos == "sorted-sibling": insert_before = self.node.get_sorted_pos_queryset(self.node.get_siblings(), newobj).first() if insert_before: insert_after = insert_before.get_prev_sibling() elif self.pos == "first-sibling": insert_before = self.node.get_siblings().first() elif self.pos == "left": insert_before = self.node insert_after = self.node.get_prev_sibling() elif self.pos == "right": insert_after = self.node insert_before = self.node.get_next_sibling() elif self.pos == "last-sibling": insert_after = self.node.get_siblings().last() newobj.path, _ = self._get_new_path(self.node.path[:-1], insert_before, insert_after) newobj.save() return newobj class LT_MoveHandler(LT_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) if self.pos in ("first-child", "last-child", "sorted-child"): if self.target == self.node: raise InvalidMoveToDescendant(_("Can't move node to itself.")) if self.target.is_descendant_of(self.node): raise InvalidMoveToDescendant(_("Can't move node to a descendant.")) insert_before = None insert_after = None prefix = self.target.path[:-1] # Assume initially that target is a sibling if self.pos == "sorted-sibling": insert_before = self.node.get_sorted_pos_queryset(self.target.get_siblings(), self.node).first() if insert_before: insert_after = insert_before.get_prev_sibling() elif self.pos == "sorted-child": insert_before = self.node.get_sorted_pos_queryset(self.target.get_children(), self.node).first() if insert_before: insert_after = insert_before.get_prev_sibling() prefix = self.target.path elif self.pos == "first-sibling": insert_before = self.target.get_siblings().first() elif self.pos == "last-sibling": insert_after = self.target.get_siblings().last() elif self.pos == "left": insert_before = self.target insert_after = self.target.get_prev_sibling() elif self.pos == "right": insert_after = self.target insert_before = self.target.get_next_sibling() elif self.pos == "first-child": insert_before = self.target.get_children().first() prefix = self.target.path elif self.pos == "last-child": insert_after = self.target.get_children().last() prefix = self.target.path new_path, nodes_moved = self._get_new_path(prefix, insert_before, insert_after) if nodes_moved: self.node.refresh_from_db() # Update the path for all the descendants of the node result_class = self.node_cls.tree_model() result_class.objects.filter(path__descendants=self.node.path, path__depth__gt=len(self.node.path)).update( path=Concat( Value(new_path, output_field=PathField()), Subpath(F("path"), len(self.node.path)), ) ) # And update the path for the node itself self.node.path = new_path self.node.save() class LT_Node(Node): """Abstract model to create your own Postgres LTree trees.""" node_order_by = [] path = PathField(unique=True) TREEBEARD_IDENTIFYING_FIELD = "path" MOVENODE_FORM_EXCLUDED_FIELDS = ("path",) objects = LT_NodeManager() _cached_attributes = (*Node._cached_attributes,) @classmethod @transaction.atomic 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) ``` """ return LT_AddRootHandler(cls, **kwargs).process() @classmethod def dump_bulk(cls, parent=None, keep_ids=True): """Dumps a tree branch to a python data structure.""" cls = cls.tree_model() # 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.objects.all() if parent: qset = qset.filter(path__descendants=parent.path) ret, lnk = [], {} pk_field = cls._meta.pk.attname for pyobj in serializers.serialize("python", qset.iterator()): # django's serializer stores the attributes in 'fields' fields = pyobj["fields"] path = PathValue(fields["path"]) depth = len(path) # this will be useless in load_bulk del fields["path"] 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 = path[0 : depth - 1] parentobj = lnk[str(parentpath)] if "children" not in parentobj: parentobj["children"] = [] parentobj["children"].append(newobj) lnk[str(path)] = 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, the entire tree is returned. """ cls = cls.tree_model() if parent is None: # return the entire tree return cls.objects.all() return cls.objects.filter(path__descendants=parent.path) @classmethod def get_root_nodes(cls): """:returns: A queryset containing the root nodes in the tree.""" return cls.tree_model().objects.filter(path__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* (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 Queryset of node objects with an extra attribute: `descendants_count`. """ cls = cls.tree_model() qs = parent.get_children() if parent else cls.get_root_nodes() subquery = ( cls.objects.filter(path__descendants=OuterRef("path")) .order_by() .annotate(count=Func(F("pk"), function="Count")) .values("count") ) return qs.annotate( descendants_count=Subquery(subquery, output_field=models.IntegerField()) - 1 ) # Subtract the parent node from the count def get_depth(self): """:returns: the depth (level) of the node""" return len(self.path) def get_siblings(self): """ :returns: A queryset of all the node's siblings, including the node itself. """ return self.tree_model().objects.filter(path__descendants=self.path[:-1], path__depth=len(self.path)) def get_children(self): """:returns: A queryset of all the node's children""" return self.tree_model().objects.filter(path__descendants=self.path, path__depth=len(self.path) + 1) def get_next_sibling(self): """ :returns: The next node's sibling, or None if it was the rightmost sibling. """ return self.get_siblings().filter(path__gt=self.path).first() def get_descendants(self, include_self=False): """ :returns: A queryset of all the node's descendants as DFS, doesn't include the node itself if `include_self` is False """ if include_self: return self.__class__.get_tree(self) 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.get_children().count() def is_sibling_of(self, node) -> bool: """ :returns: ``True`` if the node is a sibling of another node given as an argument, else, returns ``False`` """ return self.path[:-1] == node.path[:-1] def is_child_of(self, node) -> bool: """ :returns: ``True`` is the node if a child of another node given as an argument, else, returns ``False`` """ return self.path[:-1] == node.path def is_descendant_of(self, node) -> bool: """ :returns: ``True`` if the node is a descendant of another node given as an argument, else, returns ``False`` """ return len(self.path) > len(node.path) and all([self.path[idx] == label for idx, label in enumerate(node.path)]) @transaction.atomic 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) ``` """ return LT_AddChildHandler(self, kwargs).process() @transaction.atomic 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) ``` """ return LT_AddSiblingHandler(self, pos, kwargs).process() def get_root(self): """:returns: the root node for the current node object.""" return self.tree_model().objects.get(path=self.path[0]) def is_root(self): """:returns: True if the node is a root node (else, returns False)""" return len(self.path) == 1 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): """ :returns: A queryset containing the current node object's ancestors, starting by the root node and descending to the parent. """ return self.tree_model().objects.filter(path__ancestors=self.path).exclude(pk=self.pk) def get_parent(self, update=False): """ :returns: the parent node of the current node object. """ if len(self.path) == 1: return parentpath = self.path[:-1] return self.tree_model().objects.get(path=parentpath) @transaction.atomic def move(self, target, pos=None): """ Moves the current node and all it's descendants to a new position relative to another node. """ return LT_MoveHandler(self, target, pos).process() class Meta: abstract = True django-treebeard-django-treebeard-0a55403/treebeard/ltree/fields.py000066400000000000000000000105001514561205200252320ustar00rootroot00000000000000from collections import UserList from collections.abc import Iterable from django import forms from django.core.validators import RegexValidator from django.db.models import CharField, IntegerField from django.db.models.expressions import Func from django.db.models.fields import TextField from django.db.models.lookups import PostgresOperatorLookup, Transform from django.forms.widgets import TextInput # PathField implementation borrows significantly from https://github.com/mariocesar/django-ltree/blob/master/django_ltree/fields.py path_label_validator = RegexValidator( r"^(?P[a-zA-Z0-9_-]+)(?:\.[a-zA-Z0-9_-]+)*$", "A label is a sequence of alphanumeric characters and underscores separated by dots or slashes.", "invalid", ) class PathValue(UserList): def __init__(self, value): if isinstance(value, str): value = value.strip().split(".") if value else [] elif isinstance(value, Iterable): value = list(value) else: raise ValueError(f"Invalid value: {value!r} for path") super().__init__(initlist=value) def __repr__(self): return str(self) def __str__(self): return ".".join(self) class PathValueProxy: def __init__(self, field_name): self.field_name = field_name def __get__(self, instance, owner): if instance is None: return self value = instance.__dict__[self.field_name] if value is None: return value return PathValue(instance.__dict__[self.field_name]) def __set__(self, instance, value): if instance is None: return self instance.__dict__[self.field_name] = value class PathFormField(forms.CharField): default_validators = [path_label_validator] class PathField(TextField): default_validators = [path_label_validator] def db_type(self, connection): return "ltree" def formfield(self, **kwargs): kwargs["form_class"] = PathFormField kwargs["widget"] = TextInput(attrs={"class": "vTextField"}) return super().formfield(**kwargs) def contribute_to_class(self, cls, name, private_only=False): super().contribute_to_class(cls, name) setattr(cls, self.name, PathValueProxy(self.name)) def from_db_value(self, value, expression, connection, *args): if value is None: return value return PathValue(value) def get_prep_value(self, value): if value is None: return value return str(PathValue(value)) def to_python(self, value): if value is None: return value if isinstance(value, PathValue): return value return PathValue(value) def get_db_prep_value(self, value, connection, prepared=False): if value is None: return value if isinstance(value, PathValue): return str(value) if isinstance(value, (list, str)): return str(PathValue(value)) raise ValueError(f"Unknown value type {type(value)}") @PathField.register_lookup class AncestorLookup(PostgresOperatorLookup): lookup_name = "ancestors" postgres_operator = "@>" @PathField.register_lookup class DescendantLookup(PostgresOperatorLookup): lookup_name = "descendants" postgres_operator = "<@" class Subpath(Func): function = "subpath" output_field = PathField() def __init__(self, expression, pos, length=None, **extra): """ expression: the name of a field, or an expression returning a string pos: an integer >= 0, or an expression returning an integer length: an optional number of labels to return """ if not hasattr(pos, "resolve_expression"): if pos < 0: raise ValueError("'pos' must be positive") expressions = [expression, pos] if length is not None: expressions.append(length) super().__init__(*expressions, **extra) class Ltree2Text(Transform): function = "ltree2text" lookup_name = "ltree2text" output_field = CharField() class Text2LTree(Transform): function = "text2ltree" lookup_name = "text2ltree" output_field = PathField() @PathField.register_lookup class NLevel(Transform): lookup_name = "depth" function = "nlevel" output_field = IntegerField() django-treebeard-django-treebeard-0a55403/treebeard/models.py000066400000000000000000000526371514561205200241550ustar00rootroot00000000000000"""Models and base API""" import operator import warnings from contextlib import suppress from functools import cache, reduce from django.db import models, transaction from django.db.models import Q from treebeard.exceptions import InvalidPosition, MissingNodeOrderBy class Node(models.Model): """Node class""" # Subclasses must override this to provide the name of a field # that identifies the model. TREEBEARD_IDENTIFYING_FIELD = None # Fields to be excluded from MoveNodeForm MOVENODE_FORM_EXCLUDED_FIELDS = () _cached_attributes = () @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 @transaction.atomic 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 PKs. """ # tree, iterative preorder added = [] # stack of nodes to analyze stack = [(parent, node) for node in bulk_data[::-1]] foreign_key_fields = {field.name for field in cls._meta.fields if (field.one_to_one or field.many_to_one)} 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() for field in foreign_key_fields: # Append _id to field name, so that we don't need to load the foreign objects into memory node_data[f"{field}_id"] = node_data.pop(field, None) if keep_ids: node_data["pk"] = node_struct[pk_field] node_obj = parent.add_child(**node_data) if parent else 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. """ return cls.get_root_nodes().first() @classmethod def get_last_root_node(cls): """ :returns: The last root node in the tree or ``None`` if it is empty. """ return cls.get_root_nodes().last() @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. """ return self.get_children().first() def get_last_child(self): """ :returns: The rightmost node's child, or None if it has no children. """ return self.get_children().last() 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().first() 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().last() def get_prev_sibling(self): """ :returns: The previous node's sibling, or None if it was the leftmost sibling. """ ids = list(self.get_siblings().values_list("pk", flat=True)) idx = ids.index(self.pk) if idx > 0: return self.get_siblings().get(pk=ids[idx - 1]) def get_next_sibling(self): """ :returns: The next node's sibling, or None if it was the rightmost sibling. """ ids = list(self.get_siblings().values_list("pk", flat=True)) idx = ids.index(self.pk) if idx < len(ids) - 1: return self.get_siblings().get(pk=ids[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(f"Invalid relative position: {pos}") if self.node_order_by and pos not in valid_sorted_pos: raise InvalidPosition( f"Must use {' or '.join(valid_sorted_pos)} in {method_name} when node_order_by is enabled" ) 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 (MIT 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: comparator = "gt" if field.startswith("-"): field = field[1:] comparator = "lt" value = getattr(newobj, field) if value is None: warnings.warn( f"Received a null value for field '{field}', which is used " f"by '{self.__class__.__name__}.node_order_by'. " "This field will be ignored when sorting the object.", category=RuntimeWarning, ) continue filters.append(Q(*[Q(**{f: v}) for f, v in fields] + [Q(**{f"{field}__{comparator}": value})])) fields.append((field, value)) if not filters: return siblings return siblings.filter(reduce(operator.or_, filters)) def _clear_cached_attributes(self): for attr in self._cached_attributes: with suppress(AttributeError): delattr(self, attr) def refresh_from_db(self, *args, **kwargs): super().refresh_from_db(*args, **kwargs) self._clear_cached_attributes() @classmethod def get_annotated_list_qs(cls, qs): """ Efficiently generates an annotated list from a queryset. The queryset MUST be ordered by path, otherwise it will yield incorrect results. The queryset must also represent the entirety of a branch of a tree: excluded objects will not be fetched and will result in gaps in the tree. """ 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 """ qs = cls.get_tree(parent) if max_depth: qs = qs.filter(depth__lte=max_depth) return cls.get_annotated_list_qs(qs) @classmethod @cache def tree_model(cls): """ 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 fields used by Treebeard). 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(cls.TREEBEARD_IDENTIFYING_FIELD).model if cls._meta.proxy_for_model == base_class: return cls return base_class class Meta: """Abstract model.""" abstract = True django-treebeard-django-treebeard-0a55403/treebeard/mp_tree.py000066400000000000000000001157121514561205200243170ustar00rootroot00000000000000"""Materialized Path Trees""" import collections from functools import cache from typing import Any from django.core import serializers from django.db import connections, models, router, transaction from django.db.models import F, Func, OuterRef, Q, Subquery, Value from django.db.models.functions import Concat, Greatest, Length, Substr from django.utils.translation import gettext_noop as _ from treebeard.exceptions import InvalidMoveToDescendant, NodeAlreadySaved, PathOverflow from treebeard.models import Node from treebeard.numconv import NumConv 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").only("path", "depth", "numchild").iterator(): 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 an ancestor of this node, so skip it 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 parents = collections.Counter() # Mapping of parent path to the number of children it has lost pks_to_remove = [] paths_to_remove = [] for path, node in removed.items(): if parentpath := node._get_basepath(node.path, node.depth - 1): parents[parentpath] += 1 if node.is_leaf(): pks_to_remove.append(node.pk) # More efficient than querying by path else: paths_to_remove.append(node.path) model = self.model.tree_model() # Save the updated numchild of all parents for path, num_lost in parents.items(): model.objects.filter(path=path).update(numchild=Greatest(F("numchild") - num_lost, 0)) # Django will handle this as a SELECT and then a DELETE of # ids, and will deal with removing related objects query = Q(pk__in=pks_to_remove) for path in paths_to_remove: query |= Q(path__startswith=path) return super(MP_NodeQuerySet, model.objects.filter(query)).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_ComplexAddMoveHandler: def increment_numchild(self, path): self.node_cls.tree_model().objects.filter(path=path).update(numchild=F("numchild") + 1) def decrement_numchild(self, path): self.node_cls.tree_model().objects.filter(path=path).update(numchild=F("numchild") - 1) 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.set_newpath_in_branches(oldpath, newpath) return oldpath, newpath 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.set_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 _inc_path = node._inc_path() self.set_newpath_in_branches(node.path, node._inc_path()) 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 = _inc_path + oldpath[len(_inc_path) :] 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 = _inc_path + target.path[len(_inc_path) :] if movebranch: # node to move self.set_newpath_in_branches(tempnewpath or oldpath, newpath) return oldpath, newpath def set_newpath_in_branches(self, oldpath, newpath): """ .. note:: The query will only update depth values if needed. """ new_path_value = Concat(Value(newpath), Substr("path", len(oldpath) + 1)) update_kwargs = {} # Warning: MySQL processes multiple assigments left to right, using the updated value # for any column that is referenced in a subsequent assignment. This behavior differs from standard SQL. # See https://dev.mysql.com/doc/refman/8.4/en/update.html # For a table with schema name (VARCHAR), length (INT) and row (name="bob", length=3), the query: # `UPDATE table SET name='alice', length=LENGTH(name);` # would set `length` to 5 in MySQL, but 3 on other databases, because they use the original source value. # To avoid having to special case for MySQL, we need to supply the depth as the first parameter to # update_kwargs. if len(oldpath) != len(newpath): update_kwargs["depth"] = Length(new_path_value) / self.node_cls.steplen update_kwargs["path"] = new_path_value self.node_cls.tree_model().objects.filter(path__startswith=oldpath).update(**update_kwargs) class MP_AddRootHandler: 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. # We must pass an instance here to ensure that the right object is created for # models with multi-table inheritance. return last_root.add_sibling( "sorted-sibling", instance=self.kwargs.get("instance") or self.cls(**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: def __init__(self, node, creation_kwargs: dict[str, Any]): super().__init__() self.node = node self.node_cls = node.__class__ # These are deliberately not extracted in the function signature to avoid collision with model field names self.kwargs = creation_kwargs def process(self): # Lock the parent row node = self.node_cls.objects.select_for_update().get(pk=self.node.pk) if self.node_cls.node_order_by and not 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 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 = node.depth + 1 if node.is_leaf(): # the node had no children, adding the first child newobj.path = self.node_cls._get_path(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 = node.get_last_child()._inc_path() # Increment numchild on the parent, and also update the object in memory in case the caller reuses it self.node_cls.tree_model().objects.filter(pk=node.pk).update(numchild=F("numchild") + 1) self.node.numchild = 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, creation_kwargs: dict[str, Any]): super().__init__() self.node = node self.node_cls = node.__class__ self.pos = pos # These are deliberately not extracted in the function signature to avoid collision with model field names self.kwargs = creation_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) first = siblings.first() newpos = first._get_lastpos_in_path() if first else 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.increment_numchild(parentpath) # 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) first = siblings.first() newpos = first._get_lastpos_in_path() if first else 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 ) self.update_parent_counts_after_move(oldpath, newpath) self.node.refresh_from_db() # Node path and depth will have changed self.target.refresh_from_db() def update_parent_counts_after_move(self, oldpath, newpath): """ Update the numchild value of parent nodes after performing a move. """ oldparentpath = self.node_cls._get_parent_path_from_path(oldpath) newparentpath = self.node_cls._get_parent_path_from_path(newpath) if oldparentpath != newparentpath: # node changed parent, updating counts if oldparentpath: self.decrement_numchild(oldparentpath) if newparentpath: self.increment_numchild(newparentpath) def update_move_to_child_vars(self): """Update preliminary 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"): if self.target == self.node: raise InvalidMoveToDescendant(_("Can't move node to itself.")) # 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 = self.node_cls.tree_model().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 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) TREEBEARD_IDENTIFYING_FIELD = "path" MOVENODE_FORM_EXCLUDED_FIELDS = ("path", "depth", "numchild") objects = MP_NodeManager() numconv_obj_ = None _cached_attributes = ( *Node._cached_attributes, "_cached_parent_obj", ) @classmethod def _int2str(cls, num): return cls.numconv_obj().int2str(num) @classmethod def _str2int(cls, num): return cls.numconv_obj().str2int(num) @classmethod @cache def numconv_obj(cls): return NumConv(cls.alphabet) @classmethod @transaction.atomic 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 = cls.tree_model() # 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.objects.all().order_by("depth", "path") if parent: qset = qset.filter(path__startswith=parent.path) ret, lnk = [], {} pk_field = cls._meta.pk.attname for pyobj in serializers.serialize("python", qset.iterator()): # 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 = cls.tree_model() 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.alias(computed_depth=Length("path") / cls.steplen) .filter(path__range=cls._get_children_path_interval(node.path), computed_depth=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_numchild(cls, result_class, qs): vendor = connections[router.db_for_write(result_class)].vendor child_subquery = ( result_class.objects.alias(path_length=Length("path")) .order_by() .filter(path__startswith=OuterRef("path"), path_length=Length(OuterRef("path")) + cls.steplen) .annotate(count=Func(F("pk"), function="Count")) .values("count") ) qs = qs.annotate(real_numchild=Subquery(child_subquery, output_field=models.IntegerField())).exclude( numchild=F("real_numchild") ) if vendor != "mysql": qs.update(numchild=F("real_numchild")) else: # Our friend MySQL doesn't support update queries that use a select from the same table # So we have to update each object individually to_update = [] for node in qs.iterator(): node.numchild = node.real_numchild to_update.append(node) result_class.objects.bulk_update(to_update, ["numchild"]) @classmethod def fix_tree(cls, fix_paths=False, parent=None): """ 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 parent: If provided, limits the operation to descendants of the given node. If not provided, the entire tree will be fixed. Fixing only part of a tree will only work if the parent itself is valid. """ cls = cls.tree_model() qs = cls.objects.filter(path__startswith=parent.path) if parent else cls.objects.all() # fix the depth field; we need the exclude query to speed up postgres qs.exclude(depth=Length("path") / cls.steplen).update(depth=Length("path") / cls.steplen) # fix the numchild field cls._fix_numchild(cls, qs) if fix_paths: 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 = [(parent.path, parent.depth + 1)] if parent else [("", 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(_(f"Path Overflow from: '{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 = cls.tree_model() 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 cls.tree_model().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* (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 Queryset of node objects with an extra attribute: `descendants_count`. """ cls = cls.tree_model() qs = parent.get_children() if parent else cls.get_root_nodes() subquery = ( cls.objects.filter(path__startswith=OuterRef("path")) .order_by() .annotate(count=Func(F("pk"), function="Count")) .values("count") ) qs = qs.annotate( descendants_count=Subquery(subquery, output_field=models.IntegerField()) - 1 ) # Subtract the parent node from the count return qs 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 = self.tree_model().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 self.tree_model().objects.none() return ( self.tree_model() .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. """ return self.get_siblings().filter(path__gt=self.path).first() def get_descendants(self, include_self=False): """ :returns: A queryset of all the node's descendants as DFS, doesn't include the node itself if `include_self` is False """ if include_self: return self.__class__.get_tree(self) if self.is_leaf(): return self.tree_model().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. """ return self.get_siblings().filter(path__lt=self.path).last() 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`` """ if self.depth != node.depth: return False if self.depth == 1: return True # Root nodes are always siblings # making sure the non-root nodes share a parent return node.path.startswith(self._get_basepath(self.path, self.depth - 1)) 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 @transaction.atomic 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() @transaction.atomic 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 self.tree_model().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 self.tree_model().objects.none() paths = [self.path[0:pos] for pos in range(0, len(self.path), self.steplen)[1:]] return self.tree_model().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 = self.tree_model().objects.get(path=parentpath) return self._cached_parent_obj @transaction.atomic 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 f"{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(_(f"Path Overflow from: '{self.path}'")) return f"{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 django-treebeard-django-treebeard-0a55403/treebeard/ns_tree.py000066400000000000000000000505151514561205200243220ustar00rootroot00000000000000"""Nested Sets""" import operator from functools import reduce from django.core import serializers from django.db import models, transaction from django.db.models import Case, F, Q, When from django.utils.translation import gettext_noop as _ from treebeard.exceptions import InvalidMoveToDescendant, NodeAlreadySaved from treebeard.models import Node 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 = self.model.tree_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) # 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): model._close_gap(drop_lft, drop_rgt, tree_id) 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) TREEBEARD_IDENTIFYING_FIELD = "lft" MOVENODE_FORM_EXCLUDED_FIELDS = ("depth", "lft", "rgt", "tree_id") objects = NS_NodeManager() _cached_attributes = ( *Node._cached_attributes, "_cached_parent_obj", ) @classmethod @transaction.atomic 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 = cls.tree_model()(**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): lftop = "lft__gte" if lftmove else "lft__gt" output_field = models.PositiveIntegerField() cls.objects.filter(rgt__gte=rgt, tree_id=tree_id).update( lft=Case(When(**{lftop: rgt}, then=F("lft") + incdec), default=F("lft"), output_field=output_field), rgt=Case(When(rgt__gte=rgt, then=F("rgt") + incdec), default=F("rgt"), output_field=output_field), ) @classmethod def _move_tree_right(cls, tree_id): cls.objects.filter(tree_id__gte=tree_id).update(tree_id=F("tree_id") + 1) @transaction.atomic def add_child(self, **kwargs): """Adds a child to the node.""" # Fetch the parent afresh from the database and lock the row # This guards against race conditions and state drift when adding multiple children node = self.__class__.objects.filter(pk=self.pk).select_for_update().get() if not node.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 = node.get_last_child() last_child._cached_parent_obj = node new_sibling = last_child.add_sibling(pos, **kwargs) node.rgt += 2 self.rgt = node.rgt + 2 # Update the rgt of the parent object, which may be used again in a loop return new_sibling # we're adding the first child of this node node.__class__._move_right(node.tree_id, node.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 = node.tree_model()(**kwargs) newobj.tree_id = node.tree_id newobj.depth = node.depth + 1 newobj.lft = node.lft + 1 newobj.rgt = node.lft + 2 # this is just to update the cache self.rgt = node.rgt + 2 newobj._cached_parent_obj = self # saving the instance before returning it newobj.save() return newobj @transaction.atomic 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 = self.tree_model()(**kwargs) newobj.depth = self.depth 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] 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 move_right(target.tree_id, newpos, False, 2) elif pos == "first-sibling": newpos = target.lft move_right(target.tree_id, newpos - 1, False, 2) elif pos == "left": newpos = target.lft move_right(target.tree_id, newpos, True, 2) newobj.lft = newpos newobj.rgt = newpos + 1 # saving the instance before returning it newobj.save() return newobj @transaction.atomic 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 = self.tree_model() original_target = target parent = None if pos in ("first-child", "last-child", "sorted-child"): if self == target: raise InvalidMoveToDescendant(_("Can't move node to itself.")) # 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 nothing to do 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 gap = self.rgt - self.lft + 1 target_tree = target.tree_id # first make a hole if pos == "last-child": newpos = parent.rgt cls._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 cls._move_tree_right(1) elif pos == "left": cls._move_tree_right(target.tree_id) else: if pos == "last-sibling": newpos = target.get_parent().rgt cls._move_right(target.tree_id, newpos, False, gap) elif pos == "first-sibling": newpos = target.lft cls._move_right(target.tree_id, newpos - 1, False, gap) elif pos == "left": newpos = target.lft cls._move_right(target.tree_id, newpos, True, gap) # we refresh 'self' because lft/rgt may have changed self.refresh_from_db() depthdiff = target.depth - self.depth if parent: depthdiff += 1 # move the tree to the hole jump = newpos - self.lft cls.objects.filter(tree_id=self.tree_id, lft__range=(self.lft, self.rgt)).update( tree_id=target_tree, lft=F("lft") + jump, rgt=F("rgt") + jump, depth=F("depth") + depthdiff, ) # close the gap cls._close_gap(self.lft, self.rgt, self.tree_id) self.refresh_from_db() # Tree params will have changed original_target.refresh_from_db() @classmethod def _close_gap(cls, drop_lft, drop_rgt, tree_id): gapsize = drop_rgt - drop_lft + 1 output_field = models.PositiveIntegerField() cls.objects.filter(Q(tree_id=tree_id) & (Q(lft__gt=drop_lft) | Q(rgt__gt=drop_lft))).update( lft=Case(When(lft__gt=drop_lft, then=F("lft") - gapsize), default=F("lft"), output_field=output_field), rgt=Case(When(rgt__gt=drop_lft, then=F("rgt") - gapsize), default=F("rgt"), output_field=output_field), ) 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 self.tree_model().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_tree(parent) ret, lnk = [], {} pk_field = cls._meta.pk.attname for pyobj in qset.iterator(): 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 = cls.tree_model() 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, include_self=False): """ :returns: A queryset of all the node's descendants as DFS, doesn't include the node itself if `include_self` is `False` """ if include_self: return self.__class__.get_tree(self) if self.is_leaf(): return self.tree_model().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 self.tree_model().objects.none() return self.tree_model().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 cls.tree_model().objects.filter(lft=1) class Meta: """Abstract model.""" abstract = True django-treebeard-django-treebeard-0a55403/treebeard/numconv.py000066400000000000000000000035711514561205200243500ustar00rootroot00000000000000"""Convert strings to numbers and numbers to strings. Adapted and simplified for Treebeard from https://tabo.pe/projects/numconv/ by Gustavo Picon """ # from april fool's rfc 1924 BASE85 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~" class NumConv: """Class to create converter objects. :param alphabet: A string that will be used as a encoding alphabet. The base for conversion is the length of the alphabet. The default value is :data:`numconv.BASE85` :raise ValueError: when *alphabet* has duplicated characters """ def __init__(self, alphabet: str = BASE85): """basic validation and cached_map storage""" if len(set(alphabet)) != len(alphabet): raise ValueError(f"duplicate characters found in '{alphabet}'") self.radix = len(alphabet) self.alphabet = alphabet self.cached_map = dict(zip(self.alphabet, range(self.radix))) def int2str(self, num: int) -> str: """Converts an integer into a string. :param num: A numeric value to be converted to a string. :raise ValueError: when *num* isn't positive """ if num < 0: raise ValueError("number must be positive") ret = "" while True: ret = self.alphabet[num % self.radix] + ret if num < self.radix: return ret num //= self.radix def str2int(self, chars) -> int: """Converts a string into an integer. :param chars: A string that will be converted to an integer. :raise ValueError: when *chars* is invalid """ ret = 0 for char in chars: try: ret = ret * self.radix + self.cached_map[char] except KeyError: raise ValueError(f"invalid literal for str2int(): '{chars}") return ret django-treebeard-django-treebeard-0a55403/treebeard/static/000077500000000000000000000000001514561205200235725ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/static/treebeard/000077500000000000000000000000001514561205200255275ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/static/treebeard/expand-collapse.png000066400000000000000000000017741514561205200313250ustar00rootroot00000000000000PNG  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`django-treebeard-django-treebeard-0a55403/treebeard/static/treebeard/treebeard-admin.css000066400000000000000000000020151514561205200312620ustar00rootroot00000000000000/* Treebeard Admin */ .drag-handler { width: 16px; background: transparent url(expand-collapse.png) no-repeat left -48px; height: 16px; margin: 0 10px; display: inline-block; vertical-align: middle; } .drag-handler.active { background: transparent url(expand-collapse.png) no-repeat left -32px; cursor: move; } .spacer { width: 10px; margin: 0 10px; } .treebeard-collapse { width: 16px; height: 16px; display: inline-block; text-indent: -999px; } .treebeard-collapsed { background: transparent url(expand-collapse.png) no-repeat left -16px; } .treebeard-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; } django-treebeard-django-treebeard-0a55403/treebeard/static/treebeard/treebeard-admin.js000066400000000000000000000360171514561205200311170ustar00rootroot00000000000000(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) { const $elem = $(elem); var node_id = $elem.data('node-id'); var parent_id = $elem.data('parent-id'); var level = parseInt($elem.data('level')); return { elem: elem, $elem: $elem, node_id: node_id, parent_id: parent_id, level: level, is_collapsed: function () { return $elem.find('a.treebeard-collapse').hasClass('treebeard-collapsed'); }, children: function () { return $('tr[data-parent-id="' + node_id + '"]'); }, collapse: function () { // For each children, hide it's children and so on... $.each(this.children(),function () { new Node(this).collapse(); }).hide(); // Switch class to set the property expand/collapse icon $elem.find('a.treebeard-collapse').removeClass('treebeard-expanded').addClass('treebeard-collapsed'); }, 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.treebeard-collapse').removeClass('treebeard-collapsed').addClass('treebeard-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); } }); const $resultList = $('#result_list tbody tr'); // Read in JSON context and link it to each row in the table const contextList = JSON.parse(document.getElementById('tree-context').textContent); $resultList.each(function (index, el) { Object.entries(contextList[index]).forEach(function ([key, val]) { $(el).attr(`data-${key}`, val); // Must use attr() to set the HTML5 attribute, not data() }) }); // Add drag handler and spacers to each node $resultList.each(function () { // Inject spacer and collapse buttons into the first table cell that isn't an action checkbox or drag handler const $firstCell = $(this).find("td,th").not(".action-checkbox").first(); if (!$firstCell.length) { return; } const hasChildren = parseInt($(this).data("has-children")); if (hasChildren) { $firstCell.prepend("
-"); } const level = parseInt($(this).data("level")); if (level > 1) { $firstCell.prepend(" ".repeat(level - 1)); } $firstCell.prepend("") }) // Don't activate drag or collapse if GET filters are set on the page, or if user has no change permission if ($('#has-filters').val() === "1" || $('#has-change-permission').val() === "0") { return; } $body = $('body'); // Activate all rows for drag & drop // then bind mouse down event $('.drag-handler').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) { // 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.treebeard-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); django-treebeard-django-treebeard-0a55403/treebeard/templates/000077500000000000000000000000001514561205200243015ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/templates/admin/000077500000000000000000000000001514561205200253715ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/templates/admin/tree_change_list.html000066400000000000000000000020051514561205200315530ustar00rootroot00000000000000{# 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.show_admin_actions %}{% admin_actions %}{% endif %} {% result_tree cl %} {% tree_context cl as node_context %} {{ node_context|json_script:"tree-context" }} {% if action_form and actions_on_bottom and cl.show_admin_actions %}{% admin_actions %}{% endif %} {% endblock %} django-treebeard-django-treebeard-0a55403/treebeard/templates/admin/tree_list.html000066400000000000000000000010211514561205200302430ustar00rootroot00000000000000{# 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 %} django-treebeard-django-treebeard-0a55403/treebeard/templatetags/000077500000000000000000000000001514561205200247755ustar00rootroot00000000000000django-treebeard-django-treebeard-0a55403/treebeard/templatetags/__init__.py000066400000000000000000000004021514561205200271020ustar00rootroot00000000000000from 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 django-treebeard-django-treebeard-0a55403/treebeard/templatetags/admin_tree.py000066400000000000000000000016241514561205200274610ustar00rootroot00000000000000from django.contrib.admin.templatetags.admin_list import result_list from django.template import Library register = Library() 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 @register.inclusion_tag("admin/change_list_results.html") def result_tree(cl): return result_list(cl) @register.simple_tag def tree_context(cl): """ Generate a list containing additional context for each row in the list, for use by the frontend. It is assumed that the template renders the items in the same order as the `result_list` iterator. """ return [ { "node-id": str(obj.pk), "parent-id": _get_parent_id(obj), "level": obj.get_depth(), "has-children": int(not obj.is_leaf()), } for obj in cl.result_list ] django-treebeard-django-treebeard-0a55403/treebeard/templatetags/admin_tree_list.py000066400000000000000000000030031514561205200305050ustar00rootroot00000000000000from django.contrib.admin.options import TO_FIELD_VAR from django.template import Library from django.utils.html import format_html from django.utils.safestring import mark_safe 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))