pax_global_header 0000666 0000000 0000000 00000000064 15112126155 0014511 g ustar 00root root 0000000 0000000 52 comment=8358b1396e4977d5be8851fcc1b56524859ae39f
feincms-django-tree-queries-039d744/ 0000775 0000000 0000000 00000000000 15112126155 0017265 5 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/.editorconfig 0000664 0000000 0000000 00000000310 15112126155 0021734 0 ustar 00root root 0000000 0000000 # top-most EditorConfig file
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.py]
indent_size = 4
feincms-django-tree-queries-039d744/.github/ 0000775 0000000 0000000 00000000000 15112126155 0020625 5 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/.github/FUNDING.yml 0000664 0000000 0000000 00000001252 15112126155 0022442 0 ustar 00root root 0000000 0000000 # These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: matthiask
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
feincms-django-tree-queries-039d744/.github/workflows/ 0000775 0000000 0000000 00000000000 15112126155 0022662 5 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/.github/workflows/test.yml 0000664 0000000 0000000 00000010054 15112126155 0024364 0 ustar 00root root 0000000 0000000 name: Test
on:
push:
pull_request:
schedule:
- cron: "37 1 1 * *"
jobs:
mysql:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 5
matrix:
python-version: ["3.8", "3.10", "3.11", "3.12", "3.13"]
services:
mariadb:
image: mariadb
env:
MARIADB_ROOT_PASSWORD: tree_queries
options: >-
--health-cmd "mariadb-admin ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 3306:3306
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Get pip cache dir
id: pip-cache
run: |
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
with:
path: ${{ steps.pip-cache.outputs.dir }}
key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }}
restore-keys: |
${{ matrix.python-version }}-v1-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade tox tox-gh-actions
- name: Test with tox
run: tox
env:
DB_BACKEND: mysql
DB_USER: root
DB_PASSWORD: tree_queries
DB_HOST: 127.0.0.1
DB_PORT: 3306
postgres:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 5
matrix:
python-version: ["3.8", "3.10", "3.11", "3.12", "3.13"]
services:
postgres:
image: postgres
env:
POSTGRES_DB: tree_queries
POSTGRES_USER: tree_queries
POSTGRES_PASSWORD: tree_queries
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Get pip cache dir
id: pip-cache
run: |
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
with:
path: ${{ steps.pip-cache.outputs.dir }}
key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }}
restore-keys: |
${{ matrix.python-version }}-v1-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade tox tox-gh-actions
- name: Test with tox
run: tox
env:
DB_BACKEND: postgresql
DB_HOST: localhost
DB_PORT: 5432
sqlite:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 5
matrix:
python-version: ["3.8", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Get pip cache dir
id: pip-cache
run: |
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
with:
path: ${{ steps.pip-cache.outputs.dir }}
key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }}
restore-keys: |
${{ matrix.python-version }}-v1-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade tox tox-gh-actions
- name: Test with tox
run: tox
env:
DB_BACKEND: sqlite3
DB_NAME: ":memory:"
feincms-django-tree-queries-039d744/.gitignore 0000664 0000000 0000000 00000000241 15112126155 0021252 0 ustar 00root root 0000000 0000000 *.py?
*~
*.sw?
\#*#
/secrets.py
.DS_Store
._*
*.egg-info
/MANIFEST
/_build
/build
dist
tests/test.zip
/docs/_build
/.eggs
.coverage
htmlcov
venv
.tox
docs/build
feincms-django-tree-queries-039d744/.pre-commit-config.yaml 0000664 0000000 0000000 00000002151 15112126155 0023545 0 ustar 00root root 0000000 0000000 exclude: ".yarn/|yarn.lock|\\.min\\.(css|js)$"
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-added-large-files
- id: check-builtin-literals
- id: check-executables-have-shebangs
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: detect-private-key
- id: end-of-file-fixer
- id: mixed-line-ending
- id: trailing-whitespace
- repo: https://github.com/adamchainz/django-upgrade
rev: 1.29.1
hooks:
- id: django-upgrade
args: [--target-version, "3.2"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.3
hooks:
- id: ruff
args: [--unsafe-fixes]
- id: ruff-format
- repo: https://github.com/biomejs/pre-commit
rev: v2.3.2
hooks:
- id: biome-check
args: [--unsafe]
verbose: true
- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.11.0
hooks:
- id: pyproject-fmt
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.24.1
hooks:
- id: validate-pyproject
feincms-django-tree-queries-039d744/.readthedocs.yaml 0000664 0000000 0000000 00000000500 15112126155 0022507 0 ustar 00root root 0000000 0000000 # Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
version: 2
build:
os: ubuntu-24.04
tools:
python: "3.11"
sphinx:
configuration: docs/conf.py
# python:
# install:
# - requirements: docs/requirements.txt
# - method: pip
# path: .
feincms-django-tree-queries-039d744/CHANGELOG.rst 0000664 0000000 0000000 00000021276 15112126155 0021316 0 ustar 00root root 0000000 0000000 Change log
==========
Next version
~~~~~~~~~~~~
0.23 (2025-11-27)
~~~~~~~~~~~~~~~~~
- Added a new ``OrderableTreeNode`` base class which includes a position field
for ordering among siblings, and started recommending it.
0.22 (2025-11-06)
~~~~~~~~~~~~~~~~~
- Made the new ``tree_queries.admin`` module importable when
``django-js-asset`` isn't installed so that ``compilemessages`` and other
tools automatically importing this module are allowed to work.
- Added an example migration to the documentation for migrating from
django-mptt to django-tree-queries. Thanks to @felixmm for providing this!
- Avoided fetching ancestors of nodes without parents. Thanks to @felixmm!
- Added a system check which verifies that ``"tree_queries"`` has been added to
``INSTALLED_APPS`` if using the ``TreeAdmin`` model admin class.
0.21 (2025-09-16)
~~~~~~~~~~~~~~~~~
- Added the missing ``FieldDoesNotExist`` import. Thanks Kircheneer.
- Fixed all errors reported by pre-commit and started running hooks
automatically.
- Added ``TreeAdmin`` class for Django admin with intuitive tree
management, node moving capabilities, and support for both positioned and
unpositioned trees. Requires ``django-js-asset`` which is included in the new
``admin`` extra.
0.20 (2025-06-11)
~~~~~~~~~~~~~~~~~
- Added Python 3.13, Django 5.1 and 5.2 to the testsuite.
- Added tests showing that ``.descendants().update(...)`` doesn't work, but
``.filter(pk__in=....descendants()).update(...)`` does.
- Added Python 3.13 to the testsuite.
- Converted the tests to use pytest.
- Added a ``tree_info`` template tag and a ``recursetree`` template block.
- Optimized the performance by avoiding the rank table altogether in the simple
case of an ascending ordering on a single field. If that's not possible, the
README now documents using ``.tree_filter()`` and ``.tree_exclude()`` to
filter the queryset before running the recursive CTE.
- Improved the test coverage.
0.19 (2024-04-25)
~~~~~~~~~~~~~~~~~
- Reimplemented the rank table construction using a real queryset; this enables
support for pre-filtering the tree queryset using ``.tree_filter()`` and
``.tree_exclude()``. Thanks rhomboss!
- Added a ``.tree_fields()`` method to allow adding additional columns to the
tree queryset, allowing collecting ancestors fields directly when running the
initial query. For example, ``.tree_fields(tree_names="name")`` will collect
all ``name`` fields in a ``tree_fields`` array on the model instances. For
now the code only supports string fields and integer fields.
0.18 (2024-04-03)
~~~~~~~~~~~~~~~~~
- Fixed broken SQL which was generated when using a tree query with
``EXISTS()`` subqueries.
0.17 (2024-03-26)
~~~~~~~~~~~~~~~~~
- Preserved the tree ordering even when using ``.values()`` or
``.values_list()``. Thanks Glenn Matthews!
- Added support for descending sibling ordering, multi-field sibling ordering,
and related field sibling ordering. Thanks rhomboss!
0.16 (2023-11-29)
~~~~~~~~~~~~~~~~~
- Added Python 3.12, Django 5.0.
- Fixed a problem where ``.values()`` would return an incorrect mapping. Thanks
Glenn Matthews!
- Started running tests periodically to catch bugs earlier.
0.15 (2023-06-19)
~~~~~~~~~~~~~~~~~
- Switched to ruff and hatchling.
- Dropped Django 4.0.
- Added Python 3.11.
- Added a ``.without_tree_fields()`` method which calls
``.with_tree_fields(False)`` in a way which doesn't trigger the flake8
boolean trap linter.
`0.14`_ (2023-01-30)
~~~~~~~~~~~~~~~~~~~~
.. _0.14: https://github.com/matthiask/django-tree-queries/compare/0.13...0.14
- Changed the behavior around sibling ordering to warn if using
``Meta.ordering`` where ordering contains more than one field.
- Added Django 4.2a1 to the CI.
- Django 5.0 will require Python 3.10 or better, pruned the CI jobs list.
- Added quoting to the field name for the ordering between siblings so that
fields named ``order`` can be used. Thanks Tao Bojlén!
- Narrowed exception catching when determining whether the ordering field is an
integer field or not. Thanks Tao Bojlén.
`0.13`_ (2022-12-08)
~~~~~~~~~~~~~~~~~~~~
.. _0.13: https://github.com/matthiask/django-tree-queries/compare/0.12...0.13
- Made it possible to use tree queries with multiple table inheritance. Thanks
Olivier Dalang for the testcases and the initial implementation!
`0.12`_ (2022-11-30)
~~~~~~~~~~~~~~~~~~~~
.. _0.12: https://github.com/matthiask/django-tree-queries/compare/0.11...0.12
- Removed compatibility with Django < 3.2, Python < 3.8.
- Added Django 4.1 to the CI.
- Fixed ``.with_tree_fields().explain()`` on some databases. Thanks Bryan
Culver!
`0.11`_ (2022-06-10)
~~~~~~~~~~~~~~~~~~~~
.. _0.11: https://github.com/matthiask/django-tree-queries/compare/0.10...0.11
- Fixed a crash when running ``.with_tree_fields().distinct().count()`` by 1.
avoiding to select tree fields in distinct subqueries and 2. trusting the
testsuite.
`0.10`_ (2022-06-07)
~~~~~~~~~~~~~~~~~~~~
.. _0.10: https://github.com/matthiask/django-tree-queries/compare/0.9...0.10
- Fixed ordering by string fields to actually work correctly in the presence of
values of varying length.
`0.9`_ (2022-04-01)
~~~~~~~~~~~~~~~~~~~
.. _0.9: https://github.com/matthiask/django-tree-queries/compare/0.8...0.9
- Added ``TreeQuerySet.order_siblings_by`` which allows specifying an ordering
for siblings per-query.
`0.8`_ (2022-03-09)
~~~~~~~~~~~~~~~~~~~
.. _0.8: https://github.com/matthiask/django-tree-queries/compare/0.7...0.8
- Added pre-commit configuration to automatically remove some old-ish code
patterns.
- Fixed a compatibility problem with the upcoming Django 4.1.
`0.7`_ (2021-10-31)
~~~~~~~~~~~~~~~~~~~
.. _0.7: https://github.com/matthiask/django-tree-queries/compare/0.6...0.7
- Added a test with a tree node having a UUID as its primary key.
`0.6`_ (2021-07-21)
~~~~~~~~~~~~~~~~~~~
- Fixed ``TreeQuerySet.ancestors`` to support primary keys not named ``id``.
- Changed the tree compiler to only post-process its own database results.
- Added ``**kwargs``-passing to ``TreeQuery.get_compiler`` for compatibility
with Django 4.0.
`0.5`_ (2021-05-12)
~~~~~~~~~~~~~~~~~~~
- Added support for adding tree fields to queries by default. Create a
manager using ``TreeQuerySet.as_manager(with_tree_fields=True)``.
- Ensured the availability of the ``with_tree_fields`` configuration
also on subclassed managers, e.g. those used for traversing reverse
relations.
- Dropped compatibility with Django 1.8 to avoid adding workarounds to
the testsuite.
- Made it possible to use django-tree-queries in more situations involving
JOINs. Thanks Safa Alfulaij for the contribution!
`0.4`_ (2020-09-13)
~~~~~~~~~~~~~~~~~~~
- Fixed a grave bug where a position of ``110`` would be sorted before
``20`` for obvious reasons.
- Added a custom ``TreeNodeForeignKey.deconstruct`` method to avoid
migrations because of changing field types.
- Removed one case of unnecessary fumbling in ``Query``'s internals
making things needlessly harder than they need to be. Made
django-tree-queries compatible with Django's master branch.
- Removed Python 3.4 from the Travis CI job list.
- Dropped the conversion of primary keys to text on PostgreSQL. It's a
documented constraint that django-tree-queries only supports integer
primary keys, therefore the conversion wasn't necessary at all.
- Reverted to using integer arrays on PostgreSQL for ordering if
possible instead of always converting everything to padded strings.
`0.3`_ (2018-11-15)
~~~~~~~~~~~~~~~~~~~
- Added a ``label_from_instance`` override to the form fields.
- Removed the limitation that nodes can only be ordered using an integer
field within their siblings.
- Changed the representation of ``tree_path`` and ``tree_ordering`` used
on MySQL/MariaDB and sqlite3. Also made it clear that the
representation isn't part of the public interface of this package.
`0.2`_ (2018-10-04)
~~~~~~~~~~~~~~~~~~~
- Added an optional argument to ``TreeQuerySet.with_tree_fields()`` to
allow reverting to a standard queryset (without tree fields).
- Added ``tree_queries.fields.TreeNodeForeignKey``,
``tree_queries.forms.TreeNodeChoiceField`` and
``tree_queries.forms.TreeNodeMultipleChoiceField`` with node depth
visualization.
- Dropped Python 3.4 from the CI.
`0.1`_ (2018-07-30)
~~~~~~~~~~~~~~~~~~~
- Initial release!
.. _0.1: https://github.com/matthiask/django-tree-queries/commit/93d70046a2
.. _0.2: https://github.com/matthiask/django-tree-queries/compare/0.1...0.2
.. _0.3: https://github.com/matthiask/django-tree-queries/compare/0.2...0.3
.. _0.4: https://github.com/matthiask/django-tree-queries/compare/0.3...0.4
.. _0.5: https://github.com/matthiask/django-tree-queries/compare/0.4...0.5
.. _0.6: https://github.com/matthiask/django-tree-queries/compare/0.5...0.6
feincms-django-tree-queries-039d744/LICENSE 0000664 0000000 0000000 00000003014 15112126155 0020270 0 ustar 00root root 0000000 0000000 Copyright (c) 2018, Feinheit AG and individual contributors.
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of Feinheit AG nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
feincms-django-tree-queries-039d744/README.rst 0000664 0000000 0000000 00000060644 15112126155 0020766 0 ustar 00root root 0000000 0000000 ===================
django-tree-queries
===================
.. image:: https://github.com/matthiask/django-tree-queries/actions/workflows/test.yml/badge.svg
:target: https://github.com/matthiask/django-tree-queries/
:alt: CI Status
Query Django model trees using adjacency lists and recursive common
table expressions. Supports PostgreSQL, sqlite3 (3.8.3 or higher) and
MariaDB (10.2.2 or higher) and MySQL (8.0 or higher, if running without
``ONLY_FULL_GROUP_BY``).
Supports Django 3.2 or better, Python 3.8 or better. See the GitHub actions
build for more details.
Features and limitations
========================
- Supports only integer and UUID primary keys (for now).
- Allows specifying ordering among siblings.
- Uses the correct definition of depth, where root nodes have a depth of
zero.
- The parent foreign key must be named ``"parent"`` at the moment (but
why would you want to name it differently?)
- The fields added by the common table expression always are
``tree_depth``, ``tree_path`` and ``tree_ordering``. The names cannot
be changed. ``tree_depth`` is an integer, ``tree_path`` an array of
primary keys representing the path from the root to the current node
(including the current node itself), and ``tree_ordering`` an array of
values used for ordering nodes within their siblings at each level of
the tree hierarchy. Note that the contents of the ``tree_path`` and
``tree_ordering`` are subject to change. You shouldn't rely on their
contents.
- Besides adding the fields mentioned above the package only adds queryset
methods for ordering siblings and filtering ancestors and descendants. Other
features may be useful, but will not be added to the package just because
it's possible to do so.
- Little code, and relatively simple when compared to other tree
management solutions for Django. No redundant values so the only way
to end up with corrupt data is by introducing a loop in the tree
structure (making it a graph). The ``TreeNode`` abstract model class
has some protection against this.
- Supports only trees with max. 50 levels on MySQL/MariaDB, since those
databases do not support arrays and require us to provide a maximum
length for the ``tree_path`` and ``tree_ordering`` upfront.
- **Performance optimization**: The library automatically detects simple cases
(single field ordering, no tree filters, no custom tree fields) and uses an
optimized CTE that avoids creating a rank table, significantly improving
performance for basic tree queries.
Here's a blog post offering some additional insight (hopefully) into the
reasons for `django-tree-queries' existence `_.
Usage
=====
- Install ``django-tree-queries`` using pip.
- Extend ``tree_queries.models.TreeNode`` or build your own queryset
and/or manager using ``tree_queries.query.TreeQuerySet``. The
``TreeNode`` abstract model already contains a ``parent`` foreign key
for your convenience and also uses model validation to protect against
loops.
- Call the ``with_tree_fields()`` queryset method if you require the
additional fields respectively the CTE.
- Call the ``order_siblings_by("field_name")`` queryset method if you want to
order tree siblings by a specific model field. Note that Django's standard
``order_by()`` method isn't supported -- nodes are returned according to the
`depth-first search algorithm
`__.
- Use ``tree_filter()`` and ``tree_exclude()`` for better performance when
working with large tables - these filter the base table before building
the tree structure.
- Use ``tree_fields()`` to aggregate ancestor field values into arrays.
- Create a manager using
``TreeQuerySet.as_manager(with_tree_fields=True)`` if you want to add
tree fields to queries by default.
- Until documentation is more complete I'll have to refer you to the
`test suite
`_
for additional instructions and usage examples, or check the recipes below.
Recipes
=======
Basic models
~~~~~~~~~~~~
The following two examples both extend the ``TreeNode`` which offers a few
agreeable utilities and a model validation method that prevents loops in the
tree structure. The common table expression could be hardened against such
loops but this would involve a performance hit which we don't want -- this is a
documented limitation (non-goal) of the library after all.
Basic tree node
---------------
.. code-block:: python
from tree_queries.models import TreeNode
class Node(TreeNode):
name = models.CharField(max_length=100)
Tree node with ordering among siblings
--------------------------------------
Nodes with the same parent may be ordered among themselves. The default is to
order siblings by their primary key but that's not always very useful.
**Manual position management:**
.. code-block:: python
from tree_queries.models import TreeNode
class Node(TreeNode):
name = models.CharField(max_length=100)
position = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["position"]
**Automatic position management:**
For automatic position management, use ``OrderableTreeNode`` which automatically
assigns sequential position values to new nodes:
.. code-block:: python
from tree_queries.models import OrderableTreeNode
class Category(OrderableTreeNode):
name = models.CharField(max_length=100)
# position field and ordering are inherited from OrderableTreeNode
When creating new nodes without an explicit position, ``OrderableTreeNode``
automatically assigns a position value 10 units higher than the maximum position
among siblings. The increment of 10 (rather than 1) makes it explicit that the
position values themselves have no inherent meaning - they are purely for relative
ordering, not a sibling counter or index.
If you need to customize the Meta class (e.g., to add verbose names or additional
ordering fields), inherit from ``OrderableTreeNode.Meta``:
.. code-block:: python
from tree_queries.models import OrderableTreeNode
class Category(OrderableTreeNode):
name = models.CharField(max_length=100)
class Meta(OrderableTreeNode.Meta):
verbose_name = "category"
verbose_name_plural = "categories"
# ordering = ["position"] is inherited from OrderableTreeNode.Meta
.. code-block:: python
# Create nodes - positions are assigned automatically
root = Category.objects.create(name="Root") # position=10
child1 = Category.objects.create(name="Child 1", parent=root) # position=10
child2 = Category.objects.create(name="Child 2", parent=root) # position=20
child3 = Category.objects.create(name="Child 3", parent=root) # position=30
# Manual reordering is still possible
child3.position = 15 # Move between child1 and child2
child3.save()
This approach is identical to the pattern used in feincms3's ``AbstractPage``.
Add custom methods to queryset
------------------------------
.. code-block:: python
from tree_queries.models import TreeNode
from tree_queries.query import TreeQuerySet
class NodeQuerySet(TreeQuerySet):
def active(self):
return self.filter(is_active=True)
class Node(TreeNode):
is_active = models.BooleanField(default=True)
objects = NodeQuerySet.as_manager()
Querying the tree
~~~~~~~~~~~~~~~~~
All examples assume the ``Node`` class from above.
Basic usage
-----------
.. code-block:: python
# Basic usage, disregards the tree structure completely.
nodes = Node.objects.all()
# Fetch nodes in depth-first search order. All nodes will have the
# tree_path, tree_ordering and tree_depth attributes.
nodes = Node.objects.with_tree_fields()
# Fetch any node.
node = Node.objects.order_by("?").first()
# Fetch direct children and include tree fields. (The parent ForeignKey
# specifies related_name="children")
children = node.children.with_tree_fields()
# Fetch all ancestors starting from the root.
ancestors = node.ancestors()
# Fetch all ancestors including self, starting from the root.
ancestors_including_self = node.ancestors(include_self=True)
# Fetch all ancestors starting with the node itself.
ancestry = node.ancestors(include_self=True).reverse()
# Fetch all descendants in depth-first search order, including self.
descendants = node.descendants(include_self=True)
# Temporarily override the ordering by siblings.
nodes = Node.objects.order_siblings_by("id")
# Revert to a queryset without tree fields (improves performance).
nodes = Node.objects.with_tree_fields().without_tree_fields()
Understanding tree fields
-------------------------
When using ``with_tree_fields()``, each node gets three additional attributes:
- **``tree_depth``**: An integer representing the depth of the node in the tree
(root nodes have depth 0)
- **``tree_path``**: An array containing the primary keys of all ancestors plus
the current node itself, representing the path from root to current node
- **``tree_ordering``**: An array containing the ordering/ranking values used
for sibling ordering at each level of the tree hierarchy
The key difference between ``tree_path`` and ``tree_ordering``:
.. code-block:: python
# Example tree structure:
# Root (pk=1, order=0)
# ├── Child A (pk=2, order=10)
# │ └── Grandchild (pk=4, order=5)
# └── Child B (pk=3, order=20)
# For the Grandchild node:
grandchild = Node.objects.with_tree_fields().get(pk=4)
# tree_path shows the route through primary keys: Root -> Child A -> Grandchild
assert grandchild.tree_path == [1, 2, 4] # [root.pk, child_a.pk, grandchild.pk]
# tree_ordering shows ordering values at each level: Root's order, Child A's order, Grandchild's order
assert grandchild.tree_ordering == [0, 10, 5] # [root.order, child_a.order, grandchild.order]
**Important note**: When not using an explicit ordering (like a ``position``
field), siblings are ordered by their primary key by default. This means
``tree_path`` and ``tree_ordering`` will contain the same values. While this
may be fine for your use case consider adding an explicit ordering field:
.. code-block:: python
class Node(TreeNode):
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
name = models.CharField(max_length=100)
position = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["position"]
Filtering tree subsets
----------------------
**IMPORTANT**: For large tables, always use ``tree_filter()`` or ``tree_exclude()``
to limit which nodes are processed by the recursive CTE. Without these filters,
the database evaluates the entire table, which can be extremely slow.
.. code-block:: python
# Get a specific tree from a forest by filtering on root category
product_tree = Node.objects.with_tree_fields().tree_filter(category="products")
# Get organizational chart for a specific department
engineering_tree = Node.objects.with_tree_fields().tree_filter(department="engineering")
# Exclude entire trees/sections you don't need
content_trees = Node.objects.with_tree_fields().tree_exclude(category="archived")
# Chain multiple tree filters for more specific trees
recent_products = (Node.objects.with_tree_fields()
.tree_filter(category="products")
.tree_filter(created_date__gte=datetime.date.today()))
# Get descendants within a filtered tree subset
product_descendants = (Node.objects.with_tree_fields()
.tree_filter(category="products")
.descendants(some_product_node))
# Filter by site/tenant in multi-tenant applications
site_content = Node.objects.with_tree_fields().tree_filter(site_id=request.site.id)
Performance note: ``tree_filter()`` and ``tree_exclude()`` filter the base table
before the recursive CTE processes relationships, dramatically improving performance
for large datasets compared to using regular ``filter()`` after ``with_tree_fields()``.
Best used for selecting complete trees or tree sections rather than scattered nodes.
Note that the tree queryset doesn't support all types of queries Django
supports. For example, updating all descendants directly isn't supported. The
reason for that is that the recursive CTE isn't added to the UPDATE query
correctly. Workarounds often include moving the tree query into a subquery:
.. code-block:: python
# Doesn't work:
node.descendants().update(is_active=False)
# Use this workaround instead:
Node.objects.filter(pk__in=node.descendants()).update(is_active=False)
Breadth-first search
--------------------
Nobody wants breadth-first search but if you still want it you can achieve it
as follows:
.. code-block:: python
nodes = Node.objects.with_tree_fields().extra(
order_by=["__tree.tree_depth", "__tree.tree_ordering"]
)
Filter by depth
---------------
If you only want nodes from the top two levels:
.. code-block:: python
nodes = Node.objects.with_tree_fields().extra(
where=["__tree.tree_depth <= %s"],
params=[1],
)
Aggregating ancestor fields
---------------------------
Use ``tree_fields()`` to aggregate values from ancestor nodes into arrays. This is
useful for collecting paths, permissions, categories, or any field that should be
inherited down the tree hierarchy.
.. code-block:: python
# Aggregate names from all ancestors into an array
nodes = Node.objects.with_tree_fields().tree_fields(
tree_names="name",
)
# Each node now has a tree_names attribute: ['root', 'parent', 'current']
# Aggregate multiple fields
nodes = Node.objects.with_tree_fields().tree_fields(
tree_names="name",
tree_categories="category",
tree_permissions="permission_level",
)
# Build a full path string from ancestor names
nodes = Node.objects.with_tree_fields().tree_fields(tree_names="name")
for node in nodes:
full_path = " > ".join(node.tree_names) # "Root > Section > Subsection"
# Combine with tree filtering for better performance
active_nodes = (Node.objects.with_tree_fields()
.tree_filter(is_active=True)
.tree_fields(tree_names="name"))
The aggregated fields contain values from all ancestors (root to current node) in
hierarchical order, including the current node itself.
Form fields
~~~~~~~~~~~
django-tree-queries ships a model field and some form fields which augment the
default foreign key field and the choice fields with a version where the tree
structure is visualized using dashes etc. Those fields are
``tree_queries.fields.TreeNodeForeignKey``,
``tree_queries.forms.TreeNodeChoiceField``,
``tree_queries.forms.TreeNodeMultipleChoiceField``.
Templates
~~~~~~~~~
django-tree-queries includes template tags to help render tree structures in
Django templates. These template tags are designed to work efficiently with
tree querysets and respect queryset boundaries.
Setup
-----
Add ``tree_queries`` to your ``INSTALLED_APPS`` setting:
.. code-block:: python
INSTALLED_APPS = [
# ... other apps
'tree_queries',
]
Then load the template tags in your template:
.. code-block:: html
{% load tree_queries %}
tree_info filter
----------------
The ``tree_info`` filter provides detailed information about each node's
position in the tree structure. It's useful when you need fine control over
the tree rendering.
.. code-block:: html
{% load tree_queries %}
{% for node, structure in nodes|tree_info %}
{% if structure.new_level %}
{% else %}
{% endif %}
{{ node.name }}
{% for level in structure.closed_levels %}
{% endfor %}
{% endfor %}
The filter returns tuples of ``(node, structure_info)`` where ``structure_info``
contains:
- ``new_level``: ``True`` if this node starts a new level, ``False`` otherwise
- ``closed_levels``: List of levels that close after this node
- ``ancestors``: List of ancestor node representations from root to immediate parent
Example showing ancestor information:
.. code-block:: html
{% for node, structure in nodes|tree_info %}
{{ node.name }}
{% if structure.ancestors %}
(Path: {% for ancestor in structure.ancestors %}{{ ancestor }}{% if not forloop.last %} > {% endif %}{% endfor %})
{% endif %}
{% endfor %}
recursetree tag
---------------
The ``recursetree`` tag provides recursive rendering similar to django-mptt's
``recursetree`` tag, but optimized for django-tree-queries. It only considers
nodes within the provided queryset and doesn't make additional database queries.
Basic usage:
.. code-block:: html
{% load tree_queries %}
{% recursetree nodes %}
{{ node.name }}
{% if children %}
{{ children }}
{% endif %}
{% endrecursetree %}
The ``recursetree`` tag provides these context variables within the template:
- ``node``: The current tree node
- ``children``: Rendered HTML of child nodes (from the queryset)
- ``is_leaf``: ``True`` if the node has no children in the queryset
Using ``is_leaf`` for conditional rendering:
.. code-block:: html
{% recursetree nodes %}
{{ node.name }}
{% if children %}
{{ children }}
{% elif is_leaf %}
🍃
{% endif %}
{% endrecursetree %}
Advanced example with depth information:
.. code-block:: html
{% recursetree nodes %}
{{ node.name }}
{% if children %}
{{ children }}
{% endif %}
{% endrecursetree %}
Working with limited querysets
-------------------------------
Both template tags respect queryset boundaries and work efficiently with
filtered or limited querysets:
.. code-block:: python
# Only nodes up to depth 2
limited_nodes = Node.objects.with_tree_fields().extra(
where=["__tree.tree_depth <= %s"], params=[2]
)
# Only specific branches
branch_nodes = Node.objects.descendants(some_node, include_self=True)
When using these limited querysets:
- ``recursetree`` will only render nodes from the queryset
- ``is_leaf`` reflects whether nodes have children *in the queryset*, not in the full tree
- No additional database queries are made
- Nodes whose parents aren't in the queryset are treated as root nodes
Example with depth-limited queryset:
.. code-block:: html
{% recursetree limited_nodes %}
{{ node.name }}
{% if is_leaf %}
(leaf in limited view)
{% endif %}
{{ children }}
{% endrecursetree %}
This is particularly useful for creating expandable tree interfaces or
rendering only portions of large trees for performance.
Django Admin Integration
~~~~~~~~~~~~~~~~~~~~~~~~
django-tree-queries includes a ``TreeAdmin`` class for Django's admin interface
that provides an intuitive tree management experience with drag-and-drop style
node moving capabilities.
Installation
------------
To use the admin functionality, install with the ``admin`` extra:
.. code-block:: bash
pip install django-tree-queries[admin]
Usage
-----
**With automatic position management:**
For the best admin experience with proper ordering, use ``OrderableTreeNode``:
.. code-block:: python
from django.contrib import admin
from tree_queries.admin import TreeAdmin
from tree_queries.models import OrderableTreeNode
class Category(OrderableTreeNode):
name = models.CharField(max_length=100)
# position field and ordering are inherited from OrderableTreeNode
@admin.register(Category)
class CategoryAdmin(TreeAdmin):
list_display = [*TreeAdmin.list_display, "name"]
position_field = "position" # Enables sibling ordering controls
**With manual position management:**
If you prefer to manage positions yourself:
.. code-block:: python
from django.contrib import admin
from django.db.models import Max
from tree_queries.admin import TreeAdmin
from tree_queries.models import TreeNode
class Category(TreeNode):
name = models.CharField(max_length=100)
position = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["position"]
def save(self, *args, **kwargs):
# Custom position logic here
if not self.position:
self.position = (
10
+ (
self.__class__._default_manager.filter(parent_id=self.parent_id)
.order_by()
.aggregate(p=Max("position"))["p"]
or 0
)
)
super().save(*args, **kwargs)
save.alters_data = True
@admin.register(Category)
class CategoryAdmin(TreeAdmin):
list_display = [*TreeAdmin.list_display, "name"]
position_field = "position"
The ``TreeAdmin`` provides:
- **Tree visualization**: Nodes are displayed with indentation and visual tree structure
- **Collapsible nodes**: Click to expand/collapse branches for better navigation
- **Node moving**: Cut and paste nodes to reorganize the tree structure
- **Flexible ordering**: Supports both ordered (with position field) and unordered trees
- **Root moves**: Direct "move to root" buttons for trees without sibling ordering
**Configuration:**
- Set ``position_field`` to the field name used for positioning siblings (e.g., ``"position"``, ``"order"``)
- Leave ``position_field = None`` for trees positioned by other criteria (pk, name, etc.)
- The admin automatically adapts its interface based on whether positioning is controllable
**Required list_display columns:**
- ``collapse_column``: Shows expand/collapse toggles
- ``indented_title``: Displays the tree structure with indentation
- ``move_column``: Provides move controls (cut, paste, move-to-root)
These are included by default in ``TreeAdmin.list_display``.
Migrating from django-mptt
~~~~~~~~~~~~~~~~~~~~~~~~~~~
When migrating from django-mptt to django-tree-queries, you'll need to populate
the ``position`` field (or whatever field you use for sibling ordering) based on
the existing MPTT ``lft`` values. Here's an example migration:
.. code-block:: python
def fill_position(apps, schema_editor):
ModelWithMPTT = apps.get_model("your_app", "ModelWithMPTT")
db_alias = schema_editor.connection.alias
position_map = ModelWithMPTT.objects.using(db_alias).annotate(
lft_rank=Window(
expression=RowNumber(),
partition_by=[F("parent_id")],
order_by=["lft"],
),
).in_bulk()
# Update batches of 2000 objects.
batch_size = 2000
qs = ModelWithMPTT.objects.all()
batches = (qs[i : i + batch_size] for i in range(0, qs.count(), batch_size))
for batch in batches:
for obj in batch:
obj.position = position_map[obj.pk].lft_rank
ModelWithMPTT.objects.bulk_update(batch, ["position"])
class Migration(migrations.Migration):
dependencies = [...]
operations = [
migrations.RunPython(
code=fill_position,
reverse_code=migrations.RunPython.noop,
)
]
This migration uses Django's ``Window`` function with ``RowNumber()`` to assign
position values based on the original MPTT ``lft`` ordering, ensuring that siblings
maintain their relative order after the migration.
Note that the position field is used purely for ordering siblings and is not an
index. By default, django-tree-queries' admin interface starts with a position
value of 10 and increments by 10 (10, 20, 30, etc.) to make it explicit that the
position values themselves have no inherent meaning - they are purely for relative
ordering, not a sibling counter or index.
feincms-django-tree-queries-039d744/biome.json 0000664 0000000 0000000 00000002305 15112126155 0021253 0 ustar 00root root 0000000 0000000 {
"$schema": "https://biomejs.dev/schemas/2.3.3/schema.json",
"assist": { "actions": { "source": { "organizeImports": "off" } } },
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"noSvgWithoutTitle": "off"
},
"complexity": {
"noImportantStyles": "off"
},
"correctness": {
"noUndeclaredVariables": "error",
"noUnusedImports": "error",
"noUnusedVariables": "error",
"useHookAtTopLevel": "error"
},
"security": {
"noDangerouslySetInnerHtml": "warn"
},
"style": {
"noParameterAssign": "off",
"useForOf": "warn",
"useArrayLiterals": "error"
},
"suspicious": {
"noArrayIndexKey": "warn",
"noAssignInExpressions": "off"
}
}
},
"javascript": {
"formatter": {
"semicolons": "asNeeded"
},
"globals": ["django", "CKEDITOR"]
},
"css": {
"formatter": {
"enabled": true
},
"linter": {
"enabled": true
}
},
"json": {
"formatter": {
"enabled": false
}
}
}
feincms-django-tree-queries-039d744/docs/ 0000775 0000000 0000000 00000000000 15112126155 0020215 5 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/docs/Makefile 0000664 0000000 0000000 00000001134 15112126155 0021654 0 ustar 00root root 0000000 0000000 # Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = python -msphinx
SPHINXPROJ = test
SOURCEDIR = .
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
feincms-django-tree-queries-039d744/docs/conf.py 0000664 0000000 0000000 00000002465 15112126155 0021523 0 ustar 00root root 0000000 0000000 import os
import re
import subprocess
import sys
from datetime import date
sys.path.append(os.path.abspath(".."))
project = "django-tree-queries"
author = "Feinheit AG"
copyright = f"2018-{date.today().year}, {author}" # noqa: A001
version = __import__("tree_queries").__version__
release = subprocess.check_output(
"git fetch --tags; git describe", shell=True, text=True
).strip()
language = "en"
#######################################
project_slug = re.sub(r"[^a-z]+", "", project)
extensions = []
templates_path = ["_templates"]
source_suffix = ".rst"
master_doc = "index"
exclude_patterns = ["build", "Thumbs.db", ".DS_Store"]
pygments_style = "sphinx"
todo_include_todos = False
html_theme = "alabaster"
html_static_path = ["_static"]
htmlhelp_basename = project_slug + "doc"
latex_elements = {
"papersize": "a4",
}
latex_documents = [
(
master_doc,
project_slug + ".tex",
project + " Documentation",
author,
"manual",
)
]
man_pages = [
(
master_doc,
project_slug,
project + " Documentation",
[author],
1,
)
]
texinfo_documents = [
(
master_doc,
project_slug,
project + " Documentation",
author,
project_slug,
"", # Description
"Miscellaneous",
)
]
feincms-django-tree-queries-039d744/docs/index.rst 0000664 0000000 0000000 00000000071 15112126155 0022054 0 ustar 00root root 0000000 0000000 .. include:: ../README.rst
.. include:: ../CHANGELOG.rst
feincms-django-tree-queries-039d744/docs/make.bat 0000664 0000000 0000000 00000001375 15112126155 0021630 0 ustar 00root root 0000000 0000000 @ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=python -msphinx
)
set SOURCEDIR=.
set BUILDDIR=build
set SPHINXPROJ=test
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The Sphinx module was not found. Make sure you have Sphinx installed,
echo.then set the SPHINXBUILD environment variable to point to the full
echo.path of the 'sphinx-build' executable. Alternatively you may add the
echo.Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
:end
popd
feincms-django-tree-queries-039d744/pyproject.toml 0000664 0000000 0000000 00000005474 15112126155 0022213 0 ustar 00root root 0000000 0000000 [build-system]
build-backend = "hatchling.build"
requires = [
"hatchling",
]
[project]
name = "django-tree-queries"
description = "Tree queries with explicit opt-in, without configurability"
readme = "README.rst"
license = { text = "BSD-3-Clause" }
authors = [
{ name = "Matthias Kestenholz", email = "mk@feinheit.ch" },
]
requires-python = ">=3.8"
classifiers = [
"Environment :: Web Environment",
"Framework :: Django",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
"Topic :: Software Development",
]
dynamic = [
"version",
]
dependencies = [
]
optional-dependencies.admin = [
"django-js-asset",
]
optional-dependencies.tests = [
"coverage",
"django-js-asset",
"pytest",
"pytest-cov",
"pytest-django",
]
urls.Homepage = "https://github.com/matthiask/django-tree-queries/"
[tool.hatch.build]
include = [
"tree_queries/",
]
[tool.hatch.version]
path = "tree_queries/__init__.py"
[tool.ruff]
target-version = "py38"
preview = true
fix = true
show-fixes = true
lint.extend-select = [
# flake8-builtins
"A",
# flake8-bugbear
"B",
# flake8-comprehensions
"C4",
# mmcabe
"C90",
# flake8-django
"DJ",
"E",
# pyflakes, pycodestyle
"F",
# flake8-boolean-trap
"FBT",
# flake8-logging-format
"G",
# isort
"I",
# flake8-gettext
"INT",
# pep8-naming
"N",
# pygrep-hooks
"PGH",
# flake8-pie
"PIE",
# pylint
"PLC",
"PLE",
"PLW",
# flake8-pytest-style
"PT",
# unused noqa
"RUF100",
# flake8-simplify
"SIM",
# pyupgrade
"UP",
"W",
# flake8-2020
"YTT",
]
lint.extend-ignore = [
# Allow zip() without strict=
"B905",
# No line length errors
"E501",
]
lint.per-file-ignores."*/migrat*/*" = [
# Allow using PascalCase model names in migrations
"N806",
# Ignore the fact that migration files are invalid module names
"N999",
]
lint.isort.combine-as-imports = true
lint.isort.lines-after-imports = 2
lint.mccabe.max-complexity = 15
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "testapp.settings"
python_files = [ "tests.py", "test_*.py", "*_tests.py" ]
testpaths = [ "tests" ]
addopts = "-v --tb=short --strict-markers --ds=testapp.settings --cov=tree_queries --cov-report=term-missing"
markers = [
"django_db: mark test to use django database",
"postgresql: mark test as PostgreSQL-specific",
"mysql: mark test as MySQL-specific",
]
feincms-django-tree-queries-039d744/tests/ 0000775 0000000 0000000 00000000000 15112126155 0020427 5 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/tests/.gitignore 0000664 0000000 0000000 00000000024 15112126155 0022413 0 ustar 00root root 0000000 0000000 /.coverage
/htmlcov
feincms-django-tree-queries-039d744/tests/TREE_ADMIN_IMPLEMENTATION.md 0000664 0000000 0000000 00000020637 15112126155 0024635 0 ustar 00root root 0000000 0000000 # TreeAdmin Implementation Summary
This document captures the complete implementation of the TreeAdmin functionality for django-tree-queries, developed in collaboration with Claude Code.
## Overview
Added a comprehensive TreeAdmin class for Django admin that provides intuitive tree management with drag-and-drop style node moving capabilities, supporting both positioned and unpositioned trees.
## Key Features Implemented
### 1. TreeAdmin Class (`tree_queries/admin.py`)
- **Base class**: Extends Django's `ModelAdmin`
- **Configuration**: `position_field = None` (set to field name for controllable positioning)
- **Automatic adaptation**: Interface changes based on whether positioning is controllable
- **List display columns**: `collapse_column`, `indented_title`, `move_column`
### 2. Tree Visualization
- **Hierarchical display**: Unicode box-drawing characters for tree structure
- **Collapsible nodes**: Click to expand/collapse branches
- **Depth indication**: Visual indentation and depth-based styling
- **Row highlighting**: Different background colors by tree depth
### 3. Node Moving System
- **Cut/paste workflow**: Click cut button, select destination from dropdown
- **Position options** (when positioning is controllable):
- `before`: Move before another sibling
- `after`: Move after another sibling
- `first-child`: Move as first child
- `last-child`: Move as last child
- **Root moves** (when positioning not controllable):
- Direct "move to root" button with confirmation workflow
- `child`: Move as child of another node
- `root`: Move to root level
### 4. Smart Interface Adaptation
- **With position field**: Shows full move options (before, after, first-child, last-child)
- **Without position field**: Shows simplified options (child, root) + direct root button
- **Visual state management**: Uses `data-move` attribute for CSS state control
## Technical Implementation
### JavaScript (`tree_queries/static/tree_queries/tree_admin.js`)
- **Tree collapsing**: Recursive node visibility management
- **Move state management**: Session storage persistence across page reloads
- **Fetch API integration**: Consolidated `performMove()` function for all move operations
- **Error handling**: Comprehensive error messages and state cleanup
- **Button behavior**: Smart mode switching between regular and root moves
### CSS (`tree_queries/static/tree_queries/tree_admin.css`)
- **Tree visualization**: Box-drawing characters and indentation
- **State management**: Data attribute selectors (`body[data-move="root"]`)
- **Button styling**: Inline SVG icons from Material Design
- **Status bar**: Fixed position move status with action buttons
- **Responsive design**: Works with Django admin's responsive layout
### Form Validation (`MoveNodeForm`)
- **Dynamic field setup**: Adapts to admin's position_field configuration
- **Position validation**: Ensures valid moves based on tree constraints
- **Error handling**: Clear validation messages for invalid operations
- **Backend processing**: Handles all move types with proper sibling ordering
## File Structure
```
tree_queries/
├── admin.py # Main TreeAdmin class and MoveNodeForm
├── static/tree_queries/
│ ├── tree_admin.css # Complete styling with inline SVG icons
│ └── tree_admin.js # Tree interaction and move functionality
└── templatetags/
└── tree_queries.py # Template tags (tree_info, recursetree)
tests/testapp/
├── admin.py # Example admin classes for testing
├── models.py # Test models (Model, UnorderedModel, etc.)
└── test_admin.py # Comprehensive test suite (12 test cases)
```
## Dependencies and Installation
### Package Configuration (`pyproject.toml`)
```toml
dependencies = [] # Core package has no dependencies
optional-dependencies.admin = [
"django-js-asset",
]
optional-dependencies.tests = [
"coverage",
"pytest",
"pytest-cov",
"pytest-django",
"django-js-asset",
]
```
### Installation
```bash
# Core functionality only
pip install django-tree-queries
# With admin functionality
pip install django-tree-queries[admin]
# For development/testing
pip install django-tree-queries[tests]
```
### CI/CD Configuration
- **tox.ini**: Added `django-js-asset` to test dependencies
- **GitHub Actions**: Uses tox for cross-platform testing
### Running Tests
```bash
# Run tests for specific Python/Django combination
tox -e py313-dj52-sqlite
# Run all supported combinations
tox
# Run with specific database backends
tox -e py313-dj52-postgresql
tox -e py313-dj52-mysql
# Run specific test files or add pytest arguments
tox -e py313-dj52-sqlite -- tests/testapp/test_admin.py -v
tox -e py313-dj52-sqlite -- tests/testapp/test_admin.py::TreeAdminTestCase::test_position_field_configuration -v
```
## Usage Examples
### Basic TreeAdmin
```python
from django.contrib import admin
from tree_queries.admin import TreeAdmin
from .models import Category
@admin.register(Category)
class CategoryAdmin(TreeAdmin):
list_display = [*TreeAdmin.list_display, "name", "is_active"]
position_field = "order" # For controllable sibling positioning
```
### Unpositioned Trees
```python
@admin.register(Department)
class DepartmentAdmin(TreeAdmin):
list_display = [*TreeAdmin.list_display, "name"]
# position_field = None (default) - uses direct root moves
```
## Test Coverage
### Test Suite (`tests/testapp/test_admin.py`)
- **12 comprehensive test cases**
- **Coverage**: Form validation, move operations, UI adaptation
- **Test classes**: `TreeAdminTestCase`, `MoveOperationTestCase`
- **Scenarios**: Both positioned and unpositioned tree models
### Key Test Cases
1. Position field configuration validation
2. Move position options based on positioning capability
3. Form validation for all move types
4. Actual move operations (before, after, first-child, last-child, root, child)
5. UI context and button visibility
## Design Decisions
### 1. Terminology
- **"Position" over "Ordering"**: Avoided Django's overloaded "ordering" terminology
- **position_field**: More specific than generic "ordering_field"
- **Positioning vs Ordering**: Clearer semantic distinction
### 2. Architecture
- **Direct field access**: Removed getter methods, use `self.position_field` directly
- **Truthiness checks**: Use `if self.position_field:` instead of `!= None`
- **Consolidated constants**: Single source of truth for move positions
### 3. Dependencies
- **Minimal core**: No dependencies for basic tree functionality
- **Optional admin**: Admin functionality via `[admin]` extra
- **Self-contained icons**: Inline SVG to avoid external dependencies
- **Proper attribution**: Clear credit to Material Design icons
### 4. UX/UI
- **Consistent buttons**: Status bar uses buttons for both confirm and cancel
- **Visual feedback**: Row highlighting during moves
- **State persistence**: Move state survives page navigation
- **Accessibility**: Proper button semantics and ARIA-friendly
## Performance Considerations
- **Efficient queries**: Uses existing `with_tree_fields()` infrastructure
- **Minimal JavaScript**: No heavy libraries, vanilla JS only
- **CSS optimization**: Inline SVG data URLs, no external requests
- **State management**: Lightweight session storage usage
## Future Enhancement Possibilities
Based on the implementation, potential future enhancements could include:
1. **Bulk operations**: Multiple node selection and moving
2. **Drag-and-drop**: Direct mouse-based moving (though cut/paste is often more reliable)
3. **Keyboard shortcuts**: Arrow keys for navigation, shortcuts for common operations
4. **Custom position field types**: Support for different ordering strategies
5. **Tree filtering**: Admin filters based on tree structure
6. **Export/import**: Tree structure serialization
## Documentation
- **README.rst**: Complete usage documentation with examples
- **CHANGELOG.rst**: Added entry for TreeAdmin functionality
- **Inline comments**: Code documentation and icon attribution
- **Type hints**: Could be added for better IDE support
## Conclusion
The TreeAdmin implementation provides a production-ready, intuitive interface for managing tree structures in Django admin. The code is well-tested, properly documented, and designed for maintainability and extensibility.
The implementation successfully balances functionality with simplicity, providing powerful tree management capabilities while maintaining Django admin's familiar patterns and conventions.
feincms-django-tree-queries-039d744/tests/manage.py 0000775 0000000 0000000 00000000535 15112126155 0022237 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
import os
import sys
from os.path import abspath, dirname
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings")
sys.path.insert(0, dirname(dirname(abspath(__file__))))
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
feincms-django-tree-queries-039d744/tests/testapp/ 0000775 0000000 0000000 00000000000 15112126155 0022107 5 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/tests/testapp/__init__.py 0000664 0000000 0000000 00000000000 15112126155 0024206 0 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/tests/testapp/admin.py 0000664 0000000 0000000 00000001005 15112126155 0023545 0 ustar 00root root 0000000 0000000 from django.contrib import admin
from testapp import models
from tree_queries.admin import TreeAdmin
@admin.register(models.Model)
class ModelAdmin(TreeAdmin):
position_field = "order"
list_display = [*TreeAdmin.list_display, "name"]
@admin.register(models.UnorderedModel)
class UnorderedModelAdmin(TreeAdmin):
list_display = [*TreeAdmin.list_display, "name"]
@admin.register(models.StringOrderedModel)
class StringOrderedModelAdmin(TreeAdmin):
list_display = [*TreeAdmin.list_display, "name"]
feincms-django-tree-queries-039d744/tests/testapp/models.py 0000664 0000000 0000000 00000006203 15112126155 0023745 0 ustar 00root root 0000000 0000000 import uuid
from django.db import models
from tree_queries.models import OrderableTreeNode, TreeNode
from tree_queries.query import TreeQuerySet
class Model(TreeNode):
custom_id = models.AutoField(primary_key=True)
order = models.PositiveIntegerField(default=0)
name = models.CharField(max_length=100)
class Meta:
ordering = ("order",)
def __str__(self):
return self.name
class UnorderedModel(TreeNode):
name = models.CharField(max_length=100)
class StringOrderedModel(TreeNode):
name = models.CharField(max_length=100)
class Meta:
ordering = ("name",)
unique_together = (("name", "parent"),)
def __str__(self):
return self.name
class AlwaysTreeQueryModelCategory(models.Model):
def __str__(self):
return ""
class ReferenceModel(models.Model):
position = models.PositiveIntegerField(default=0)
tree_field = models.ForeignKey(
Model,
on_delete=models.CASCADE,
blank=True,
null=True,
)
class Meta:
ordering = ("position",)
def __str__(self):
return ""
class AlwaysTreeQueryModel(TreeNode):
name = models.CharField(max_length=100)
related = models.ManyToManyField("self", symmetrical=True)
category = models.ForeignKey(
AlwaysTreeQueryModelCategory,
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="instances",
)
objects = TreeQuerySet.as_manager(with_tree_fields=True)
class Meta:
base_manager_name = "objects"
def __str__(self):
return self.name
class UUIDModel(TreeNode):
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
name = models.CharField(max_length=100)
def __str__(self):
return self.name
class MultiOrderedModel(TreeNode):
first_position = models.PositiveIntegerField(default=0)
second_position = models.PositiveIntegerField(default=0)
name = models.CharField(max_length=100)
class Meta:
ordering = ("first_position",)
def __str__(self):
return self.name
class TreeNodeIsOptional(models.Model):
parent = models.ForeignKey("self", null=True, on_delete=models.CASCADE)
objects = TreeQuerySet.as_manager()
def __str__(self):
return ""
class InheritParentModel(TreeNode):
name = models.CharField(max_length=100)
class InheritChildModel(InheritParentModel):
pass
class InheritGrandChildModel(InheritChildModel):
pass
class InheritAbstractChildModel(InheritParentModel):
class Meta:
abstract = True
class InheritConcreteGrandChildModel(InheritAbstractChildModel):
pass
class RelatedOrderModel(TreeNode):
name = models.CharField(max_length=100)
class OneToOneRelatedOrder(models.Model):
relatedmodel = models.OneToOneField(
RelatedOrderModel,
on_delete=models.CASCADE,
primary_key=True,
related_name="related",
)
order = models.PositiveIntegerField(default=0)
def __str__(self):
return ""
class OrderedModel(OrderableTreeNode):
name = models.CharField(max_length=100)
def __str__(self):
return self.name
feincms-django-tree-queries-039d744/tests/testapp/settings.py 0000664 0000000 0000000 00000004666 15112126155 0024335 0 ustar 00root root 0000000 0000000 import os
DATABASES = {
"default": {
"ENGINE": f"django.db.backends.{os.getenv('DB_BACKEND', 'sqlite3')}",
"NAME": os.getenv("DB_NAME", ":memory:"),
"USER": os.getenv("DB_USER"),
"PASSWORD": os.getenv("DB_PASSWORD"),
"HOST": os.getenv("DB_HOST", ""),
"PORT": os.getenv("DB_PORT", ""),
"TEST": {
"USER": "default_test",
},
},
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
if os.environ.get("DB_BACKEND") in {"mysql", "mariadb"}:
DATABASES["default"]["OPTIONS"] = {
"init_command": "SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));"
}
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.admin",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.staticfiles",
"django.contrib.messages",
"testapp",
"tree_queries",
]
USE_TZ = True
MEDIA_ROOT = "/media/"
STATIC_URL = "/static/"
BASEDIR = os.path.dirname(__file__)
MEDIA_ROOT = os.path.join(BASEDIR, "media/")
STATIC_ROOT = os.path.join(BASEDIR, "static/")
SECRET_KEY = "supersikret"
LOGIN_REDIRECT_URL = "/?login=1"
ROOT_URLCONF = "testapp.urls"
LANGUAGES = (("en", "English"), ("de", "German"))
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
if os.getenv("SQL"): # pragma: no cover
from django.utils.log import DEFAULT_LOGGING as LOGGING
LOGGING["handlers"]["console"]["level"] = "DEBUG"
LOGGING["loggers"]["django.db.backends"] = {
"level": "DEBUG",
"handlers": ["console"],
"propagate": False,
}
feincms-django-tree-queries-039d744/tests/testapp/test_admin.py 0000664 0000000 0000000 00000034355 15112126155 0024622 0 ustar 00root root 0000000 0000000 from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import User
from django.contrib.messages.storage.fallback import FallbackStorage
from django.contrib.sessions.middleware import SessionMiddleware
from django.db import models as django_models
from django.test import RequestFactory, TestCase
from testapp import models
from testapp.admin import ModelAdmin, UnorderedModelAdmin
from tree_queries.admin import MOVE_POSITIONS, MOVE_POSITIONS_PARENT_ONLY, MoveNodeForm
class TreeAdminTestCase(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
# Create superuser for admin access
self.user = User.objects.create_superuser("admin", "admin@test.com", "password")
# Set up test tree structure with ordering
self.root = models.Model.objects.create(name="root", order=10)
self.child1 = models.Model.objects.create(
name="child1", parent=self.root, order=10
)
self.child2 = models.Model.objects.create(
name="child2", parent=self.root, order=20
)
self.grandchild = models.Model.objects.create(
name="grandchild", parent=self.child1, order=10
)
# Set up unordered tree structure
self.unordered_root = models.UnorderedModel.objects.create(name="root")
self.unordered_child = models.UnorderedModel.objects.create(
name="child", parent=self.unordered_root
)
def setup_request(self, request):
"""Set up request with proper middleware for messages and sessions."""
# Set up session
middleware = SessionMiddleware(lambda r: None)
middleware.process_request(request)
request.session.save()
# Set up messages
request._messages = FallbackStorage(request)
request.user = self.user
return request
def test_position_field_configuration(self):
"""Test different position field configurations."""
# Model with position field
admin = ModelAdmin(models.Model, self.site)
assert admin.position_field == "order"
assert admin.position_field
# Model without position field
admin = UnorderedModelAdmin(models.UnorderedModel, self.site)
assert admin.position_field is None
assert not admin.position_field
def test_move_positions_with_ordering(self):
"""Test available move positions when ordering is controllable."""
admin = ModelAdmin(models.Model, self.site)
request = self.factory.get("/")
request.user = self.user
# Should show all positions
move_column_html = admin.move_column(self.child1)
for key in MOVE_POSITIONS:
assert f'value="{key}"' in move_column_html
def test_move_positions_without_ordering(self):
"""Test available move positions when ordering is not controllable."""
admin = UnorderedModelAdmin(models.UnorderedModel, self.site)
request = self.factory.get("/")
request.user = self.user
# Should show only parent-only positions
move_column_html = admin.move_column(self.unordered_child)
for key in MOVE_POSITIONS_PARENT_ONLY:
assert f'value="{key}"' in move_column_html
# Should not show sibling positions
assert 'value="before"' not in move_column_html
assert 'value="after"' not in move_column_html
# Should show "move to root" button for child nodes in models without ordering
assert "move-to-root" in move_column_html
# But root nodes should NOT show the "move to root" button
root_move_column_html = admin.move_column(self.unordered_root)
assert "move-to-root" not in root_move_column_html
def test_tree_admin_context(self):
"""Test the context provided to JavaScript."""
admin = ModelAdmin(models.Model, self.site)
request = self.factory.get("/")
request.user = self.user
context = admin.tree_admin_context(request)
assert context["initiallyCollapseDepth"] == 1
# Test without ordering
admin = UnorderedModelAdmin(models.UnorderedModel, self.site)
context = admin.tree_admin_context(request)
assert context["initiallyCollapseDepth"] == 1
def test_move_node_form_with_ordering(self):
"""Test move node form validation with ordering field."""
admin = ModelAdmin(models.Model, self.site)
request = self.factory.post(
"/admin/testapp/model/move-node/",
{
"move": self.grandchild.pk,
"relative_to": self.child2.pk,
"position": "before",
},
)
request.user = self.user
form = MoveNodeForm(request.POST, modeladmin=admin, request=request)
assert form.is_valid()
# Test all valid positions
for position in MOVE_POSITIONS:
form = MoveNodeForm(
{
"move": self.grandchild.pk,
"relative_to": self.child2.pk,
"position": position,
},
modeladmin=admin,
request=request,
)
assert form.is_valid(), f"Position {position} should be valid"
def test_move_node_form_without_ordering(self):
"""Test move node form validation without ordering field."""
admin = UnorderedModelAdmin(models.UnorderedModel, self.site)
request = self.factory.post(
"/admin/testapp/unorderedmodel/move-node/",
{
"move": self.unordered_child.pk,
"relative_to": self.unordered_root.pk,
"position": "child",
},
)
request.user = self.user
form = MoveNodeForm(request.POST, modeladmin=admin, request=request)
assert form.is_valid()
# Test only valid positions for unordered model
for position in MOVE_POSITIONS_PARENT_ONLY:
form = MoveNodeForm(
{
"move": self.unordered_child.pk,
"relative_to": self.unordered_root.pk,
"position": position,
},
modeladmin=admin,
request=request,
)
assert form.is_valid(), f"Position {position} should be valid"
# Test invalid positions for unordered model
form = MoveNodeForm(
{
"move": self.unordered_child.pk,
"relative_to": self.unordered_root.pk,
"position": "before",
},
modeladmin=admin,
request=request,
)
assert not form.is_valid()
# Test that "root" is valid without relative_to
form = MoveNodeForm(
{"move": self.unordered_child.pk, "position": "root"},
modeladmin=admin,
request=request,
)
assert form.is_valid()
# Test that "root" is also valid with relative_to (for backward compatibility)
form = MoveNodeForm(
{
"move": self.unordered_child.pk,
"relative_to": self.unordered_root.pk,
"position": "root",
},
modeladmin=admin,
request=request,
)
assert form.is_valid()
# Test that non-root positions require relative_to
form = MoveNodeForm(
{"move": self.unordered_child.pk, "position": "child"},
modeladmin=admin,
request=request,
)
assert not form.is_valid()
class MoveOperationTestCase(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.user = User.objects.create_superuser("admin", "admin@test.com", "password")
# Create test tree with ordering
self.root = models.Model.objects.create(name="root", order=10)
self.child1 = models.Model.objects.create(
name="child1", parent=self.root, order=10
)
self.child2 = models.Model.objects.create(
name="child2", parent=self.root, order=20
)
self.child3 = models.Model.objects.create(
name="child3", parent=self.root, order=30
)
def setup_request(self, request):
"""Set up request with proper middleware for messages and sessions."""
# Set up session
middleware = SessionMiddleware(lambda r: None)
middleware.process_request(request)
request.session.save()
# Set up messages
request._messages = FallbackStorage(request)
request.user = self.user
return request
def test_move_before(self):
"""Test moving a node before another sibling."""
admin = ModelAdmin(models.Model, self.site)
request = self.factory.post("/test/")
request.user = self.user
form = MoveNodeForm(
{
"move": self.child3.pk,
"relative_to": self.child1.pk,
"position": "before",
},
modeladmin=admin,
request=request,
)
# Verify form is valid
assert form.is_valid()
# Test the move logic directly
move = form.cleaned_data["move"]
relative_to = form.cleaned_data["relative_to"]
form.cleaned_data["position"]
# Simulate the move logic
move.parent = relative_to.parent
position_field = admin.position_field
siblings_qs = relative_to.__class__._default_manager.filter(
parent=relative_to.parent
)
siblings_qs.filter(**{
f"{position_field}__gte": getattr(relative_to, position_field)
}).update(**{position_field: django_models.F(position_field) + 10})
setattr(move, position_field, getattr(relative_to, position_field))
move.save()
# Refresh from database
self.child3.refresh_from_db()
self.child1.refresh_from_db()
# child3 should now have the same order as child1 originally had
assert self.child3.order == 10
def test_move_after(self):
"""Test moving a node after another sibling."""
admin = ModelAdmin(models.Model, self.site)
request = self.factory.post(
"/admin/testapp/model/move-node/",
{
"move": self.child1.pk,
"relative_to": self.child2.pk,
"position": "after",
},
)
request = self.setup_request(request)
form = MoveNodeForm(request.POST, modeladmin=admin, request=request)
result = form.process()
assert result == "ok"
# Refresh from database
self.child1.refresh_from_db()
# child1 should now be positioned after child2
assert self.child1.order == 30 # child2.order + 10
def test_move_first_child(self):
"""Test moving a node as first child."""
# Create a new node to move
new_child = models.Model.objects.create(name="new_child", order=100)
admin = ModelAdmin(models.Model, self.site)
request = self.factory.post(
"/admin/testapp/model/move-node/",
{
"move": new_child.pk,
"relative_to": self.root.pk,
"position": "first-child",
},
)
request = self.setup_request(request)
form = MoveNodeForm(request.POST, modeladmin=admin, request=request)
result = form.process()
assert result == "ok"
# Refresh from database
new_child.refresh_from_db()
# Should be child of root and have order 10 (first position)
assert new_child.parent == self.root
assert new_child.order == 10
def test_move_last_child(self):
"""Test moving a node as last child."""
new_child = models.Model.objects.create(name="new_child", order=100)
admin = ModelAdmin(models.Model, self.site)
request = self.factory.post(
"/admin/testapp/model/move-node/",
{
"move": new_child.pk,
"relative_to": self.root.pk,
"position": "last-child",
},
)
request = self.setup_request(request)
form = MoveNodeForm(request.POST, modeladmin=admin, request=request)
result = form.process()
assert result == "ok"
# Refresh from database
new_child.refresh_from_db()
# Should be child of root
assert new_child.parent == self.root
def test_move_to_root_without_ordering(self):
"""Test moving a node to root level when no ordering is available."""
# Use unordered model
root = models.UnorderedModel.objects.create(name="root")
child = models.UnorderedModel.objects.create(name="child", parent=root)
admin = UnorderedModelAdmin(models.UnorderedModel, self.site)
request = self.factory.post(
"/admin/testapp/unorderedmodel/move-node/",
{
"move": child.pk,
"position": "root", # No relative_to needed for root moves
},
)
request = self.setup_request(request)
form = MoveNodeForm(request.POST, modeladmin=admin, request=request)
result = form.process()
assert result == "ok"
# Refresh from database
child.refresh_from_db()
# Should now be at root level
assert child.parent is None
def test_move_as_child_without_ordering(self):
"""Test moving a node as child when no ordering is available."""
root1 = models.UnorderedModel.objects.create(name="root1")
root2 = models.UnorderedModel.objects.create(name="root2")
child = models.UnorderedModel.objects.create(name="child", parent=root1)
admin = UnorderedModelAdmin(models.UnorderedModel, self.site)
request = self.factory.post(
"/admin/testapp/unorderedmodel/move-node/",
{"move": child.pk, "relative_to": root2.pk, "position": "child"},
)
request = self.setup_request(request)
form = MoveNodeForm(request.POST, modeladmin=admin, request=request)
result = form.process()
assert result == "ok"
# Refresh from database
child.refresh_from_db()
# Should now be child of root2
assert child.parent == root2
feincms-django-tree-queries-039d744/tests/testapp/test_ordered.py 0000664 0000000 0000000 00000014137 15112126155 0025152 0 ustar 00root root 0000000 0000000 from django.test import TestCase
from .models import OrderedModel
class OrderableTreeNodeTestCase(TestCase):
def test_automatic_position_assignment(self):
"""Test that position is automatically assigned to new nodes"""
root = OrderedModel.objects.create(name="Root")
assert root.position == 10
child1 = OrderedModel.objects.create(name="Child 1", parent=root)
assert child1.position == 10
child2 = OrderedModel.objects.create(name="Child 2", parent=root)
assert child2.position == 20
child3 = OrderedModel.objects.create(name="Child 3", parent=root)
assert child3.position == 30
def test_manual_position_respected(self):
"""Test that manually set positions are not overwritten"""
root = OrderedModel.objects.create(name="Root", position=100)
assert root.position == 100
child = OrderedModel.objects.create(name="Child", parent=root, position=50)
assert child.position == 50
def test_position_increments_from_max(self):
"""Test that positions increment from the current maximum"""
root = OrderedModel.objects.create(name="Root")
# Create children with custom positions
OrderedModel.objects.create(name="Child 1", parent=root, position=100)
OrderedModel.objects.create(name="Child 2", parent=root, position=200)
# Next auto-assigned position should be max + 10
child3 = OrderedModel.objects.create(name="Child 3", parent=root)
assert child3.position == 210
def test_siblings_ordered_by_position(self):
"""Test that siblings are correctly ordered by position"""
root = OrderedModel.objects.create(name="Root")
child1 = OrderedModel.objects.create(name="Child 1", parent=root)
child2 = OrderedModel.objects.create(name="Child 2", parent=root)
child3 = OrderedModel.objects.create(name="Child 3", parent=root)
siblings = list(root.children.all())
assert siblings[0] == child1
assert siblings[1] == child2
assert siblings[2] == child3
def test_reordering_siblings(self):
"""Test that siblings can be manually reordered"""
root = OrderedModel.objects.create(name="Root")
child1 = OrderedModel.objects.create(name="Child 1", parent=root) # position=10
child2 = OrderedModel.objects.create(name="Child 2", parent=root) # position=20
child3 = OrderedModel.objects.create(name="Child 3", parent=root) # position=30
# Move child3 between child1 and child2
child3.position = 15
child3.save()
siblings = list(root.children.all())
assert siblings[0] == child1
assert siblings[1] == child3
assert siblings[2] == child2
def test_position_per_parent(self):
"""Test that positions are assigned per parent"""
root1 = OrderedModel.objects.create(name="Root 1")
root2 = OrderedModel.objects.create(name="Root 2")
child1_1 = OrderedModel.objects.create(name="Child 1-1", parent=root1)
child2_1 = OrderedModel.objects.create(name="Child 2-1", parent=root2)
# Both should get position 10 since they have different parents
assert child1_1.position == 10
assert child2_1.position == 10
child1_2 = OrderedModel.objects.create(name="Child 1-2", parent=root1)
child2_2 = OrderedModel.objects.create(name="Child 2-2", parent=root2)
# Both should get position 20
assert child1_2.position == 20
assert child2_2.position == 20
def test_ordering_with_tree_queries(self):
"""Test that ordering works correctly with tree queries"""
root = OrderedModel.objects.create(name="Root")
child1 = OrderedModel.objects.create(name="Child 1", parent=root)
OrderedModel.objects.create(name="Grandchild 1", parent=child1)
OrderedModel.objects.create(name="Grandchild 2", parent=child1)
OrderedModel.objects.create(name="Child 2", parent=root)
# Get tree with tree fields
nodes = list(OrderedModel.objects.with_tree_fields())
# Verify depth-first order respects position ordering
assert nodes[0].name == "Root"
assert nodes[1].name == "Child 1"
assert nodes[2].name == "Grandchild 1"
assert nodes[3].name == "Grandchild 2"
assert nodes[4].name == "Child 2"
def test_update_preserves_position(self):
"""Test that updating a node doesn't change its position"""
root = OrderedModel.objects.create(name="Root")
child = OrderedModel.objects.create(name="Child", parent=root)
original_position = child.position
assert original_position == 10
# Update the name
child.name = "Updated Child"
child.save()
# Position should remain the same
assert child.position == original_position
def test_zero_position_is_replaced(self):
"""Test that position=0 triggers auto-assignment"""
root = OrderedModel.objects.create(name="Root")
# Even if we explicitly set position=0, it should be replaced on create
child = OrderedModel(name="Child", parent=root, position=0)
child.save()
assert child.position == 10
# Create another child to verify positions increment correctly
child2 = OrderedModel.objects.create(name="Child 2", parent=root)
assert child2.position == 20
def test_ordering_inherited_from_meta(self):
"""Test that ordering is inherited from OrderableTreeNode.Meta"""
# This test verifies that the Meta.ordering is properly inherited
root = OrderedModel.objects.create(name="Root")
OrderedModel.objects.create(name="Child 2", parent=root, position=20)
OrderedModel.objects.create(name="Child 1", parent=root, position=10)
OrderedModel.objects.create(name="Child 3", parent=root, position=30)
# Query without explicit ordering should use Meta.ordering
children = list(OrderedModel.objects.filter(parent=root))
assert children[0].name == "Child 1"
assert children[1].name == "Child 2"
assert children[2].name == "Child 3"
feincms-django-tree-queries-039d744/tests/testapp/test_queries.py 0000664 0000000 0000000 00000113757 15112126155 0025213 0 ustar 00root root 0000000 0000000 from types import SimpleNamespace
import pytest
from django import forms
from django.core.exceptions import ValidationError
from django.db import connections, models
from django.db.models import Count, Q, Sum
from django.db.models.expressions import RawSQL
from django.test import TestCase
from testapp.models import (
AlwaysTreeQueryModel,
AlwaysTreeQueryModelCategory,
InheritChildModel,
InheritConcreteGrandChildModel,
InheritGrandChildModel,
InheritParentModel,
Model,
MultiOrderedModel,
OneToOneRelatedOrder,
ReferenceModel,
RelatedOrderModel,
StringOrderedModel,
TreeNodeIsOptional,
UnorderedModel,
UUIDModel,
)
from tree_queries.compiler import SEPARATOR, TreeCompiler, TreeQuery
from tree_queries.query import pk
@pytest.mark.django_db
class TestTreeQueries:
def create_tree(self):
tree = SimpleNamespace()
tree.root = Model.objects.create(name="root")
tree.child1 = Model.objects.create(parent=tree.root, order=0, name="1")
tree.child2 = Model.objects.create(parent=tree.root, order=1, name="2")
tree.child1_1 = Model.objects.create(parent=tree.child1, order=0, name="1-1")
tree.child2_1 = Model.objects.create(parent=tree.child2, order=0, name="2-1")
tree.child2_2 = Model.objects.create(parent=tree.child2, order=42, name="2-2")
return tree
def test_stuff(self):
Model.objects.create()
assert len(Model.objects.with_tree_fields()) == 1
instance = Model.objects.with_tree_fields().get()
assert instance.tree_depth == 0
assert instance.tree_path == [instance.pk]
def test_no_attributes(self):
tree = self.create_tree()
root = Model.objects.get(pk=tree.root.pk)
assert not hasattr(root, "tree_depth")
assert not hasattr(root, "tree_ordering")
assert not hasattr(root, "tree_path")
def test_attributes(self):
tree = self.create_tree()
# Ordering should be deterministic
child2_2 = (
Model.objects.with_tree_fields()
.order_siblings_by("order", "pk")
.get(pk=tree.child2_2.pk)
)
assert child2_2.tree_depth == 2
# Tree ordering is an array of the ranks assigned to a comment's
# ancestors when they are ordered without respect for tree relations.
assert child2_2.tree_ordering == [1, 5, 6]
assert child2_2.tree_path == [tree.root.pk, tree.child2.pk, tree.child2_2.pk]
def test_ancestors(self):
tree = self.create_tree()
tc = TestCase()
with tc.assertNumQueries(2):
assert list(tree.child2_2.ancestors()) == [tree.root, tree.child2]
assert list(tree.child2_2.ancestors(include_self=True)) == [
tree.root,
tree.child2,
tree.child2_2,
]
assert list(tree.child2_2.ancestors().reverse()) == [tree.child2, tree.root]
assert list(tree.root.ancestors()) == []
assert list(tree.root.ancestors(include_self=True)) == [tree.root]
child2_2 = Model.objects.with_tree_fields().get(pk=tree.child2_2.pk)
tc = TestCase()
with tc.assertNumQueries(1):
assert list(child2_2.ancestors()) == [tree.root, tree.child2]
def test_descendants(self):
tree = self.create_tree()
assert list(tree.child2.descendants()) == [tree.child2_1, tree.child2_2]
assert list(tree.child2.descendants(include_self=True)) == [
tree.child2,
tree.child2_1,
tree.child2_2,
]
def test_queryset_or(self):
tree = self.create_tree()
qs = Model.objects.with_tree_fields()
assert list(qs.filter(pk=tree.child1.pk) | qs.filter(pk=tree.child2.pk)) == [
tree.child1,
tree.child2,
]
def test_twice(self):
assert list(Model.objects.with_tree_fields().with_tree_fields()) == []
def test_boring_coverage(self):
with pytest.raises(ValueError, match="Need either using or connection"):
TreeQuery(Model).get_compiler()
def test_count(self):
tree = self.create_tree()
assert Model.objects.count() == 6
assert Model.objects.with_tree_fields().count() == 6
assert Model.objects.with_tree_fields().distinct().count() == 6
assert list(Model.objects.descendants(tree.child1)) == [tree.child1_1]
assert Model.objects.descendants(tree.child1).count() == 1
assert Model.objects.descendants(tree.child1).distinct().count() == 1
# .distinct() shouldn't always remove tree fields
qs = list(Model.objects.with_tree_fields().distinct())
assert qs[0].tree_depth == 0
assert qs[5].tree_depth == 2
def test_annotate(self):
tree = self.create_tree()
assert [
(node, node.children__count, node.tree_depth)
for node in Model.objects.with_tree_fields().annotate(Count("children"))
] == [
(tree.root, 2, 0),
(tree.child1, 1, 1),
(tree.child1_1, 0, 2),
(tree.child2, 2, 1),
(tree.child2_1, 0, 2),
(tree.child2_2, 0, 2),
]
def test_update_aggregate(self):
self.create_tree()
Model.objects.with_tree_fields().update(order=3)
assert Model.objects.with_tree_fields().aggregate(Sum("order")) == {
"order__sum": 18
}
# TODO Sum("tree_depth") does not work because the field is not
# known yet.
def test_update_descendants(self):
"""UpdateQuery does not work with tree queries"""
tree = self.create_tree()
# OperationalError would probably be appropriate, but the psycopg2
# backend raises psycopg2.errors.UndefinedTable, which isn't an
# OperationalError subclass.
with pytest.raises(Exception, match="__tree"):
tree.root.descendants().update(name="test")
def test_update_descendants_with_filter(self):
"""Updating works when using a filter"""
tree = self.create_tree()
Model.objects.filter(pk__in=tree.child2.descendants()).update(name="test")
assert [node.name for node in Model.objects.with_tree_fields()] == [
"root",
"1",
"1-1",
"2",
"test",
"test",
]
def test_delete_descendants(self):
"""DeleteQuery works with tree queries"""
tree = self.create_tree()
tree.child2.descendants(include_self=True).delete()
assert list(Model.objects.with_tree_fields()) == [
tree.root,
tree.child1,
tree.child1_1,
# tree.child2,
# tree.child2_1,
# tree.child2_2,
]
def test_aggregate_descendants(self):
"""AggregateQuery works with tree queries"""
tree = self.create_tree()
assert tree.root.descendants(include_self=True).aggregate(Sum("pk"))[
"pk__sum"
] == sum(node.pk for node in Model.objects.all())
def test_values(self):
self.create_tree()
assert list(Model.objects.with_tree_fields().values("name")) == [
{"name": "root"},
{"name": "1"},
{"name": "1-1"},
{"name": "2"},
{"name": "2-1"},
{"name": "2-2"},
]
def test_values_ancestors(self):
tree = self.create_tree()
assert list(Model.objects.ancestors(tree.child2_1).values()) == [
{
"custom_id": tree.root.pk,
"name": "root",
"order": 0,
"parent_id": None,
},
{
"custom_id": tree.child2.pk,
"name": "2",
"order": 1,
"parent_id": tree.root.pk,
},
]
def test_values_list(self):
self.create_tree()
assert list(
Model.objects.with_tree_fields().values_list("name", flat=True)
) == ["root", "1", "1-1", "2", "2-1", "2-2"]
def test_values_list_ancestors(self):
tree = self.create_tree()
assert list(
Model.objects.ancestors(tree.child2_1).values_list("parent", flat=True)
) == [tree.root.parent_id, tree.child2.parent_id]
def test_loops(self):
tree = self.create_tree()
tree.root.parent_id = tree.child1.pk
with pytest.raises(ValidationError) as cm:
tree.root.full_clean()
assert cm.value.messages == ["A node cannot be made a descendant of itself."]
# No error.
tree.child1.full_clean()
def test_unordered(self):
assert list(UnorderedModel.objects.all()) == []
u2 = UnorderedModel.objects.create(name="u2")
u1 = UnorderedModel.objects.create(name="u1")
u0 = UnorderedModel.objects.create(name="u0")
u1.parent = u0
u1.save()
u2.parent = u0
u2.save()
# Siblings are ordered by primary key (in order of creation)
assert [obj.name for obj in UnorderedModel.objects.with_tree_fields()] == [
"u0",
"u2",
"u1",
]
def test_revert(self):
tree = self.create_tree()
obj = (
Model.objects.with_tree_fields().without_tree_fields().get(pk=tree.root.pk)
)
assert not hasattr(obj, "tree_depth")
def test_form_field(self):
tree = self.create_tree()
class Form(forms.ModelForm):
class Meta:
model = Model
fields = ["parent"]
html = f"{Form().as_table()}"
assert f'' in html
assert "root" in html
class OtherForm(forms.Form):
node = Model._meta.get_field("parent").formfield(
label_from_instance=lambda obj: "{}{}".format(
"".join(
["*** " if obj == tree.child2_1 else "--- "] * obj.tree_depth
),
obj,
),
queryset=tree.child2.descendants(),
)
html = f"{OtherForm().as_table()}"
assert f'' in html
assert "root" not in html
def test_string_ordering(self):
tree = SimpleNamespace()
tree.americas = StringOrderedModel.objects.create(name="Americas")
tree.europe = StringOrderedModel.objects.create(name="Europe")
tree.france = StringOrderedModel.objects.create(
name="France", parent=tree.europe
)
tree.south_america = StringOrderedModel.objects.create(
name="South America", parent=tree.americas
)
tree.ecuador = StringOrderedModel.objects.create(
name="Ecuador", parent=tree.south_america
)
tree.colombia = StringOrderedModel.objects.create(
name="Colombia", parent=tree.south_america
)
tree.peru = StringOrderedModel.objects.create(
name="Peru", parent=tree.south_america
)
tree.north_america = StringOrderedModel.objects.create(
name="North America", parent=tree.americas
)
assert list(StringOrderedModel.objects.with_tree_fields()) == [
tree.americas,
tree.north_america,
tree.south_america,
tree.colombia,
tree.ecuador,
tree.peru,
tree.europe,
tree.france,
]
assert list(tree.peru.ancestors(include_self=True)) == [
tree.americas,
tree.south_america,
tree.peru,
]
assert list(
StringOrderedModel.objects.descendants(tree.americas, include_self=True)
) == [
tree.americas,
tree.north_america,
tree.south_america,
tree.colombia,
tree.ecuador,
tree.peru,
]
def test_many_ordering(self):
root = Model.objects.create(order=1, name="root")
for i in range(20, 0, -1):
Model.objects.create(parent=root, name=f"Node {i}", order=i * 10)
positions = [m.order for m in Model.objects.with_tree_fields()]
assert positions == sorted(positions)
def test_bfs_ordering(self):
tree = self.create_tree()
nodes = Model.objects.with_tree_fields().extra(
order_by=["__tree.tree_depth", "__tree.tree_ordering"]
)
assert list(nodes) == [
tree.root,
tree.child1,
tree.child2,
tree.child1_1,
tree.child2_1,
tree.child2_2,
]
def test_always_tree_query(self):
AlwaysTreeQueryModel.objects.create(name="Nothing")
obj = AlwaysTreeQueryModel.objects.get()
assert hasattr(obj, "tree_depth")
assert hasattr(obj, "tree_ordering")
assert hasattr(obj, "tree_path")
assert obj.tree_depth == 0
AlwaysTreeQueryModel.objects.update(name="Something")
obj.refresh_from_db()
assert obj.name == "Something"
AlwaysTreeQueryModel.objects.all().delete()
def test_tree_query_select_related(self):
AlwaysTreeQueryModel.objects.create(
name="Nothing", category=AlwaysTreeQueryModelCategory.objects.create()
)
AlwaysTreeQueryModel.objects.create(
name="Nothing", category=AlwaysTreeQueryModelCategory.objects.create()
)
tc = TestCase()
with tc.assertNumQueries(1):
_read = [
(obj, obj.category)
for obj in AlwaysTreeQueryModel.objects.with_tree_fields().select_related(
"category"
)
]
def test_always_tree_query_relations(self):
c = AlwaysTreeQueryModelCategory.objects.create()
m1 = AlwaysTreeQueryModel.objects.create(name="Nothing", category=c)
m2 = AlwaysTreeQueryModel.objects.create(name="Something")
m1.related.add(m2)
m3 = m2.related.get()
assert m1 == m3
assert m3.tree_depth == 0
m4 = c.instances.get()
assert m1 == m4
assert m4.tree_depth == 0
def test_reference(self):
tree = self.create_tree()
references = SimpleNamespace()
references.none = ReferenceModel.objects.create(position=0)
references.root = ReferenceModel.objects.create(
position=1, tree_field=tree.root
)
references.child1 = ReferenceModel.objects.create(
position=2, tree_field=tree.child1
)
references.child2 = ReferenceModel.objects.create(
position=3, tree_field=tree.child2
)
references.child1_1 = ReferenceModel.objects.create(
position=4, tree_field=tree.child1_1
)
references.child2_1 = ReferenceModel.objects.create(
position=5, tree_field=tree.child2_1
)
references.child2_2 = ReferenceModel.objects.create(
position=6, tree_field=tree.child2_2
)
assert list(
ReferenceModel.objects.filter(
tree_field__in=tree.child2.descendants(include_self=True)
)
) == [references.child2, references.child2_1, references.child2_2]
assert list(
ReferenceModel.objects.filter(
Q(tree_field__in=tree.child2.ancestors(include_self=True))
| Q(tree_field__in=tree.child2.descendants(include_self=True))
)
) == [
references.root,
references.child2,
references.child2_1,
references.child2_2,
]
assert list(
ReferenceModel.objects.filter(
Q(tree_field__in=tree.child2_2.descendants(include_self=True))
| Q(tree_field__in=tree.child1.descendants())
| Q(tree_field__in=tree.child1.ancestors())
)
) == [references.root, references.child1_1, references.child2_2]
assert list(
ReferenceModel.objects.exclude(
Q(tree_field__in=tree.child2.ancestors(include_self=True))
| Q(tree_field__in=tree.child2.descendants(include_self=True))
| Q(tree_field__isnull=True)
)
) == [references.child1, references.child1_1]
assert list(
ReferenceModel.objects.exclude(
Q(tree_field__in=tree.child2.descendants())
| Q(tree_field__in=tree.child2.ancestors())
| Q(tree_field__in=tree.child1.descendants(include_self=True))
| Q(tree_field__in=tree.child1.ancestors())
)
) == [references.none, references.child2]
assert list(
ReferenceModel.objects.filter(
Q(
Q(tree_field__in=tree.child2.descendants())
& ~Q(id=references.child2_2.id)
)
| Q(tree_field__isnull=True)
| Q(tree_field__in=tree.child1.ancestors())
)
) == [references.none, references.root, references.child2_1]
assert list(
ReferenceModel.objects.filter(
tree_field__in=tree.child2.descendants(include_self=True).filter(
parent__in=tree.child2.descendants(include_self=True)
)
)
) == [references.child2_1, references.child2_2]
def test_reference_isnull_issue63(self):
# https://github.com/feincms/django-tree-queries/issues/63
assert (
list(Model.objects.with_tree_fields().exclude(referencemodel__isnull=False))
== []
)
def test_annotate_tree(self):
tree = self.create_tree()
qs = Model.objects.with_tree_fields().filter(
Q(pk__in=tree.child2.ancestors(include_self=True))
| Q(pk__in=tree.child2.descendants(include_self=True))
)
if connections[Model.objects.db].vendor == "postgresql":
qs = qs.annotate(
is_my_field=RawSQL(
"%s = ANY(__tree.tree_path)",
[pk(tree.child2_1)],
output_field=models.BooleanField(),
)
)
else:
qs = qs.annotate(
is_my_field=RawSQL(
f'instr(__tree.tree_path, "{SEPARATOR}{pk(tree.child2_1)}{SEPARATOR}") <> 0',
[],
output_field=models.BooleanField(),
)
)
assert [(node, node.is_my_field) for node in qs] == [
(tree.root, False),
(tree.child2, False),
(tree.child2_1, True),
(tree.child2_2, False),
]
def test_uuid_queries(self):
root = UUIDModel.objects.create(name="root")
child1 = UUIDModel.objects.create(parent=root, name="child1")
child2 = UUIDModel.objects.create(parent=root, name="child2")
assert set(root.descendants()) == {child1, child2}
assert list(child1.ancestors(include_self=True)) == [root, child1]
def test_sibling_ordering(self):
tree = SimpleNamespace()
tree.root = MultiOrderedModel.objects.create(name="root")
tree.child1 = MultiOrderedModel.objects.create(
parent=tree.root, first_position=0, second_position=1, name="1"
)
tree.child2 = MultiOrderedModel.objects.create(
parent=tree.root, first_position=1, second_position=0, name="2"
)
tree.child1_1 = MultiOrderedModel.objects.create(
parent=tree.child1, first_position=0, second_position=1, name="1-1"
)
tree.child2_1 = MultiOrderedModel.objects.create(
parent=tree.child2, first_position=0, second_position=1, name="2-1"
)
tree.child2_2 = MultiOrderedModel.objects.create(
parent=tree.child2, first_position=1, second_position=0, name="2-2"
)
first_order = [
tree.root,
tree.child1,
tree.child1_1,
tree.child2,
tree.child2_1,
tree.child2_2,
]
second_order = [
tree.root,
tree.child2,
tree.child2_2,
tree.child2_1,
tree.child1,
tree.child1_1,
]
nodes = MultiOrderedModel.objects.order_siblings_by("second_position")
assert list(nodes) == second_order
nodes = MultiOrderedModel.objects.with_tree_fields()
assert list(nodes) == first_order
nodes = MultiOrderedModel.objects.order_siblings_by("second_position").all()
assert list(nodes) == second_order
def test_depth_filter(self):
tree = self.create_tree()
nodes = Model.objects.with_tree_fields().extra(
where=["__tree.tree_depth between %s and %s"],
params=[0, 1],
)
assert list(nodes) == [
tree.root,
tree.child1,
# tree.child1_1,
tree.child2,
# tree.child2_1,
# tree.child2_2,
]
@pytest.mark.postgresql
@pytest.mark.skipif(
connections["default"].vendor != "postgresql",
reason="EXPLAIN test only meaningful for PostgreSQL",
)
def test_explain(self):
explanation = Model.objects.with_tree_fields().explain()
assert "CTE" in explanation
def test_tree_queries_without_tree_node(self):
TreeNodeIsOptional.objects.create(parent=TreeNodeIsOptional.objects.create())
nodes = list(TreeNodeIsOptional.objects.with_tree_fields())
assert nodes[0].tree_depth == 0
assert nodes[1].tree_depth == 1
def test_polymorphic_queries(self):
"""test queries on concrete child classes in multi-table inheritance setup"""
# create a tree with a random mix of classes/subclasses
root = InheritChildModel.objects.create(name="root")
child1 = InheritGrandChildModel.objects.create(parent=root, name="child1")
child2 = InheritParentModel.objects.create(parent=root, name="child2")
InheritParentModel.objects.create(parent=child1, name="child1_1")
InheritChildModel.objects.create(parent=child2, name="child2_1")
InheritConcreteGrandChildModel.objects.create(parent=child2, name="child2_2")
# ensure we get the full tree if querying the super class
objs = InheritParentModel.objects.with_tree_fields()
assert {(p.name, tuple(p.tree_path)) for p in objs} == {
("root", (1,)),
("child1", (1, 2)),
("child1_1", (1, 2, 4)),
("child2", (1, 3)),
("child2_1", (1, 3, 5)),
("child2_2", (1, 3, 6)),
}
# ensure we still get the tree when querying only a subclass (including sub-subclasses)
objs = InheritChildModel.objects.with_tree_fields()
assert {(p.name, tuple(p.tree_path)) for p in objs} == {
("root", (1,)),
("child1", (1, 2)),
("child2_1", (1, 3, 5)),
}
# ensure we still get the tree when querying only a subclass
objs = InheritGrandChildModel.objects.with_tree_fields()
assert {(p.name, tuple(p.tree_path)) for p in objs} == {
("child1", (1, 2)),
}
# ensure we don't get confused by an intermediate abstract subclass
objs = InheritConcreteGrandChildModel.objects.with_tree_fields()
assert {(p.name, tuple(p.tree_path)) for p in objs} == {
("child2_2", (1, 3, 6)),
}
def test_descending_order(self):
tree = self.create_tree()
nodes = Model.objects.order_siblings_by("-order")
assert list(nodes) == [
tree.root,
tree.child2,
tree.child2_2,
tree.child2_1,
tree.child1,
tree.child1_1,
]
def test_multi_field_order(self):
tree = SimpleNamespace()
tree.root = MultiOrderedModel.objects.create(name="root")
tree.child1 = MultiOrderedModel.objects.create(
parent=tree.root, first_position=0, second_position=1, name="1"
)
tree.child2 = MultiOrderedModel.objects.create(
parent=tree.root, first_position=0, second_position=0, name="2"
)
tree.child1_1 = MultiOrderedModel.objects.create(
parent=tree.child1, first_position=1, second_position=1, name="1-1"
)
tree.child2_1 = MultiOrderedModel.objects.create(
parent=tree.child2, first_position=0, second_position=1, name="2-1"
)
tree.child2_2 = MultiOrderedModel.objects.create(
parent=tree.child2, first_position=1, second_position=0, name="2-2"
)
nodes = MultiOrderedModel.objects.order_siblings_by(
"first_position", "-second_position"
)
assert list(nodes) == [
tree.root,
tree.child1,
tree.child1_1,
tree.child2,
tree.child2_1,
tree.child2_2,
]
def test_order_by_related(self):
tree = SimpleNamespace()
tree.root = RelatedOrderModel.objects.create(name="root")
tree.child1 = RelatedOrderModel.objects.create(parent=tree.root, name="1")
tree.child1_related = OneToOneRelatedOrder.objects.create(
relatedmodel=tree.child1, order=0
)
tree.child2 = RelatedOrderModel.objects.create(parent=tree.root, name="2")
tree.child2_related = OneToOneRelatedOrder.objects.create(
relatedmodel=tree.child2, order=1
)
tree.child1_1 = RelatedOrderModel.objects.create(parent=tree.child1, name="1-1")
tree.child1_1_related = OneToOneRelatedOrder.objects.create(
relatedmodel=tree.child1_1, order=0
)
tree.child2_1 = RelatedOrderModel.objects.create(parent=tree.child2, name="2-1")
tree.child2_1_related = OneToOneRelatedOrder.objects.create(
relatedmodel=tree.child2_1, order=0
)
tree.child2_2 = RelatedOrderModel.objects.create(parent=tree.child2, name="2-2")
tree.child2_2_related = OneToOneRelatedOrder.objects.create(
relatedmodel=tree.child2_2, order=1
)
nodes = RelatedOrderModel.objects.order_siblings_by("related__order")
assert list(nodes) == [
tree.root,
tree.child1,
tree.child1_1,
tree.child2,
tree.child2_1,
tree.child2_2,
]
def test_tree_exclude(self):
tree = self.create_tree()
# Tree-filter should remove children if
# the parent meets the filtering criteria
nodes = Model.objects.tree_exclude(name="2")
assert list(nodes) == [
tree.root,
tree.child1,
tree.child1_1,
]
def test_tree_filter(self):
tree = self.create_tree()
# Tree-filter should remove children if
# the parent does not meet the filtering criteria
nodes = Model.objects.tree_filter(name__in=["root", "1-1", "2", "2-1", "2-2"])
assert list(nodes) == [
tree.root,
tree.child2,
tree.child2_1,
tree.child2_2,
]
def test_tree_filter_chaining(self):
tree = self.create_tree()
# Tree-filter should remove children if
# the parent does not meet the filtering criteria
nodes = Model.objects.tree_exclude(name="2-2").tree_filter(
name__in=["root", "1-1", "2", "2-1", "2-2"]
)
assert list(nodes) == [
tree.root,
tree.child2,
tree.child2_1,
]
def test_tree_filter_related(self):
tree = SimpleNamespace()
tree.root = RelatedOrderModel.objects.create(name="root")
tree.root_related = OneToOneRelatedOrder.objects.create(
relatedmodel=tree.root, order=0
)
tree.child1 = RelatedOrderModel.objects.create(parent=tree.root, name="1")
tree.child1_related = OneToOneRelatedOrder.objects.create(
relatedmodel=tree.child1, order=0
)
tree.child2 = RelatedOrderModel.objects.create(parent=tree.root, name="2")
tree.child2_related = OneToOneRelatedOrder.objects.create(
relatedmodel=tree.child2, order=1
)
tree.child1_1 = RelatedOrderModel.objects.create(parent=tree.child1, name="1-1")
tree.child1_1_related = OneToOneRelatedOrder.objects.create(
relatedmodel=tree.child1_1, order=0
)
tree.child2_1 = RelatedOrderModel.objects.create(parent=tree.child2, name="2-1")
tree.child2_1_related = OneToOneRelatedOrder.objects.create(
relatedmodel=tree.child2_1, order=0
)
tree.child2_2 = RelatedOrderModel.objects.create(parent=tree.child2, name="2-2")
tree.child2_2_related = OneToOneRelatedOrder.objects.create(
relatedmodel=tree.child2_2, order=1
)
nodes = RelatedOrderModel.objects.tree_filter(related__order=0)
assert list(nodes) == [
tree.root,
tree.child1,
tree.child1_1,
]
def test_tree_filter_with_order(self):
tree = SimpleNamespace()
tree.root = MultiOrderedModel.objects.create(
name="root",
first_position=1,
)
tree.child1 = MultiOrderedModel.objects.create(
parent=tree.root, first_position=0, second_position=1, name="1"
)
tree.child2 = MultiOrderedModel.objects.create(
parent=tree.root, first_position=1, second_position=0, name="2"
)
tree.child1_1 = MultiOrderedModel.objects.create(
parent=tree.child1, first_position=1, second_position=1, name="1-1"
)
tree.child2_1 = MultiOrderedModel.objects.create(
parent=tree.child2, first_position=1, second_position=1, name="2-1"
)
tree.child2_2 = MultiOrderedModel.objects.create(
parent=tree.child2, first_position=1, second_position=0, name="2-2"
)
nodes = MultiOrderedModel.objects.tree_filter(
first_position__gt=0
).order_siblings_by("-second_position")
assert list(nodes) == [
tree.root,
tree.child2,
tree.child2_1,
tree.child2_2,
]
def test_tree_filter_q_objects(self):
tree = self.create_tree()
# Tree-filter should remove children if
# the parent does not meet the filtering criteria
nodes = Model.objects.tree_filter(
Q(name__in=["root", "1-1", "2", "2-1", "2-2"])
)
assert list(nodes) == [
tree.root,
tree.child2,
tree.child2_1,
tree.child2_2,
]
def test_tree_filter_q_mix(self):
tree = SimpleNamespace()
tree.root = MultiOrderedModel.objects.create(
name="root", first_position=1, second_position=2
)
tree.child1 = MultiOrderedModel.objects.create(
parent=tree.root, first_position=1, second_position=0, name="1"
)
tree.child2 = MultiOrderedModel.objects.create(
parent=tree.root, first_position=1, second_position=2, name="2"
)
tree.child1_1 = MultiOrderedModel.objects.create(
parent=tree.child1, first_position=1, second_position=1, name="1-1"
)
tree.child2_1 = MultiOrderedModel.objects.create(
parent=tree.child2, first_position=1, second_position=1, name="2-1"
)
tree.child2_2 = MultiOrderedModel.objects.create(
parent=tree.child2, first_position=1, second_position=2, name="2-2"
)
# Tree-filter should remove children if
# the parent does not meet the filtering criteria
nodes = MultiOrderedModel.objects.tree_filter(
Q(first_position=1), second_position=2
)
assert list(nodes) == [
tree.root,
tree.child2,
tree.child2_2,
]
def test_tree_fields(self):
self.create_tree()
qs = Model.objects.tree_fields(tree_names="name", tree_orders="order")
names = [obj.tree_names for obj in qs]
assert names == [
["root"],
["root", "1"],
["root", "1", "1-1"],
["root", "2"],
["root", "2", "2-1"],
["root", "2", "2-2"],
]
orders = [obj.tree_orders for obj in qs]
assert orders == [[0], [0, 0], [0, 0, 0], [0, 1], [0, 1, 0], [0, 1, 42]]
# ids = [obj.tree_pks for obj in Model.objects.tree_fields(tree_pks="custom_id")]
# self.assertIsInstance(ids[0][0], int)
# ids = [obj.tree_pks for obj in Model.objects.tree_fields(tree_pks="parent_id")]
# self.assertEqual(ids[0], [""])
def test_invalid_sibling_order_type(self):
"""Test that invalid sibling order types raise ValueError"""
self.create_tree()
# Create a TreeQuery directly to test the validation in get_rank_table
query = TreeQuery(Model)
query.sibling_order = 123 # Invalid type
compiler = TreeCompiler(query, connections[Model.objects.db], Model.objects.db)
# This should raise ValueError during get_rank_table
with pytest.raises(
ValueError,
match="Sibling order must be a string or a list or tuple of strings",
):
compiler.get_rank_table()
@pytest.mark.postgresql
@pytest.mark.skipif(
connections["default"].vendor != "postgresql",
reason="EXPLAIN test only meaningful for PostgreSQL",
)
def test_explain_query_handling(self):
"""Test that EXPLAIN queries are handled correctly"""
self.create_tree()
# This should not raise an error and should include EXPLAIN in output
explanation = Model.objects.with_tree_fields().explain()
assert "CTE" in explanation
@pytest.mark.mysql
@pytest.mark.skipif(
connections["default"].vendor != "mysql",
reason="MySQL-specific code path test only meaningful for MySQL",
)
def test_mysql_specific_code_paths(self):
"""Test MySQL-specific code paths"""
tree = self.create_tree()
# Test that queries work with MySQL-specific string concatenation
nodes = list(Model.objects.with_tree_fields())
assert len(nodes) == 6
# This exercises the MySQL-specific CTE implementation
descendants = list(tree.root.descendants())
assert len(descendants) == 5
@pytest.mark.postgresql
@pytest.mark.skipif(
connections["default"].vendor != "postgresql",
reason="PostgreSQL-specific descendants query test only meaningful for PostgreSQL",
)
def test_postgresql_descendants_query_path(self):
"""Test PostgreSQL-specific descendants query logic"""
tree = self.create_tree()
# This exercises the PostgreSQL-specific path in query.py:120 using ANY() syntax
descendants = list(Model.objects.descendants(tree.child2))
expected_descendants = [tree.child2_1, tree.child2_2]
assert len(descendants) == 2
assert set(descendants) == set(expected_descendants)
def test_rank_table_optimization(self):
"""Test that rank table optimization works correctly"""
# Test that simple cases can skip rank table (all databases now support it)
query = TreeQuery(Model)
compiler = TreeCompiler(query, connections[Model.objects.db], Model.objects.db)
# Default should allow optimization
assert compiler._can_skip_rank_table()
# Descending order should prevent optimization
query.sibling_order = "-order"
assert not compiler._can_skip_rank_table()
# Multiple fields should prevent optimization
query.sibling_order = ["order", "name"]
assert not compiler._can_skip_rank_table()
# String fields should prevent optimization
query.sibling_order = "name"
assert not compiler._can_skip_rank_table()
# Test that tree filters prevent optimization
self.create_tree()
filtered_qs = Model.objects.tree_filter(name="root")
query = filtered_qs.query
compiler = TreeCompiler(query, connections[Model.objects.db], Model.objects.db)
assert not compiler._can_skip_rank_table()
# Test that simple custom tree fields now allow optimization
custom_fields_qs = Model.objects.tree_fields(tree_names="name")
query = custom_fields_qs.query
compiler = TreeCompiler(query, connections[Model.objects.db], Model.objects.db)
assert compiler._can_skip_rank_table() # Now should allow optimization
def test_optimization_sql_differences(self):
"""Test that the optimization produces different SQL"""
self.create_tree()
# Simple query that should use optimization
simple_qs = Model.objects.with_tree_fields()
simple_sql, _ = simple_qs.query.get_compiler(using=Model.objects.db).as_sql()
# Complex query that should NOT use optimization (descending order)
complex_qs = Model.objects.with_tree_fields().order_siblings_by("-order")
complex_sql, _ = complex_qs.query.get_compiler(using=Model.objects.db).as_sql()
# The optimized query should not contain "__rank_table"
assert "__rank_table" not in simple_sql
# The complex query should contain "__rank_table"
assert "__rank_table" in complex_sql
# Both should contain "__tree" CTE
assert "__tree" in simple_sql
assert "__tree" in complex_sql
def test_tree_fields_optimization(self):
"""Test that tree fields work with the optimization"""
self.create_tree()
# Test that simple tree fields use optimization
qs = Model.objects.tree_fields(tree_names="name")
query = qs.query
compiler = TreeCompiler(query, connections[Model.objects.db], Model.objects.db)
assert compiler._can_skip_rank_table()
# Test that the query works correctly
results = list(qs)
assert len(results) == 6
# Check that tree_names field is populated correctly
root = next(obj for obj in results if obj.name == "root")
assert root.tree_names == ["root"]
child2_2 = next(obj for obj in results if obj.name == "2-2")
assert child2_2.tree_names == ["root", "2", "2-2"]
feincms-django-tree-queries-039d744/tests/testapp/test_templatetags.py 0000664 0000000 0000000 00000067150 15112126155 0026223 0 ustar 00root root 0000000 0000000 from types import SimpleNamespace
import pytest
from django import template
from django.template import Context, Template, Variable
from django.test import TestCase
from testapp.models import Model, UnorderedModel
from tree_queries.templatetags.tree_queries import (
RecurseTreeNode,
previous_current_next,
tree_info,
tree_item_iterator,
)
@pytest.mark.django_db
class TestTemplateTags:
def create_tree(self):
tree = SimpleNamespace()
tree.root = Model.objects.create(name="root")
tree.child1 = Model.objects.create(parent=tree.root, order=0, name="1")
tree.child2 = Model.objects.create(parent=tree.root, order=1, name="2")
tree.child1_1 = Model.objects.create(parent=tree.child1, order=0, name="1-1")
tree.child2_1 = Model.objects.create(parent=tree.child2, order=0, name="2-1")
tree.child2_2 = Model.objects.create(parent=tree.child2, order=42, name="2-2")
return tree
def test_previous_current_next_basic(self):
"""Test the previous_current_next utility function"""
items = [1, 2, 3, 4]
result = list(previous_current_next(items))
expected = [(None, 1, 2), (1, 2, 3), (2, 3, 4), (3, 4, None)]
assert result == expected
def test_previous_current_next_empty(self):
"""Test previous_current_next with empty list"""
items = []
result = list(previous_current_next(items))
assert result == []
def test_previous_current_next_single(self):
"""Test previous_current_next with single item"""
items = [42]
result = list(previous_current_next(items))
assert result == [(None, 42, None)]
def test_tree_item_iterator_basic(self):
"""Test tree_item_iterator without ancestors"""
tree = self.create_tree()
items = list(Model.objects.with_tree_fields())
result = list(tree_item_iterator(items))
# Check that we get the expected number of items
assert len(result) == 6
# Check structure of first item (root)
item, structure = result[0]
assert item == tree.root
assert structure["new_level"] is True
assert structure["closed_levels"] == []
# Check structure of second item (child1)
item, structure = result[1]
assert item == tree.child1
assert structure["new_level"] is True
assert structure["closed_levels"] == []
# Check structure of third item (child1_1)
item, structure = result[2]
assert item == tree.child1_1
assert structure["new_level"] is True
assert structure["closed_levels"] == [2]
# Check structure of last item (child2_2)
item, structure = result[5]
assert item == tree.child2_2
assert structure["new_level"] is False
assert structure["closed_levels"] == [2, 1, 0]
def test_tree_item_iterator_with_ancestors(self):
"""Test tree_item_iterator with ancestors enabled"""
tree = self.create_tree()
items = list(Model.objects.with_tree_fields())
result = list(tree_item_iterator(items, ancestors=True))
# Check structure of root item
item, structure = result[0]
assert item == tree.root
assert structure["ancestors"] == []
# Check structure of child1_1 item
item, structure = result[2]
assert item == tree.child1_1
assert structure["ancestors"] == [str(tree.root), str(tree.child1)]
# Check structure of child2_1 item
item, structure = result[4]
assert item == tree.child2_1
assert structure["ancestors"] == [str(tree.root), str(tree.child2)]
def test_tree_item_iterator_with_custom_callback(self):
"""Test tree_item_iterator with custom callback for ancestors"""
tree = self.create_tree()
items = list(Model.objects.with_tree_fields())
# Custom callback that returns the name attribute
def name_callback(obj):
return obj.name
result = list(tree_item_iterator(items, ancestors=True, callback=name_callback))
# Check structure of child1_1 item with custom callback
item, structure = result[2]
assert item == tree.child1_1
assert structure["ancestors"] == ["root", "1"]
def test_tree_info_filter_basic(self):
"""Test the tree_info template filter basic functionality"""
self.create_tree()
items = list(Model.objects.with_tree_fields())
result = list(tree_info(items))
# Should return same as tree_item_iterator with ancestors=True
expected = list(tree_item_iterator(items, ancestors=True))
assert len(result) == len(expected)
# Check that structure matches
for (item1, struct1), (item2, struct2) in zip(result, expected):
assert item1 == item2
assert struct1["new_level"] == struct2["new_level"]
assert struct1["closed_levels"] == struct2["closed_levels"]
assert struct1["ancestors"] == struct2["ancestors"]
def test_tree_info_filter_always_has_ancestors(self):
"""Test that tree_info filter always includes ancestors"""
tree = self.create_tree()
items = list(Model.objects.with_tree_fields())
result = list(tree_info(items))
# Check that ancestors are always included
item, structure = result[2] # child1_1
assert item == tree.child1_1
assert "ancestors" in structure
assert structure["ancestors"] == [str(tree.root), str(tree.child1)]
# Check root has empty ancestors
item, structure = result[0] # root
assert item == tree.root
assert "ancestors" in structure
assert structure["ancestors"] == []
def test_tree_info_in_template(self):
"""Test tree_info filter used in an actual Django template"""
self.create_tree()
items = list(Model.objects.with_tree_fields())
template = Template("""
{% load tree_queries %}
{% for item, structure in items|tree_info %}
{% if structure.new_level %}
{% else %}
{% endif %}
{{ item.name }}
{% for level in structure.closed_levels %}
{% endfor %}
{% endfor %}
""")
context = Context({"items": items})
result = template.render(context)
# Check that the template renders without errors
assert "root" in result
assert "1" in result
assert "1-1" in result
assert "2" in result
assert "2-1" in result
assert "2-2" in result
# Check for proper nesting structure
assert "
" in result
assert "
" in result
def test_tree_info_with_ancestors_in_template(self):
"""Test tree_info filter with ancestors in template"""
self.create_tree()
items = list(Model.objects.with_tree_fields())
template = Template("""
{% load tree_queries %}
{% for item, structure in items|tree_info %}
{{ item.name }}{% if structure.ancestors %} (ancestors: {% for ancestor in structure.ancestors %}{{ ancestor }}{% if not forloop.last %}, {% endif %}{% endfor %}){% endif %}
{% endfor %}
""")
context = Context({"items": items})
result = template.render(context)
# Check that ancestors are properly displayed
assert "root" in result
assert "(ancestors: root)" in result
assert "(ancestors: root, 1)" in result
assert "(ancestors: root, 2)" in result
def test_empty_items_list(self):
"""Test template tags with empty items list"""
result = list(tree_info([]))
assert result == []
result = list(tree_item_iterator([]))
assert result == []
def test_single_item_tree(self):
"""Test template tags with single item"""
root = Model.objects.create(name="root")
items = list(Model.objects.with_tree_fields())
result = list(tree_info(items))
assert len(result) == 1
item, structure = result[0]
assert item == root
assert structure["new_level"] is True
assert structure["closed_levels"] == [0]
def test_recursetree_basic(self):
"""Test basic recursetree functionality"""
self.create_tree()
items = Model.objects.with_tree_fields()
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}
{% if children %}
{{ children }}
{% endif %}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
# Check that all nodes are rendered
assert "root" in result
assert "1" in result
assert "1-1" in result
assert "2" in result
assert "2-1" in result
assert "2-2" in result
# Check nested structure
assert "
" in result
assert "
" in result
assert "
" in result
assert "
" in result
def test_recursetree_with_depth_info(self):
"""Test recursetree with node depth information"""
self.create_tree()
items = Model.objects.with_tree_fields()
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}
{{ children }}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
# Check depth classes are applied correctly
assert 'class="depth-0"' in result # root
assert 'class="depth-1"' in result # child1, child2
assert 'class="depth-2"' in result # child1_1, child2_1, child2_2
def test_recursetree_empty_queryset(self):
"""Test recursetree with empty queryset"""
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}
{% endrecursetree %}
""")
context = Context({"items": Model.objects.none()})
result = template.render(context)
# Should render just the outer ul
assert "
" in result
assert "
" in result
assert "
" not in result
def test_recursetree_single_root(self):
"""Test recursetree with single root node"""
Model.objects.create(name="lone-root")
items = Model.objects.with_tree_fields()
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}{{ children }}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
assert "lone-root" in result
assert "" in result
def test_recursetree_without_tree_fields(self):
"""Test recursetree with queryset that doesn't have tree fields"""
self.create_tree()
# Use regular queryset without tree fields
items = Model.objects.all()
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}{{ children }}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
# Should still render root node (the one with parent_id=None)
assert "root" in result
def test_recursetree_conditional_children(self):
"""Test recursetree with conditional children rendering"""
self.create_tree()
items = Model.objects.with_tree_fields()
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}
{% if children %}
{{ children }}
{% else %}
{% endif %}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
# Check that leaf nodes get the leaf class
assert 'class="leaf-node"' in result
# Check that parent nodes get the has-children class
assert 'class="has-children"' in result
def test_recursetree_complex_template(self):
"""Test recursetree with more complex template logic"""
self.create_tree()
items = Model.objects.with_tree_fields()
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}
{% if children %}
{{ children }}
{% endif %}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
# Check data attributes are present
assert "data-id=" in result
assert "data-depth=" in result
# Check heading levels (h1 for root, h2 for level 1, etc.)
assert "
" in result # root
assert "
" in result # children
assert "
" in result # grandchildren
def test_recursetree_syntax_error(self):
"""Test that recursetree raises proper syntax error for invalid usage"""
with pytest.raises(template.TemplateSyntaxError) as excinfo:
Template("""
{% load tree_queries %}
{% recursetree %}
{% endrecursetree %}
""")
assert "tag requires a queryset" in str(excinfo.value)
with pytest.raises(template.TemplateSyntaxError) as excinfo:
Template("""
{% load tree_queries %}
{% recursetree items extra_arg %}
{% endrecursetree %}
""")
assert "tag requires a queryset" in str(excinfo.value)
def test_recursetree_limited_queryset_depth(self):
"""Test recursetree with queryset limited to specific depth"""
self.create_tree()
# Only get nodes up to depth 1 (root and first level children)
items = Model.objects.with_tree_fields().extra(
where=["__tree.tree_depth <= %s"], params=[1]
)
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}
{% if children %}
{{ children }}
{% endif %}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
# Should include root, child1, child2 but NOT child1_1, child2_1, child2_2
assert "root" in result
assert "1" in result # child1
assert "2" in result # child2
assert "1-1" not in result # should not be rendered
assert "2-1" not in result # should not be rendered
assert "2-2" not in result # should not be rendered
# Check depth attributes
assert 'data-depth="0"' in result # root
assert 'data-depth="1"' in result # children
assert 'data-depth="2"' not in result # grandchildren excluded
def test_recursetree_filtered_by_name(self):
"""Test recursetree with queryset filtered by specific criteria"""
self.create_tree()
# Only get nodes with specific names (partial tree)
items = Model.objects.with_tree_fields().filter(
name__in=["root", "2", "2-1", "1"] # Excludes "1-1" and "2-2"
)
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}{{ children }}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
# Should include filtered nodes
assert "root" in result
assert ">1<" in result # child1
assert ">2<" in result # child2
assert "2-1" in result # child2_1
# Should NOT include excluded nodes
assert "1-1" not in result
assert "2-2" not in result
def test_recursetree_subtree_only(self):
"""Test recursetree with queryset containing only a subtree"""
tree = self.create_tree()
# Only get child2 and its descendants (excludes root, child1, child1_1)
items = Model.objects.descendants(tree.child2, include_self=True)
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}{{ children }}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
# Should include only child2 and its descendants
assert "2" in result # child2 (root of subtree)
assert "2-1" in result
assert "2-2" in result
# Should NOT include nodes outside the subtree
assert "root" not in result
assert 'data-name="1"' not in result # child1
assert "1-1" not in result
def test_recursetree_orphaned_nodes(self):
"""Test recursetree with queryset that has orphaned nodes (parent not in queryset)"""
self.create_tree()
# Get only leaf nodes (their parents are not included)
items = Model.objects.with_tree_fields().filter(name__in=["1-1", "2-1", "2-2"])
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}{{ children }}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
# All nodes should be treated as roots since their parents aren't in queryset
assert "1-1" in result
assert "2-1" in result
assert "2-2" in result
# Should render three separate root nodes
assert result.count("
") == 3
def test_recursetree_mixed_levels(self):
"""Test recursetree with queryset containing nodes from different levels"""
self.create_tree()
# Mix of root, some children, and some grandchildren
items = Model.objects.with_tree_fields().filter(
name__in=["root", "1-1", "2", "2-2"] # Skip child1 and child2_1
)
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}
{% if children %}[{{ children }}]{% endif %}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
# root should be a root with child2 as its child
assert 'data-name="root"' in result
assert 'data-name="2"' in result
# 1-1 should be orphaned (parent "1" not in queryset)
assert 'data-name="1-1"' in result
# 2-2 should be child of 2
assert 'data-name="2-2"' in result
# Check nesting - root should contain 2, and 2 should contain 2-2
assert "root" in result
assert "[" in result
assert "]" in result # 2 has children (contains closing bracket)
def test_recursetree_no_database_queries_for_children(self):
"""Test that recursetree doesn't make additional database queries for children"""
self.create_tree()
items = Model.objects.with_tree_fields()
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}{{ children }}
{% endrecursetree %}
""")
context = Context({"items": items})
# Force evaluation of queryset to count queries
list(items)
# Count queries during template rendering
tc = TestCase()
with tc.assertNumQueries(0): # Should not make any additional queries
result = template.render(context)
# Verify the result still contains all expected nodes
assert "root" in result
assert "1" in result
assert "1-1" in result
assert "2" in result
assert "2-1" in result
assert "2-2" in result
def test_recursetree_is_leaf_context_variable(self):
"""Test that is_leaf context variable is properly set"""
self.create_tree()
items = Model.objects.with_tree_fields()
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}
{% if is_leaf %}[LEAF]{% endif %}
{{ children }}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
# Check that leaf nodes are marked as such
assert 'data-name="1-1" data-is-leaf="True"' in result # child1_1 is leaf
assert 'data-name="2-1" data-is-leaf="True"' in result # child2_1 is leaf
assert 'data-name="2-2" data-is-leaf="True"' in result # child2_2 is leaf
# Check that non-leaf nodes are marked as such
assert 'data-name="root" data-is-leaf="False"' in result # root has children
assert 'data-name="1" data-is-leaf="False"' in result # child1 has children
assert 'data-name="2" data-is-leaf="False"' in result # child2 has children
# Check that [LEAF] appears for leaf nodes
assert "[LEAF]" in result # Should appear for leaf nodes
assert (
result.count("[LEAF]") == 3
) # Should appear exactly 3 times (for 1-1, 2-1, 2-2)
# Check that [LEAF] doesn't appear for non-leaf nodes
assert "root[LEAF]" not in result
assert "1[LEAF]" not in result # This might match "1-1[LEAF]", so be specific
assert ">2[LEAF]" not in result
def test_recursetree_is_leaf_with_limited_queryset(self):
"""Test is_leaf behavior with limited queryset"""
self.create_tree()
# Only get nodes up to depth 1 - so child1 and child2 appear as leaves
items = Model.objects.with_tree_fields().extra(
where=["__tree.tree_depth <= %s"], params=[1]
)
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{% if is_leaf %}LEAF:{{ node.name }}{% else %}BRANCH:{{ node.name }}{% endif %}
{{ children }}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
# In this limited queryset, child1 and child2 should appear as leaves
# even though they have children in the full tree
assert "LEAF:1" in result # child1 appears as leaf (no children in queryset)
assert "LEAF:2" in result # child2 appears as leaf (no children in queryset)
assert "BRANCH:root" in result # root has children (child1, child2) in queryset
# These shouldn't appear since they're not in the queryset
assert "1-1" not in result
assert "2-1" not in result
assert "2-2" not in result
def test_recursetree_is_leaf_orphaned_nodes(self):
"""Test is_leaf with orphaned nodes (parent not in queryset)"""
self.create_tree()
# Get only leaf nodes - they should all be treated as leaf nodes
items = Model.objects.with_tree_fields().filter(name__in=["1-1", "2-1", "2-2"])
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
# All nodes should be leaves since they have no children in the queryset
assert 'data-leaf="True"' in result
assert 'data-leaf="False"' not in result
assert result.count('data-leaf="True"') == 3 # All three nodes are leaves
def test_recursetree_cache_reuse(self):
"""Test that recursetree cache is reused properly"""
self.create_tree()
items = Model.objects.with_tree_fields()
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}{{ children }}
{% endrecursetree %}
""")
context = Context({"items": items})
# First render should cache the children
result1 = template.render(context)
# Second render should reuse the cache
result2 = template.render(context)
assert result1 == result2
assert "root" in result1
def test_recursetree_nodes_without_tree_ordering(self):
"""Test recursetree with nodes that don't have tree_ordering attribute"""
# Create tree without tree_ordering
u0 = UnorderedModel.objects.create(name="u0")
UnorderedModel.objects.create(name="u1", parent=u0)
UnorderedModel.objects.create(name="u2", parent=u0)
items = UnorderedModel.objects.with_tree_fields()
template = Template("""
{% load tree_queries %}
{% recursetree items %}
{{ node.name }}{{ children }}
{% endrecursetree %}
""")
context = Context({"items": items})
result = template.render(context)
# Should render correctly even without tree_ordering
assert "u0" in result
assert "u1" in result
assert "u2" in result
def test_recursetree_get_children_from_cache_edge_cases(self):
"""Test edge cases in _get_children_from_cache method"""
tree = self.create_tree()
Model.objects.with_tree_fields()
# Create a RecurseTreeNode instance
queryset_var = Variable("items")
nodelist = [] # Empty nodelist for testing
recurse_node = RecurseTreeNode(nodelist, queryset_var)
# Test when cache is None
assert recurse_node._get_children_from_cache(tree.root) == []
# Test when cache exists but node not in cache
recurse_node._cached_children = {}
assert recurse_node._get_children_from_cache(tree.root) == []
def test_tree_item_iterator_edge_cases(self):
"""Test edge cases in tree_item_iterator"""
# Test with single item that has tree_depth attribute
class MockNode:
def __init__(self, name, tree_depth=0):
self.name = name
self.tree_depth = tree_depth # Include required attribute
mock_item = MockNode("test", tree_depth=0)
# This should work correctly with tree_depth
result = list(tree_item_iterator([mock_item], ancestors=True))
assert len(result) == 1
item, structure = result[0]
assert item == mock_item
assert structure["new_level"] is True
assert "ancestors" in structure
def test_previous_current_next_edge_cases(self):
"""Test edge cases in previous_current_next function"""
# Test with generator that raises StopIteration
def empty_generator():
return
yield # Never reached
result = list(previous_current_next(empty_generator()))
assert result == []
# Test with None items
result = list(previous_current_next([None, None]))
expected = [(None, None, None), (None, None, None)]
assert result == expected
feincms-django-tree-queries-039d744/tests/testapp/urls.py 0000664 0000000 0000000 00000000167 15112126155 0023452 0 ustar 00root root 0000000 0000000 from django.contrib import admin
from django.urls import path
urlpatterns = [
path("admin/", admin.site.urls),
]
feincms-django-tree-queries-039d744/tox.ini 0000664 0000000 0000000 00000003374 15112126155 0020607 0 ustar 00root root 0000000 0000000 [tox]
envlist =
docs
py{38,310}-dj{32,41,42}-{sqlite,postgresql,mysql}
py{310,311,312}-dj{32,41,42,50,51}-{sqlite,postgresql,mysql}
py{312,313}-dj{51,52,main}-{sqlite,postgresql,mysql}
[testenv]
deps =
dj32: Django>=3.2,<4.0
dj41: Django>=4.1,<4.2
dj42: Django>=4.2,<5.0
dj50: Django>=5.0,<5.1
dj51: Django>=5.1,<5.2
dj52: Django>=5.2,<6.0
djmain: https://github.com/django/django/archive/main.tar.gz
postgresql: psycopg2-binary
mysql: mysqlclient
pytest
pytest-django
pytest-cov
django-js-asset
passenv=
CI
DB_BACKEND
DB_NAME
DB_USER
DB_PASSWORD
DB_HOST
DB_PORT
GITHUB_*
SQL
setenv =
PYTHONPATH = {toxinidir}:{toxinidir}/tests
PYTHONWARNINGS = d
DJANGO_SETTINGS_MODULE = testapp.settings
DB_NAME = {env:DB_NAME:tree_queries}
DB_USER = {env:DB_USER:tree_queries}
DB_HOST = {env:DB_HOST:localhost}
DB_PASSWORD = {env:DB_PASSWORD:tree_queries}
pip_pre = True
commands =
pytest {posargs}
[testenv:py{38,310,311,312,313}-dj{32,41,42,50,51,52,main}-postgresql]
setenv =
{[testenv]setenv}
DB_BACKEND = postgresql
DB_PORT = {env:DB_PORT:5432}
[testenv:py{38,310,311,312,313}-dj{32,41,42,50,51,52,main}-mysql]
setenv =
{[testenv]setenv}
DB_BACKEND = mysql
DB_PORT = {env:DB_PORT:3306}
[testenv:py{38,310,311,312,313}-dj{32,41,42,50,51,52,main}-sqlite]
setenv =
{[testenv]setenv}
DB_BACKEND = sqlite3
DB_NAME = ":memory:"
[testenv:docs]
commands = make -C {toxinidir}/docs html
deps =
Sphinx
allowlist_externals = make
[gh-actions]
python =
3.8: py38
3.10: py310
3.11: py311
3.12: py312
3.13: py313
[gh-actions:env]
DB_BACKEND =
mysql: mysql
postgresql: postgresql
sqlite3: sqlite
feincms-django-tree-queries-039d744/tree_queries/ 0000775 0000000 0000000 00000000000 15112126155 0021761 5 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/tree_queries/__init__.py 0000664 0000000 0000000 00000000027 15112126155 0024071 0 ustar 00root root 0000000 0000000 __version__ = "0.23.0"
feincms-django-tree-queries-039d744/tree_queries/admin.py 0000664 0000000 0000000 00000032325 15112126155 0023430 0 ustar 00root root 0000000 0000000 import json
from django import forms
from django.conf import settings
from django.contrib import admin, messages
from django.contrib.admin import ModelAdmin, SimpleListFilter, display, helpers
from django.contrib.admin.options import (
IncorrectLookupParameters,
csrf_protect_m,
)
from django.core import checks
from django.core.exceptions import ValidationError
from django.db.models import F
from django.http import HttpResponse
from django.urls import path
from django.utils.html import format_html, format_html_join, mark_safe
from django.utils.translation import gettext_lazy as _
__all__ = (
"TreeAdmin",
"AncestorFilter",
)
MOVE_POSITIONS = {
"before": _("before"),
"first-child": _("as first child"),
"last-child": _("as last child"),
"after": _("after"),
}
# Positions available when we don't control sibling ordering
MOVE_POSITIONS_PARENT_ONLY = {
"child": _("as child"),
}
class TreeAdmin(ModelAdmin):
"""
Position field configuration. Set to the name of the field used for positioning
siblings, or None if no position field exists or if positioning is not controllable.
"""
position_field = None
"""
``ModelAdmin`` subclass for managing models using `django-tree-queries
`_ trees.
Shows the tree's hierarchy and adds a view to move nodes around. To use
this class the three columns ``collapse_column``, ``indented_title`` and
``move_column`` should be added to subclasses ``list_display``::
class NodeAdmin(TreeAdmin):
list_display = [*TreeAdmin.list_display, ...]
# This is the default:
# list_display_links = ["indented_title"]
admin.site.register(Node, NodeAdmin)
"""
list_display = ["collapse_column", "indented_title", "move_column"]
list_display_links = ["indented_title"]
def check(self, **kwargs):
errors = super().check(**kwargs)
if "tree_queries" not in settings.INSTALLED_APPS:
errors.append(
checks.Error(
'"tree_queries" must be in INSTALLED_APPS.',
obj=self.__class__,
id="tree_queries.E001",
)
)
return errors
@csrf_protect_m
def changelist_view(self, request, **kwargs):
from js_asset.js import JS # noqa: PLC0415
response = super().changelist_view(request, **kwargs)
if not hasattr(response, "context_data"):
return response
context = self.tree_admin_context(request)
response.context_data["media"] += forms.Media(
css={
"all": [
"tree_queries/tree_admin.css",
]
},
js=[
JS(
"tree_queries/tree_admin.js",
{"id": "tree-admin-context", "data-context": json.dumps(context)},
),
],
)
return response
def tree_admin_context(self, request):
return {
"initiallyCollapseDepth": 1,
}
def get_queryset(self, request):
return self.model._default_manager.with_tree_fields()
@display(description="")
def collapse_column(self, instance):
return format_html(
'',
instance.pk,
instance.tree_depth,
)
def indented_title(self, instance, *, ellipsize=True):
"""
Use Unicode box-drawing characters to visualize the tree hierarchy.
"""
box_drawing = []
for _i in range(instance.tree_depth - 1):
box_drawing.append('')
if instance.tree_depth > 0:
box_drawing.append('')
return format_html(
'
'
'
{}
'
'
{}
'
"
",
mark_safe("".join(box_drawing)),
" ellipsize" if ellipsize else "",
instance.tree_depth * 30,
instance,
)
indented_title.short_description = _("title")
@admin.display(description=_("move"))
def move_column(self, instance):
"""
Show a ``move`` link which leads to a separate page where the move
destination may be selected.
"""
positions = (
MOVE_POSITIONS if self.position_field else MOVE_POSITIONS_PARENT_ONLY
)
options = format_html_join(
"", '', positions.items()
)
# Add "to root" button for models without controllable positioning
# Only show for nodes that aren't already at root level
root_button = ""
if not self.position_field and instance.parent_id is not None:
root_button = format_html(
'",
instance.pk,
_("Move '{}' to root level").format(instance),
)
return format_html(
"""\
{}
""",
instance.pk,
_("Move '{}' to a new location").format(instance),
root_button,
instance.pk,
_("Choose new location"),
options,
)
def get_urls(self):
"""
Add our own ``move`` view.
"""
return [
path(
"move-node/",
self.admin_site.admin_view(self.move_node_view),
),
] + super().get_urls()
def move_node_view(self, request):
kw = {"request": request, "modeladmin": self}
form = MoveNodeForm(request.POST, **kw)
return HttpResponse(form.process())
def action_form_view(self, request, obj, *, form_class, title):
kw = {"request": request, "obj": obj, "modeladmin": self}
form = form_class(request.POST if request.method == "POST" else None, **kw)
if form.is_valid():
return form.process()
return self.render_action_form(request, form, title=title, obj=obj)
def render_action_form(self, request, form, *, title, obj):
adminform = helpers.AdminForm(
form,
[
(None, {"fields": form.fields.keys()})
], # list(self.get_fieldsets(request, obj)),
{}, # self.get_prepopulated_fields(request, obj),
(), # self.get_readonly_fields(request, obj),
model_admin=self,
)
media = self.media + adminform.media
context = dict(
self.admin_site.each_context(request),
title=title,
object_id=obj.pk,
original=obj,
adminform=adminform,
errors=helpers.AdminErrorList(form, ()),
preserved_filters=self.get_preserved_filters(request),
media=media,
is_popup=False,
inline_admin_formsets=[],
save_as_new=False,
show_save_and_add_another=False,
show_save_and_continue=False,
show_delete=False,
)
response = self.render_change_form(
request, context, add=False, change=True, obj=obj
)
# Suppress the rendering of the "save and add another" button.
response.context_data["has_add_permission"] = False
return response
class MoveNodeForm(forms.Form):
def __init__(self, *args, **kwargs):
self.modeladmin = kwargs.pop("modeladmin")
self.request = kwargs.pop("request")
super().__init__(*args, **kwargs)
self.fields["move"] = forms.ModelChoiceField(
queryset=self.modeladmin.get_queryset(self.request)
)
self.fields["relative_to"] = forms.ModelChoiceField(
queryset=self.modeladmin.get_queryset(self.request), required=False
)
positions = (
MOVE_POSITIONS
if self.modeladmin.position_field
else MOVE_POSITIONS_PARENT_ONLY
)
# Always allow "root" position even if not shown in dropdown (handled by separate button)
all_choices = list(positions.items())
if ("root", _("to root")) not in all_choices:
all_choices.append(("root", _("to root")))
self.fields["position"] = forms.ChoiceField(choices=all_choices)
def clean(self):
cleaned_data = super().clean()
position = cleaned_data.get("position")
relative_to = cleaned_data.get("relative_to")
# relative_to is required for all positions except "root"
if position != "root" and not relative_to:
raise forms.ValidationError(
_("A target node is required for this move position.")
)
return cleaned_data
def process(self):
if not self.is_valid():
messages.error(self.request, _("Invalid node move request."))
messages.error(self.request, str(self.errors))
return "error"
move = self.cleaned_data["move"]
relative_to = self.cleaned_data["relative_to"]
position = self.cleaned_data["position"]
if position == "root":
move.parent = None
siblings_qs = move.__class__._default_manager.filter(parent=None)
elif position in {"first-child", "last-child"} or position == "child":
move.parent = relative_to
siblings_qs = relative_to.children
else:
move.parent = relative_to.parent
siblings_qs = relative_to.__class__._default_manager.filter(
parent=relative_to.parent
)
try:
# All fields of model are not in this form
move.full_clean(exclude=[f.name for f in move._meta.get_fields()])
except ValidationError as exc:
messages.error(
self.request,
_("Error while validating the new position of '{}'.").format(move),
)
messages.error(self.request, str(exc))
return "error"
position_field = self.modeladmin.position_field
if position == "before" and position_field:
siblings_qs.filter(**{
f"{position_field}__gte": getattr(relative_to, position_field)
}).update(**{position_field: F(position_field) + 10})
setattr(move, position_field, getattr(relative_to, position_field))
move.save()
elif position == "after" and position_field:
siblings_qs.filter(**{
f"{position_field}__gt": getattr(relative_to, position_field)
}).update(**{position_field: F(position_field) + 10})
setattr(move, position_field, getattr(relative_to, position_field) + 10)
move.save()
elif position == "first-child" and position_field:
siblings_qs.update(**{position_field: F(position_field) + 10})
setattr(move, position_field, 10)
move.save()
elif position == "last-child" and position_field:
setattr(
move, position_field, 0
) # Let model's save method handle the position
move.save()
elif position in {"child", "root"}:
# Parent already set above, just save
if position_field and position == "root":
setattr(move, position_field, 0) # Let model handle positioning
move.save()
else: # pragma: no cover
pass
messages.success(
self.request,
_("Node '{}' has been moved to its new position.").format(move),
)
return "ok"
class AncestorFilter(SimpleListFilter):
"""
Only show the subtree of an ancestor
By default, the first two levels are shown in the ``list_filter`` sidebar.
This can be changed by setting the ``max_depth`` class attribute to a
different value.
Usage::
class NodeAdmin(TreeAdmin):
list_display = ("indented_title", "move_column", ...)
list_filter = ("is_active", AncestorFilter, ...)
admin.site.register(Node, NodeAdmin)
"""
title = _("subtree")
parameter_name = "ancestor"
max_depth = 1
def indent(self, depth):
return mark_safe("├" * depth)
def lookups(self, request, model_admin):
return [
(node.id, format_html("{} {}", self.indent(node.tree_depth), node))
for node in model_admin.model._default_manager.with_tree_fields().extra(
where=[f"tree_depth <= {self.max_depth}"]
)
]
def queryset(self, request, queryset):
if self.value():
try:
node = queryset.model._default_manager.get(pk=self.value())
except (TypeError, ValueError, queryset.model.DoesNotExist) as exc:
raise IncorrectLookupParameters() from exc
return queryset.descendants(node, include_self=True)
return queryset
feincms-django-tree-queries-039d744/tree_queries/compiler.py 0000664 0000000 0000000 00000047733 15112126155 0024163 0 ustar 00root root 0000000 0000000 import django
from django.core.exceptions import FieldDoesNotExist
from django.db import connections
from django.db.models import Expression, F, QuerySet, Value, Window
from django.db.models.functions import RowNumber
from django.db.models.sql.compiler import SQLCompiler
from django.db.models.sql.query import Query
SEPARATOR = "\x1f"
def _find_tree_model(cls):
return cls._meta.get_field("parent").model
class TreeQuery(Query):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._setup_query()
def _setup_query(self):
"""
Run on initialization and at the end of chaining. Any attributes that
would normally be set in __init__() should go here instead.
"""
# We add the variables for `sibling_order` and `rank_table_query` here so they
# act as instance variables which do not persist between user queries
# the way class variables do
# Only add the sibling_order attribute if the query doesn't already have one to preserve cloning behavior
if not hasattr(self, "sibling_order"):
# Add an attribute to control the ordering of siblings within trees
opts = _find_tree_model(self.model)._meta
self.sibling_order = opts.ordering if opts.ordering else opts.pk.attname
# Only add the rank_table_query attribute if the query doesn't already have one to preserve cloning behavior
if not hasattr(self, "rank_table_query"):
# Create a default QuerySet for the rank_table to use
# so we can avoid recursion
self.rank_table_query = QuerySet(model=_find_tree_model(self.model))
if not hasattr(self, "tree_fields"):
self.tree_fields = {}
def get_compiler(self, using=None, connection=None, **kwargs):
# Copied from django/db/models/sql/query.py
if using is None and connection is None:
raise ValueError("Need either using or connection")
if using:
connection = connections[using]
# Difference: Not connection.ops.compiler, but our own compiler which
# adds the CTE.
# **kwargs passes on elide_empty from Django 4.0 onwards
return TreeCompiler(self, connection, using, **kwargs)
def get_sibling_order(self):
return self.sibling_order
def get_rank_table_query(self):
return self.rank_table_query
def get_tree_fields(self):
return self.tree_fields
class TreeCompiler(SQLCompiler):
CTE_POSTGRESQL = """
WITH RECURSIVE __rank_table(
{tree_fields_columns}
"{pk}",
"{parent}",
"rank_order"
) AS (
{rank_table}
),
__tree (
{tree_fields_names}
"tree_depth",
"tree_path",
"tree_ordering",
"tree_pk"
) AS (
SELECT
{tree_fields_initial}
0,
array[T.{pk}],
array[T.rank_order],
T."{pk}"
FROM __rank_table T
WHERE T."{parent}" IS NULL
UNION ALL
SELECT
{tree_fields_recursive}
__tree.tree_depth + 1,
__tree.tree_path || T.{pk},
__tree.tree_ordering || T.rank_order,
T."{pk}"
FROM __rank_table T
JOIN __tree ON T."{parent}" = __tree.tree_pk
)
"""
CTE_MYSQL = """
WITH RECURSIVE __rank_table(
{tree_fields_columns}
{pk},
{parent},
rank_order
) AS (
{rank_table}
),
__tree(
{tree_fields_names}
tree_depth,
tree_path,
tree_ordering,
tree_pk
) AS (
SELECT
{tree_fields_initial}
0,
-- Limit to max. 50 levels...
CAST(CONCAT("{sep}", {pk}, "{sep}") AS char(1000)),
CAST(CONCAT("{sep}", LPAD(CONCAT(T.rank_order, "{sep}"), 20, "0"))
AS char(1000)),
T.{pk}
FROM __rank_table T
WHERE T.{parent} IS NULL
UNION ALL
SELECT
{tree_fields_recursive}
__tree.tree_depth + 1,
CONCAT(__tree.tree_path, T2.{pk}, "{sep}"),
CONCAT(__tree.tree_ordering, LPAD(CONCAT(T2.rank_order, "{sep}"), 20, "0")),
T2.{pk}
FROM __tree, __rank_table T2
WHERE __tree.tree_pk = T2.{parent}
)
"""
CTE_SQLITE = """
WITH RECURSIVE __rank_table(
{tree_fields_columns}
{pk},
{parent},
rank_order
) AS (
{rank_table}
),
__tree(
{tree_fields_names}
tree_depth,
tree_path,
tree_ordering,
tree_pk
) AS (
SELECT
{tree_fields_initial}
0,
printf("{sep}%%s{sep}", {pk}),
printf("{sep}%%020s{sep}", T.rank_order),
T."{pk}" tree_pk
FROM __rank_table T
WHERE T."{parent}" IS NULL
UNION ALL
SELECT
{tree_fields_recursive}
__tree.tree_depth + 1,
__tree.tree_path || printf("%%s{sep}", T.{pk}),
__tree.tree_ordering || printf("%%020s{sep}", T.rank_order),
T."{pk}"
FROM __rank_table T
JOIN __tree ON T."{parent}" = __tree.tree_pk
)
"""
# Optimized CTEs without rank table for simple cases
CTE_POSTGRESQL_SIMPLE = """
WITH RECURSIVE __tree (
{tree_fields_names}"tree_depth",
"tree_path",
"tree_ordering",
"tree_pk"
) AS (
SELECT
{tree_fields_initial}0,
array[T.{pk}],
array[T."{order_field}"],
T.{pk}
FROM {db_table} T
WHERE T."{parent}" IS NULL
UNION ALL
SELECT
{tree_fields_recursive}__tree.tree_depth + 1,
__tree.tree_path || T.{pk},
__tree.tree_ordering || T."{order_field}",
T.{pk}
FROM {db_table} T
JOIN __tree ON T."{parent}" = __tree.tree_pk
)
"""
CTE_MYSQL_SIMPLE = """
WITH RECURSIVE __tree(
{tree_fields_names}tree_depth,
tree_path,
tree_ordering,
tree_pk
) AS (
SELECT
{tree_fields_initial}0,
CAST(CONCAT("{sep}", T.{pk}, "{sep}") AS char(1000)),
CAST(CONCAT("{sep}", LPAD(CONCAT(T.`{order_field}`, "{sep}"), 20, "0")) AS char(1000)),
T.{pk}
FROM {db_table} T
WHERE T.`{parent}` IS NULL
UNION ALL
SELECT
{tree_fields_recursive}__tree.tree_depth + 1,
CONCAT(__tree.tree_path, T.{pk}, "{sep}"),
CONCAT(__tree.tree_ordering, LPAD(CONCAT(T.`{order_field}`, "{sep}"), 20, "0")),
T.{pk}
FROM {db_table} T, __tree
WHERE __tree.tree_pk = T.`{parent}`
)
"""
CTE_SQLITE_SIMPLE = """
WITH RECURSIVE __tree(
{tree_fields_names}tree_depth,
tree_path,
tree_ordering,
tree_pk
) AS (
SELECT
{tree_fields_initial}0,
"{sep}" || T."{pk}" || "{sep}",
"{sep}" || printf("%%020s", T."{order_field}") || "{sep}",
T."{pk}"
FROM {db_table} T
WHERE T."{parent}" IS NULL
UNION ALL
SELECT
{tree_fields_recursive}__tree.tree_depth + 1,
__tree.tree_path || T."{pk}" || "{sep}",
__tree.tree_ordering || printf("%%020s", T."{order_field}") || "{sep}",
T."{pk}"
FROM {db_table} T
JOIN __tree ON T."{parent}" = __tree.tree_pk
)
"""
def _can_skip_rank_table(self):
"""
Determine if we can skip the rank table optimization.
We can skip it when:
1. No tree filters are applied (rank_table_query is unchanged)
2. Simple ordering (single field, ascending)
3. No custom tree fields
"""
# Check if tree filters have been applied
original_query = QuerySet(model=_find_tree_model(self.query.model))
if str(self.query.get_rank_table_query().query) != str(original_query.query):
return False
# Check if custom tree fields are simple column references
tree_fields = self.query.get_tree_fields()
if tree_fields:
model = _find_tree_model(self.query.model)
for _name, column in tree_fields.items():
# Only allow simple column names (no complex expressions)
if not isinstance(column, str):
return False
# Check if it's a valid field on the model
try:
model._meta.get_field(column)
except FieldDoesNotExist:
return False
# Check for complex ordering
sibling_order = self.query.get_sibling_order()
if isinstance(sibling_order, (list, tuple)):
if len(sibling_order) > 1:
return False
order_field = sibling_order[0]
else:
order_field = sibling_order
# Check for descending order or complex expressions
if (
isinstance(order_field, str)
and order_field.startswith("-")
or not isinstance(order_field, str)
):
return False
# Check for related field lookups (contains __)
if "__" in order_field:
return False
# Check if the ordering field is numeric/integer
# For string fields, the optimization might not preserve correct order
# because we bypass the ROW_NUMBER() ranking that the complex CTE uses
field = _find_tree_model(self.query.model)._meta.get_field(order_field)
if not hasattr(field, "get_internal_type"):
return False
field_type = field.get_internal_type()
return field_type in (
"AutoField",
"BigAutoField",
"IntegerField",
"BigIntegerField",
"PositiveIntegerField",
"PositiveSmallIntegerField",
"SmallIntegerField",
)
def get_rank_table(self):
# Get and validate sibling_order
sibling_order = self.query.get_sibling_order()
if isinstance(sibling_order, (list, tuple)):
order_fields = sibling_order
elif isinstance(sibling_order, str):
order_fields = [sibling_order]
else:
raise ValueError(
"Sibling order must be a string or a list or tuple of strings."
)
# Convert strings to expressions. This is to maintain backwards compatibility
# with Django versions < 4.1
if django.VERSION < (4, 1):
base_order = []
for field in order_fields:
if isinstance(field, Expression):
base_order.append(field)
elif isinstance(field, str):
if field[0] == "-":
base_order.append(F(field[1:]).desc())
else:
base_order.append(F(field).asc())
order_fields = base_order
# Get the rank table query
rank_table_query = self.query.get_rank_table_query()
rank_table_query = (
rank_table_query.order_by() # Ensure there is no ORDER BY at the end of the SQL
# Values allows us to both limit and specify the order of
# the columns selected so that they match the CTE
.values(
*self.query.get_tree_fields().values(),
"pk",
"parent",
rank_order=Window(
expression=RowNumber(),
order_by=order_fields,
),
)
)
rank_table_sql, rank_table_params = rank_table_query.query.sql_with_params()
return rank_table_sql, rank_table_params
def as_sql(self, *args, **kwargs):
# Try detecting if we're used in a EXISTS(1 as "a") subquery like
# Django's sql.Query.exists() generates. If we detect such a query
# we're skipping the tree generation since it's not necessary in the
# best case and references unused table aliases (leading to SQL errors)
# in the worst case. See GitHub issue #63.
if (
self.query.subquery
and (ann := self.query.annotations)
and ann == {"a": Value(1)}
):
return super().as_sql(*args, **kwargs)
# The general idea is that if we have a summary query (e.g. .count())
# then we do not want to ask Django to add the tree fields to the query
# using .query.add_extra. The way to determine whether we have a
# summary query on our hands is to check the is_summary attribute of
# all annotations.
#
# A new case appeared in the GitHub issue #26: Queries using
# .distinct().count() crashed. The reason for this is that Django uses
# a distinct subquery *without* annotations -- the annotations are kept
# in the surrounding query. Because of this we look at the distinct and
# subquery attributes.
#
# I am not confident that this is the perfect way to approach this
# problem but I just gotta stop worrying and trust the testsuite.
skip_tree_fields = (
self.query.distinct and self.query.subquery
) or any( # pragma: no branch
# OK if generator is not consumed completely
annotation.is_summary
for alias, annotation in self.query.annotations.items()
)
opts = _find_tree_model(self.query.model)._meta
params = {
"parent": "parent_id", # XXX Hardcoded.
"pk": opts.pk.attname,
"db_table": opts.db_table,
"sep": SEPARATOR,
}
# Check if we can use the optimized path without rank table
use_rank_table = not self._can_skip_rank_table()
if use_rank_table:
# Get the rank_table SQL and params
rank_table_sql, rank_table_params = self.get_rank_table()
params["rank_table"] = rank_table_sql
else:
# Use optimized path - get the order field for simple CTE
sibling_order = self.query.get_sibling_order()
if isinstance(sibling_order, (list, tuple)):
order_field = sibling_order[0]
else:
order_field = sibling_order
params["order_field"] = order_field
rank_table_params = []
# Set database-specific CTE template and column reference format
if self.connection.vendor == "postgresql":
cte = (
self.CTE_POSTGRESQL_SIMPLE
if not use_rank_table
else self.CTE_POSTGRESQL
)
cte_initial = "array[{column}]::text[], "
cte_recursive = "__tree.{name} || {column}::text, "
elif self.connection.vendor == "sqlite":
cte = self.CTE_SQLITE_SIMPLE if not use_rank_table else self.CTE_SQLITE
cte_initial = 'printf("{sep}%%s{sep}", {column}), '
cte_recursive = '__tree.{name} || printf("%%s{sep}", {column}), '
elif self.connection.vendor == "mysql":
cte = self.CTE_MYSQL_SIMPLE if not use_rank_table else self.CTE_MYSQL
cte_initial = 'CAST(CONCAT("{sep}", {column}, "{sep}") AS char(1000)), '
cte_recursive = 'CONCAT(__tree.{name}, {column}, "{sep}"), '
tree_fields = self.query.get_tree_fields()
qn = self.connection.ops.quote_name
# Generate tree field parameters using unified templates
# Set column reference format based on CTE type
if use_rank_table:
# Complex CTE uses rank table references
column_ref_format = "{column}"
params.update({
"tree_fields_columns": "".join(
f"{qn(column)}, " for column in tree_fields.values()
),
})
else:
# Simple CTE uses direct table references
column_ref_format = "T.{column}"
# Generate unified tree field parameters
params.update({
"tree_fields_names": "".join(f"{qn(name)}, " for name in tree_fields),
"tree_fields_initial": "".join(
cte_initial.format(
column=column_ref_format.format(column=qn(column)),
name=qn(name),
sep=SEPARATOR,
)
for name, column in tree_fields.items()
),
"tree_fields_recursive": "".join(
cte_recursive.format(
column=column_ref_format.format(column=qn(column)),
name=qn(name),
sep=SEPARATOR,
)
for name, column in tree_fields.items()
),
})
if "__tree" not in self.query.extra_tables: # pragma: no branch - unlikely
tree_params = params.copy()
# use aliased table name (U0, U1, U2)
base_table = self.query.__dict__.get("base_table")
if base_table is not None:
tree_params["db_table"] = base_table
# When using tree queries in subqueries our base table may use
# an alias. Let's hope using the first alias is correct.
aliases = self.query.table_map.get(tree_params["db_table"])
if aliases:
tree_params["db_table"] = aliases[0]
select = {
"tree_depth": "__tree.tree_depth",
"tree_path": "__tree.tree_path",
"tree_ordering": "__tree.tree_ordering",
}
# Add custom tree fields for both simple and complex CTEs
select.update({name: f"__tree.{name}" for name in tree_fields})
self.query.add_extra(
# Do not add extra fields to the select statement when it is a
# summary query or when using .values() or .values_list()
select={} if skip_tree_fields or self.query.values_select else select,
select_params=None,
where=["__tree.tree_pk = {db_table}.{pk}".format(**tree_params)],
params=None,
tables=["__tree"],
order_by=(
[]
# Do not add ordering for aggregates, or if the ordering
# has already been specified using .extra()
if skip_tree_fields or self.query.extra_order_by
else ["__tree.tree_ordering"] # DFS is the only true way
),
)
sql_0, sql_1 = super().as_sql(*args, **kwargs)
explain = ""
if sql_0.startswith("EXPLAIN "):
explain, sql_0 = sql_0.split(" ", 1)
# Pass any additional rank table sql paramaters so that the db backend can handle them.
# This only works because we know that the CTE is at the start of the query.
return (
"".join([explain, cte.format(**params), sql_0]),
(*rank_table_params, *sql_1),
)
def get_converters(self, expressions):
converters = super().get_converters(expressions)
tree_fields = {"__tree.tree_path", "__tree.tree_ordering"} | {
f"__tree.{name}" for name in self.query.tree_fields
}
for i, expression in enumerate(expressions):
# We care about tree fields and annotations only
if not hasattr(expression, "sql"):
continue
if expression.sql in tree_fields:
converters[i] = ([converter], expression)
return converters
def converter(value, expression, connection, context=None):
# context can be removed as soon as we only support Django>=2.0
if isinstance(value, str):
# MySQL/MariaDB and sqlite3 do not support arrays. Split the value on
# the ASCII unit separator (chr(31)).
# NOTE: The representation of array is NOT part of the API.
value = value.split(SEPARATOR)[1:-1]
try:
# Either all values are convertible to int or don't bother
return [int(v) for v in value] # Maybe Field.to_python()?
except ValueError:
return value
feincms-django-tree-queries-039d744/tree_queries/fields.py 0000664 0000000 0000000 00000000644 15112126155 0023605 0 ustar 00root root 0000000 0000000 from django.db import models
from tree_queries.forms import TreeNodeChoiceField
class TreeNodeForeignKey(models.ForeignKey):
def deconstruct(self):
name, _path, args, kwargs = super().deconstruct()
return (name, "django.db.models.ForeignKey", args, kwargs)
def formfield(self, **kwargs):
kwargs.setdefault("form_class", TreeNodeChoiceField)
return super().formfield(**kwargs)
feincms-django-tree-queries-039d744/tree_queries/forms.py 0000664 0000000 0000000 00000001320 15112126155 0023455 0 ustar 00root root 0000000 0000000 from django import forms
class TreeNodeIndentedLabels:
def __init__(self, queryset, *args, **kwargs):
if hasattr(queryset, "with_tree_fields"):
queryset = queryset.with_tree_fields()
if "label_from_instance" in kwargs:
self.label_from_instance = kwargs.pop("label_from_instance")
super().__init__(queryset, *args, **kwargs)
def label_from_instance(self, obj):
depth = getattr(obj, "tree_depth", 0)
return "{}{}".format("".join(["--- "] * depth), obj)
class TreeNodeChoiceField(TreeNodeIndentedLabels, forms.ModelChoiceField):
pass
class TreeNodeMultipleChoiceField(
TreeNodeIndentedLabels, forms.ModelMultipleChoiceField
):
pass
feincms-django-tree-queries-039d744/tree_queries/locale/ 0000775 0000000 0000000 00000000000 15112126155 0023220 5 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/tree_queries/locale/de/ 0000775 0000000 0000000 00000000000 15112126155 0023610 5 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/tree_queries/locale/de/LC_MESSAGES/ 0000775 0000000 0000000 00000000000 15112126155 0025375 5 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/tree_queries/locale/de/LC_MESSAGES/django.mo 0000664 0000000 0000000 00000001041 15112126155 0027170 0 ustar 00root root 0000000 0000000 4 L ` - a B 9
A node cannot be made a descendant of itself. parent Project-Id-Version: PACKAGE VERSION
Report-Msgid-Bugs-To:
PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE
Last-Translator: FULL NAME
Language-Team: LANGUAGE
Language:
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Plural-Forms: nplurals=2; plural=(n != 1);
Ein Element kann nicht Unterelement von sich selbst sein. übergeordnet feincms-django-tree-queries-039d744/tree_queries/locale/de/LC_MESSAGES/django.po 0000664 0000000 0000000 00000001464 15112126155 0027204 0 ustar 00root root 0000000 0000000 # 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.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-07-30 12:31+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
"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"
#: models.py:14
msgid "parent"
msgstr "übergeordnet"
#: models.py:56
msgid "A node cannot be made a descendant of itself."
msgstr "Ein Element kann nicht Unterelement von sich selbst sein."
feincms-django-tree-queries-039d744/tree_queries/models.py 0000664 0000000 0000000 00000006350 15112126155 0023622 0 ustar 00root root 0000000 0000000 from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Max
from django.utils.translation import gettext_lazy as _
from tree_queries.fields import TreeNodeForeignKey
from tree_queries.query import TreeQuerySet
class TreeNode(models.Model):
parent = TreeNodeForeignKey(
"self",
blank=True,
null=True,
on_delete=models.CASCADE,
verbose_name=_("parent"),
related_name="children",
)
objects = TreeQuerySet.as_manager()
class Meta:
abstract = True
def ancestors(self, **kwargs):
"""
Returns all ancestors of the current node
See ``TreeQuerySet.ancestors`` for details and optional arguments.
"""
return self.__class__._default_manager.ancestors(self, **kwargs)
def descendants(self, **kwargs):
"""
Returns all descendants of the current node
See ``TreeQuerySet.descendants`` for details and optional arguments.
"""
return self.__class__._default_manager.descendants(self, **kwargs)
def clean(self):
"""
Raises a validation error if saving this instance would result in loops
in the tree structure
"""
super().clean()
if (
self.parent_id
and self.pk
and (
self.__class__._default_manager.ancestors(
self.parent_id, include_self=True
)
.filter(pk=self.pk)
.exists()
)
):
raise ValidationError(_("A node cannot be made a descendant of itself."))
class OrderableTreeNode(TreeNode):
"""
A TreeNode with automatic position management for consistent sibling ordering.
This mixin provides automatic position value assignment when creating new nodes,
ensuring siblings are properly ordered. When a node is saved without an explicit
position value, it automatically receives a position 10 units higher than the
maximum position among its siblings.
Usage:
class Category(OrderableTreeNode):
name = models.CharField(max_length=100)
# position field and ordering are provided by OrderableTreeNode
The position field increments by 10 (rather than 1) to make it explicit that
the position values themselves have no inherent meaning - they are purely for
relative ordering, not a sibling counter or index. This approach is identical
to the one used in feincms3's AbstractPage.
"""
position = models.PositiveIntegerField(default=0, db_index=True)
class Meta:
abstract = True
ordering = ["position"]
def save(self, *args, **kwargs):
"""
Automatically assigns a position value if not set.
If the position is 0 (the default), calculates a new position by finding
the maximum position among siblings and adding 10.
"""
if not self.position:
self.position = 10 + (
self.__class__._default_manager.filter(parent_id=self.parent_id)
.order_by()
.aggregate(p=Max("position"))["p"]
or 0
)
super().save(*args, **kwargs)
save.alters_data = True
feincms-django-tree-queries-039d744/tree_queries/query.py 0000664 0000000 0000000 00000011250 15112126155 0023477 0 ustar 00root root 0000000 0000000 from django.db import connections, models
from django.db.models.sql.query import Query
from tree_queries.compiler import SEPARATOR, TreeQuery
def pk(of):
"""
Returns the primary key of the argument if it is an instance of a model, or
the argument as-is otherwise
"""
return of.pk if hasattr(of, "pk") else of
class TreeManager(models.Manager):
def get_queryset(self):
queryset = super().get_queryset()
return queryset.with_tree_fields() if self._with_tree_fields else queryset
class TreeQuerySet(models.QuerySet):
def with_tree_fields(self, tree_fields=True): # noqa: FBT002
"""
Requests tree fields on this queryset
Pass ``False`` to revert to a queryset without tree fields.
"""
if tree_fields:
self.query.__class__ = TreeQuery
self.query._setup_query()
else:
self.query.__class__ = Query
return self
def without_tree_fields(self):
"""
Requests no tree fields on this queryset
"""
return self.with_tree_fields(tree_fields=False)
def order_siblings_by(self, *order_by):
"""
Sets TreeQuery sibling_order attribute
Pass the names of model fields as a list of strings
to order tree siblings by those model fields
"""
self.query.__class__ = TreeQuery
self.query._setup_query()
self.query.sibling_order = order_by
return self
def tree_filter(self, *args, **kwargs):
"""
Adds a filter to the TreeQuery rank_table_query
Takes the same arguements as a Django QuerySet .filter()
"""
self.query.__class__ = TreeQuery
self.query._setup_query()
self.query.rank_table_query = self.query.rank_table_query.filter(
*args, **kwargs
)
return self
def tree_exclude(self, *args, **kwargs):
"""
Adds a filter to the TreeQuery rank_table_query
Takes the same arguements as a Django QuerySet .exclude()
"""
self.query.__class__ = TreeQuery
self.query._setup_query()
self.query.rank_table_query = self.query.rank_table_query.exclude(
*args, **kwargs
)
return self
def tree_fields(self, **tree_fields):
self.query.__class__ = TreeQuery
self.query._setup_query()
self.query.tree_fields = tree_fields
return self
@classmethod
def as_manager(cls, *, with_tree_fields=False):
manager_class = TreeManager.from_queryset(cls)
# Only used in deconstruct:
manager_class._built_with_as_manager = True
# Set attribute on class, not on the instance so that the automatic
# subclass generation used e.g. for relations also finds this
# attribute.
manager_class._with_tree_fields = with_tree_fields
return manager_class()
as_manager.queryset_only = True
def ancestors(self, of, *, include_self=False):
"""
Returns ancestors of the given node ordered from the root of the tree
towards deeper levels, optionally including the node itself
"""
if not include_self and of.parent_id is None:
# Node without parent cannot have ancestors.
return self.none()
if not hasattr(of, "tree_path"):
of = self.with_tree_fields().get(pk=pk(of))
ids = of.tree_path if include_self else of.tree_path[:-1]
return (
self.with_tree_fields() # TODO tree fields not strictly required
.filter(pk__in=ids)
.extra(order_by=["__tree.tree_depth"])
)
def descendants(self, of, *, include_self=False):
"""
Returns descendants of the given node in depth-first order, optionally
including and starting with the node itself
"""
connection = connections[self.db]
if connection.vendor == "postgresql":
queryset = self.with_tree_fields().extra(
where=["%s = ANY(__tree.tree_path)"],
params=[self.model._meta.pk.get_db_prep_value(pk(of), connection)],
)
else:
queryset = self.with_tree_fields().extra(
# NOTE! The representation of tree_path is NOT part of the API.
where=[
# XXX This *may* be unsafe with some primary key field types.
# It is certainly safe with integers.
f'instr(__tree.tree_path, "{SEPARATOR}{self.model._meta.pk.get_db_prep_value(pk(of), connection)}{SEPARATOR}") <> 0'
]
)
if not include_self:
return queryset.exclude(pk=pk(of))
return queryset
feincms-django-tree-queries-039d744/tree_queries/static/ 0000775 0000000 0000000 00000000000 15112126155 0023250 5 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/tree_queries/static/tree_queries/ 0000775 0000000 0000000 00000000000 15112126155 0025744 5 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/tree_queries/static/tree_queries/tree_admin.css 0000664 0000000 0000000 00000010557 15112126155 0030575 0 ustar 00root root 0000000 0000000 .box {
white-space: nowrap;
position: relative;
margin: -8px;
overflow: hidden;
}
.box-drawing {
position: absolute;
display: inline-block;
font-family: monospace;
font-size: 28px;
line-height: 1.2;
font-weight: normal;
}
.box-drawing i {
display: inline-block;
font-style: normal;
width: 30px;
}
.box-drawing i.l::after {
content: "\2502";
}
.box-drawing i.a::after {
content: "\251c";
}
.box-text {
padding: 8px;
}
.box-text.ellipsize {
max-width: min(50vw, 70ch);
text-overflow: ellipsis;
overflow: hidden;
}
.field-collapse_column {
position: relative;
width: 1rem;
padding: 0 !important;
}
.collapse-toggle {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: inline-block;
transition: 0.1s transform;
transform: rotate(0deg) translateY(6px);
transform-origin: 50% 50%;
cursor: pointer;
}
.collapse-toggle.collapsed {
transform: rotate(-45deg) translateY(2px);
}
.collapse-toggle::after {
content: "\25E2";
font-size: 1rem;
}
.collapse-hide {
display: none !important;
}
.move-status {
background: var(--selected-row);
padding: 8px 16px;
cursor: pointer;
position: fixed;
left: 0;
right: 0;
bottom: 0;
border: 1px solid var(--hairline-color);
z-index: 25; /* .nav-sidebar uses 15 and 20 */
}
.move-status .confirm-root-move {
margin-left: 10px;
padding: 2px 6px;
background: var(--success-fg, #28a745);
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: inherit;
}
.move-status .confirm-root-move:hover {
background: var(--success-fg, #218838);
}
.move-status .cancel-move {
margin-left: 10px;
padding: 2px 6px;
background: none;
color: var(--link-fg);
border: 1px solid var(--hairline-color);
border-radius: 3px;
cursor: pointer;
font-size: inherit;
}
.move-status .cancel-move:hover {
background: var(--hairline-color);
}
.field-move_column {
padding-top: 0;
padding-bottom: 0;
vertical-align: middle;
}
.move-controls {
display: flex;
gap: 4px;
align-items: center;
}
.move-cut,
.move-to-root {
appearance: none;
cursor: pointer;
background: none;
border: none;
padding: 0;
color: var(--link-fg);
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
}
/* Icon styles using SVGs from Google Material Icons
https://github.com/google/material-design-icons
Licensed under Apache License 2.0 */
.move-cut .tree-icon::before {
content: "";
display: block;
width: 24px;
height: 24px;
/* content_cut icon */
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.89-2 2-2 2 .89 2 2-.89 2-2 2zm0 12c-1.1 0-2-.89-2-2s.89-2 2-2 2 .89 2 2-.89 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3z'/%3E%3C/svg%3E");
}
.move-to-root .tree-icon::before {
content: "";
display: block;
width: 24px;
height: 24px;
/* vertical_align_top icon */
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M8 11h3v10h2V11h3l-4-4-4 4zM4 3v2h16V3H4z'/%3E%3C/svg%3E");
}
.move-to-root {
color: var(--success-fg, #28a745);
}
/* Show paste dropdowns when in regular move mode */
body[data-move="regular"] .move-paste {
display: revert;
}
/* Hide paste dropdown on the selected row (can't move relative to itself) */
body[data-move] .move-selected .move-paste {
display: none;
}
#changelist tr[data-tree-depth] {
--_f: 3%;
background: color-mix(in oklab, var(--body-fg) var(--_f), var(--body-bg));
}
#changelist tr[data-tree-depth]:nth-child(2n) {
--_f: 0%;
}
#changelist tr[data-tree-depth="0"] {
--_f: 18%;
}
#changelist tr[data-tree-depth="0"]:nth-child(2n) {
--_f: 15%;
}
#changelist tr[data-tree-depth="1"] {
--_f: 9%;
}
#changelist tr[data-tree-depth="1"]:nth-child(2n) {
--_f: 6%;
}
#changelist tbody tr.move-selected {
background: var(--selected-row);
}
#changelist tbody tr.move-highlight {
background: var(--message-success-bg);
}
.move-paste {
display: none;
max-width: 6ch;
height: 28px;
}
feincms-django-tree-queries-039d744/tree_queries/static/tree_queries/tree_admin.js 0000664 0000000 0000000 00000015003 15112126155 0030410 0 ustar 00root root 0000000 0000000 document.addEventListener("DOMContentLoaded", () => {
const root = document.querySelector("#result_list tbody")
const PK = 0
const DEPTH = 1
const CHILDREN = 2
const TR = 3
const TOGGLE = 4
const nodes = {}
const parents = []
for (const toggle of root.querySelectorAll(".collapse-toggle")) {
const node = toggle.closest("tr")
const pk = +toggle.dataset.pk
const treeDepth = +toggle.dataset.treeDepth
const rec = [pk, treeDepth, [], node, toggle]
parents[treeDepth] = rec
nodes[pk] = rec
node.dataset.pk = pk
node.dataset.treeDepth = treeDepth
if (treeDepth > 0) {
// parent may be on the previous page if the changelist is paginated.
const parent = parents[treeDepth - 1]
if (parent) {
parent[CHILDREN].push(rec)
parent[TOGGLE].classList.remove("collapse-hide")
}
}
}
function setCollapsed(pk, collapsed) {
nodes[pk][TOGGLE].classList.toggle("collapsed", collapsed)
for (const rec of nodes[pk][CHILDREN]) {
rec[TR].classList.toggle("collapse-hide", collapsed)
setCollapsed(rec[PK], collapsed)
}
}
function initiallyCollapse(minDepth) {
for (const rec of Object.values(nodes)) {
if (rec[DEPTH] >= minDepth && rec[CHILDREN].length) {
setCollapsed(rec[PK], true)
}
}
}
root.addEventListener("click", (e) => {
const collapseToggle = e.target.closest(".collapse-toggle")
if (collapseToggle) {
e.preventDefault()
setCollapsed(
+collapseToggle.dataset.pk,
!collapseToggle.classList.contains("collapsed"),
)
}
})
const context = JSON.parse(
document.querySelector("#tree-admin-context").dataset.context,
)
initiallyCollapse(context.initiallyCollapseDepth)
})
document.addEventListener("DOMContentLoaded", () => {
let statusElement
const performMove = (formData) => {
return fetch("move-node/", {
credentials: "include",
method: "POST",
body: formData,
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return response.text()
})
.then((result) => {
if (result === "ok") {
setMoving({ ..._moving, highlight: true })
window.location.reload()
} else {
throw new Error("Move operation failed")
}
})
.catch((error) => {
console.error("Move operation failed:", error)
setMoving(null)
alert("Failed to move node. Please try again.")
throw error // Re-throw for additional handling if needed
})
}
const showMoving = (moving) => {
if (!statusElement) {
statusElement = document.createElement("div")
statusElement.className = "move-status"
document.body.append(statusElement)
}
for (const el of document.querySelectorAll(".move-selected"))
el.classList.remove("move-selected")
if (moving?.highlight) {
const row = document.querySelector(`tr[data-pk="${moving.pk}"]`)
row.classList.add("move-highlight")
setTimeout(() => {
row.classList.remove("move-highlight")
setMoving(null)
}, 1000)
} else if (moving) {
if (moving.toRoot) {
statusElement.innerHTML = `
${moving.title}
`
} else {
statusElement.innerHTML = `
${moving.title}
`
}
statusElement.style.display = "block"
document.body.setAttribute(
"data-move",
moving.toRoot ? "root" : "regular",
)
document
.querySelector(`tr[data-pk="${moving.pk}"]`)
.classList.add("move-selected")
} else {
statusElement.style.display = "none"
document.body.removeAttribute("data-move")
}
}
document.addEventListener("click", (e) => {
const btn = e.target.closest(".move-cut")
if (btn) {
if (_moving?.pk === btn.dataset.pk && !_moving?.toRoot) {
// Same node in regular mode - cancel
setMoving(null)
} else {
// Start or switch to regular move mode
setMoving({ pk: btn.dataset.pk, title: btn.title })
}
}
const rootBtn = e.target.closest(".move-to-root")
if (rootBtn) {
if (_moving?.pk === rootBtn.dataset.pk && _moving?.toRoot) {
// Same node in root mode - cancel
setMoving(null)
} else {
// Start or switch to root move mode
setMoving({
pk: rootBtn.dataset.pk,
title: rootBtn.title,
toRoot: true,
})
}
}
const confirmBtn = e.target.closest(".confirm-root-move")
if (confirmBtn && _moving?.toRoot) {
// Execute the root move
const csrf = document.querySelector(
"input[name=csrfmiddlewaretoken]",
).value
const body = new FormData()
body.append("csrfmiddlewaretoken", csrf)
body.append("move", _moving.pk)
body.append("position", "root")
performMove(body)
}
const cancelBtn = e.target.closest(".cancel-move")
if (cancelBtn) {
setMoving(null)
return
}
const el = e.target.closest(".move-status")
if (el && !e.target.closest(".confirm-root-move")) {
setMoving(null)
}
})
document.addEventListener("change", (e) => {
const select = e.target.closest(".move-paste")
if (select?.value && _moving) {
const csrf = document.querySelector(
"input[name=csrfmiddlewaretoken]",
).value
const body = new FormData()
body.append("csrfmiddlewaretoken", csrf)
body.append("move", _moving.pk)
body.append("relative_to", select.dataset.pk)
body.append("position", select.value)
performMove(body).catch(() => {
// Reset the select to its default state on error
select.value = ""
})
// console.debug(JSON.stringify({ _moving, where: `${select.dataset.pk}:${select.value}` }))
}
})
document.body.addEventListener("keyup", (e) => {
if (e.key === "Escape") setMoving(null)
})
const _key = `f3moving:${location.pathname}`
let _moving
try {
_moving = JSON.parse(sessionStorage.getItem(_key))
} catch (e) {
console.error(e)
}
const setMoving = (moving) => {
_moving = moving
if (_moving) {
sessionStorage.setItem(_key, JSON.stringify(_moving))
} else {
sessionStorage.removeItem(_key)
}
showMoving(_moving)
}
showMoving(_moving)
})
feincms-django-tree-queries-039d744/tree_queries/templatetags/ 0000775 0000000 0000000 00000000000 15112126155 0024453 5 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/tree_queries/templatetags/__init__.py 0000664 0000000 0000000 00000000000 15112126155 0026552 0 ustar 00root root 0000000 0000000 feincms-django-tree-queries-039d744/tree_queries/templatetags/tree_queries.py 0000664 0000000 0000000 00000023655 15112126155 0027534 0 ustar 00root root 0000000 0000000 # From https://raw.githubusercontent.com/triopter/django-tree-query-template/refs/heads/main/tq_template/templatetags/tq_template.py
import copy
import itertools
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
def previous_current_next(items):
"""
From http://www.wordaligned.org/articles/zippy-triples-served-with-python
Creates an iterator which returns (previous, current, next) triples,
with ``None`` filling in when there is no previous or next
available.
"""
extend = itertools.chain([None], items, [None])
prev, cur, nex = itertools.tee(extend, 3)
# Advancing an iterator twice when we know there are two items (the
# two Nones at the start and at the end) will never fail except if
# `items` is some funny StopIteration-raising generator. There's no point
# in swallowing this exception.
next(cur)
next(nex)
next(nex)
return zip(prev, cur, nex)
def tree_item_iterator(items, *, ancestors=False, callback=str):
"""
Given a list of tree items, iterates over the list, generating
two-tuples of the current tree item and a ``dict`` containing
information about the tree structure around the item, with the
following keys:
``'new_level'``
``True`` if the current item is the start of a new level in
the tree, ``False`` otherwise.
``'closed_levels'``
A list of levels which end after the current item. This will
be an empty list if the next item is at the same level as the
current item.
If ``ancestors`` is ``True``, the following key will also be
available:
``'ancestors'``
A list of representations of the ancestors of the current
node, in descending order (root node first, immediate parent
last).
For example: given the sample tree below, the contents of the
list which would be available under the ``'ancestors'`` key
are given on the right::
Books -> []
Sci-fi -> ['Books']
Dystopian Futures -> ['Books', 'Sci-fi']
You can overload the default representation by providing an
optional ``callback`` function which takes a single argument
and performs coersion as required.
"""
structure = {}
first_item_level = 0
for previous, current, next_ in previous_current_next(items):
current_level = current.tree_depth
if previous:
structure["new_level"] = previous.tree_depth < current_level
if ancestors:
# If the previous node was the end of any number of
# levels, remove the appropriate number of ancestors
# from the list.
if structure["closed_levels"]:
structure["ancestors"] = structure["ancestors"][
: -len(structure["closed_levels"])
]
# If the current node is the start of a new level, add its
# parent to the ancestors list.
if structure["new_level"]:
structure["ancestors"].append(callback(previous))
else:
structure["new_level"] = True
if ancestors:
# Set up the ancestors list on the first item
structure["ancestors"] = []
first_item_level = current_level
if next_:
structure["closed_levels"] = list(
range(current_level, next_.tree_depth, -1)
)
else:
# All remaining levels need to be closed
structure["closed_levels"] = list(
range(current_level, first_item_level - 1, -1)
)
# Return a deep copy of the structure dict so this function can
# be used in situations where the iterator is consumed
# immediately.
yield current, copy.deepcopy(structure)
@register.filter
def tree_info(items):
"""
Given a list of tree items, produces doubles of a tree item and a
``dict`` containing information about the tree structure around the
item, with the following contents:
new_level
``True`` if the current item is the start of a new level in
the tree, ``False`` otherwise.
closed_levels
A list of levels which end after the current item. This will
be an empty list if the next item is at the same level as the
current item.
ancestors
A list of ancestors of the current node, in descending order
(root node first, immediate parent last).
Using this filter with unpacking in a ``{% for %}`` tag, you should
have enough information about the tree structure to create a
hierarchical representation of the tree.
Example::
{% for genre,structure in genres|tree_info %}
{% if structure.new_level %}
{% else %}
{% endif %}
{{ genre.name }}
{% for level in structure.closed_levels %}
{% endfor %}
{% endfor %}
"""
return tree_item_iterator(items, ancestors=True)
class RecurseTreeNode(template.Node):
"""
Template node for recursive tree rendering, similar to django-mptt's recursetree.
Renders a section of template recursively for each node in a tree, providing
'node' and 'children' context variables. Only considers nodes from the provided
queryset - will not fetch additional children beyond what's in the queryset.
"""
def __init__(self, nodelist, queryset_var):
self.nodelist = nodelist
self.queryset_var = queryset_var
self._cached_children = None
def _cache_tree_children(self, queryset):
"""
Cache children relationships for all nodes in the queryset.
This avoids additional database queries and respects the queryset boundaries.
"""
if self._cached_children is not None:
return self._cached_children
self._cached_children = {}
# Group nodes by their parent_id for efficient lookup
for node in queryset:
parent_id = getattr(node, "parent_id", None)
if parent_id not in self._cached_children:
self._cached_children[parent_id] = []
self._cached_children[parent_id].append(node)
# Sort children by tree_ordering if available, otherwise by pk
for children_list in self._cached_children.values():
if children_list and hasattr(children_list[0], "tree_ordering"):
children_list.sort(key=lambda x: (x.tree_ordering, x.pk))
else:
children_list.sort(key=lambda x: x.pk)
return self._cached_children
def _get_children_from_cache(self, node):
"""Get children of a node from the cached children, not from database"""
if self._cached_children is None:
return []
return self._cached_children.get(node.pk, [])
def _render_node(self, context, node):
"""Recursively render a node and its children from the cached queryset"""
bits = []
context.push()
# Get children from cache (only nodes that were in the original queryset)
children = self._get_children_from_cache(node)
for child in children:
bits.append(self._render_node(context, child))
# Set context variables that templates can access
context["node"] = node
context["children"] = mark_safe("".join(bits))
context["is_leaf"] = len(children) == 0
# Render the template with the current node context
rendered = self.nodelist.render(context)
context.pop()
return rendered
def render(self, context):
"""Render the complete tree starting from root nodes in the queryset"""
queryset = self.queryset_var.resolve(context)
# Ensure we have tree fields for proper traversal
if hasattr(queryset, "with_tree_fields"):
queryset = queryset.with_tree_fields()
# Convert to list to avoid re-evaluation and cache the children relationships
queryset_list = list(queryset)
self._cache_tree_children(queryset_list)
# Get root nodes (nodes without parents or whose parents are not in the queryset)
queryset_pks = {node.pk for node in queryset_list}
roots = []
for node in queryset_list:
parent_id = getattr(node, "parent_id", None)
if parent_id is None or parent_id not in queryset_pks:
roots.append(node)
# Sort roots by tree_ordering if available, otherwise by pk
if roots and hasattr(roots[0], "tree_ordering"):
roots.sort(key=lambda x: (x.tree_ordering, x.pk))
else:
roots.sort(key=lambda x: x.pk)
# Render each root node and its descendants
bits = [self._render_node(context, node) for node in roots]
return "".join(bits)
@register.tag
def recursetree(parser, token):
"""
Recursively render a tree structure.
Usage:
{% recursetree nodes %}
{{ node.name }}
{% if children %}
{{ children }}
{% elif is_leaf %}
Leaf node
{% endif %}
{% endrecursetree %}
This tag will render the template content for each node in the tree,
providing these variables in the template context:
- 'node': the current tree node
- 'children': rendered HTML of all child nodes in the queryset
- 'is_leaf': True if the node has no children in the queryset, False otherwise
"""
bits = token.contents.split()
if len(bits) != 2:
raise template.TemplateSyntaxError(f"{bits[0]} tag requires a queryset")
queryset_var = template.Variable(bits[1])
nodelist = parser.parse(("endrecursetree",))
parser.delete_first_token()
return RecurseTreeNode(nodelist, queryset_var)