├── .github ├── .gitkeep ├── FUNDING.yml └── workflows │ └── test.yaml ├── .gitignore ├── .nojekyll ├── .readthedocs.yaml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── .gitignore ├── .nojekyll ├── about.md ├── admin.md ├── api.md ├── apifirst.md ├── cache.md ├── customization.md ├── dnd.md ├── import_export.md ├── index.md ├── insert-after.jpg ├── insert-as-child.jpg ├── installation.md ├── migration.md ├── models.md ├── requirements.txt ├── roadmap.md └── using.md ├── mkdocs.yml ├── pyproject.toml ├── requirements.txt ├── setup.py ├── tests ├── __init__.py ├── apps.py ├── manage.py ├── models.py ├── requirements.txt ├── settings.py └── test_suite.py └── treenode ├── __init__.py ├── admin ├── __init__.py ├── admin.py ├── changelist.py ├── exporter.py ├── importer.py └── mixin.py ├── apps.py ├── cache.py ├── forms.py ├── managers ├── __init__.py ├── managers.py ├── queries.py └── tasks.py ├── models ├── __init__.py ├── decorators.py ├── factory.py ├── mixins │ ├── __init__.py │ ├── ancestors.py │ ├── children.py │ ├── descendants.py │ ├── family.py │ ├── logical.py │ ├── node.py │ ├── properties.py │ ├── roots.py │ ├── siblings.py │ ├── tree.py │ └── update.py └── models.py ├── settings.py ├── signals.py ├── static ├── .gitkeep ├── css │ ├── .gitkeep │ ├── tree_widget.css │ ├── treenode_admin.css │ └── treenode_tabs.css ├── js │ ├── .gitkeep │ ├── lz-string.min.js │ ├── tree_widget.js │ └── treenode_admin.js └── vendors │ └── jquery-ui │ ├── AUTHORS.txt │ ├── LICENSE.txt │ ├── external │ └── jquery │ │ └── jquery.js │ ├── images │ ├── ui-icons_444444_256x240.png │ ├── ui-icons_555555_256x240.png │ ├── ui-icons_777620_256x240.png │ ├── ui-icons_777777_256x240.png │ ├── ui-icons_cc0000_256x240.png │ └── ui-icons_ffffff_256x240.png │ ├── index.html │ ├── jquery-ui.css │ ├── jquery-ui.js │ ├── jquery-ui.min.css │ ├── jquery-ui.min.js │ ├── jquery-ui.structure.css │ ├── jquery-ui.structure.min.css │ ├── jquery-ui.theme.css │ ├── jquery-ui.theme.min.css │ └── package.json ├── templates ├── .gitkeep └── treenode │ ├── .gitkeep │ ├── admin │ ├── .gitkeep │ ├── treenode_ajax_rows.html │ ├── treenode_changelist.html │ ├── treenode_import_export.html │ └── treenode_rows.html │ └── widgets │ └── tree_widget.html ├── templatetags ├── __init__.py └── treenode_admin.py ├── tests.py ├── urls.py ├── utils ├── __init__.py └── db │ ├── __init__.py │ ├── compiler.py │ ├── db_vendor.py │ ├── service.py │ ├── sqlcompat.py │ └── sqlquery.py ├── version.py ├── views ├── __init__.py ├── autoapi.py ├── autocomplete.py ├── children.py ├── common.py ├── crud.py └── search.py └── widgets.py /.github/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimurKady/django-fast-treenode/b1880330f0d49aa1a418df70e5aa7827ea1baae2/.github/.gitkeep -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: timurkady 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | 17 | services: 18 | postgres: 19 | image: postgres:14 20 | env: 21 | POSTGRES_USER: postgres 22 | POSTGRES_PASSWORD: postgres 23 | POSTGRES_DB: testdb 24 | ports: 25 | - 5432:5432 26 | options: >- 27 | --health-cmd pg_isready 28 | --health-interval 10s 29 | --health-timeout 5s 30 | --health-retries 5 31 | 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v3 35 | 36 | - name: Set up Python 37 | uses: actions/setup-python@v4 38 | with: 39 | python-version: "3.12" 40 | 41 | - name: Install dependencies 42 | run: | 43 | python -m pip install --upgrade pip 44 | pip install -r tests/requirements.txt 45 | pip install git+https://github.com/TimurKady/django-fast-treenode.git 46 | pip install psycopg2-binary 47 | 48 | - name: Run tests on PostgreSQL 49 | env: 50 | DJANGO_SETTINGS_MODULE: tests.settings 51 | PYTHONPATH: . 52 | POSTGRES_DB: testdb 53 | POSTGRES_USER: postgres 54 | POSTGRES_PASSWORD: postgres 55 | POSTGRES_HOST: localhost 56 | POSTGRES_PORT: 5432 57 | run: | 58 | python tests/manage.py makemigrations --noinput 59 | python tests/manage.py migrate --noinput 60 | python tests/manage.py test --debug-mode -v2 61 | 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.spyproject 2 | /dist 3 | /django_fast_treenode.egg-info 4 | __pycache__/ 5 | 6 | -------------------------------------------------------------------------------- /.nojekyll: -------------------------------------------------------------------------------- 1 | touch .nojekyll 2 | git add .nojekyll 3 | git commit -m "Disable Jekyll for GitHub Pages" 4 | git push origin main 5 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version, and other tools you might need 8 | build: 9 | os: ubuntu-24.04 10 | tools: 11 | python: "3.13" 12 | 13 | # Build documentation with Mkdocs 14 | mkdocs: 15 | configuration: mkdocs.yml 16 | 17 | # Optionally, but recommended, 18 | # declare the Python requirements required to build your documentation 19 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | python: 21 | install: 22 | - requirements: docs/requirements.txt 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [3.0.0] – 2025-04-29 4 | 5 | ### Major update 6 | - Complete refactoring of the package architecture with division into submodules (views, utils, managers, models, admin). 7 | - Removed deprecated approaches (closure), replaced with a single model with path-based and priority-based logic. 8 | - Introduced automatic tree integrity check: `check_tree_integrity()`. 9 | - Expanded serialization capabilities: `get_tree()`, `get_tree_json()`, `load_tree_json()`, `load_tree()`. 10 | - Support for new interfaces for import/export, autocomplete, CRUD and search. 11 | - Added Django-compatible `unittest` tests for automatic CI/CD checking. 12 | 13 | ### Added 14 | - `treenode/admin/{exporter, importer, mixin}.py` — new modules for extended work in the admin panel. 15 | - `treenode/models/models.py` — new main container of the tree model. 16 | - `treenode/utils/db/` — new subsystem for low-level SQL logic and compatibility with various DBMS. 17 | - `treenode/views/*` — modular structure of views (autocomplete, search, api). 18 | - `treenode/settings.py` — default configuration with customization support. 19 | - New system of decorators, factories and updates (e.g. `models/decorators.py`, `models/mixins/update.py`). 20 | - `tests.py` file with unit tests. 21 | 22 | ### Removed 23 | - `closure.py`, `adjacency.py`, `base36.py`, `base16.py`, `radix.py` — deprecated storage and encoding schemes. 24 | - `treenode/views.py` — monolithic view replaced with modular structure. 25 | - Temporary or deprecated modules: `utils/aid.py`, `utils/db.py`, `utils/importer.py`, `classproperty.py`. 26 | 27 | ### Changed 28 | - Refactoring of almost all mixins (`ancestors`, `descendants`, `siblings`, `roots`, `properties`, `logical`, etc.). 29 | - Rewritten `admin.py`, `changelist.py`, `cache.py`, `widgets.py`, `factory.py` and others. 30 | - Improved compatibility with Django 4/5, performance, readability and code extensibility. 31 | - Improved logic of `version.py`, `urls.py`, added type annotations and documentation. 32 | 33 | ### Requirements 34 | - Python ≥ 3.9 35 | - Django ≥ 4.0 36 | - msgpack>=1.0.0 37 | - openpyxl>=3.0.0 38 | - pyyaml>=5.1 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2025 Timur Kady 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | recursive-include treenode/templates * 4 | recursive-include treenode/static * 5 | recursive-include docs * -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Treenode Framework 2 | **A hybrid open-source framework for working with trees in Django** 3 | 4 | [![Tests](https://github.com/TimurKady/django-fast-treenode/actions/workflows/test.yaml/badge.svg?branch=main)](https://github.com/TimurKady/django-fast-treenode/actions/workflows/test.yaml) 5 | [![Docs](https://readthedocs.org/projects/django-fast-treenode/badge/?version=latest)](https://django-fast-treenode.readthedocs.io/) 6 | [![PyPI](https://img.shields.io/pypi/v/django-fast-treenode.svg)](https://pypi.org/project/django-fast-Treenode/) 7 | [![Published on Django Packages](https://img.shields.io/badge/Published%20on-Django%20Packages-0c3c26)](https://djangopackages.org/packages/p/django-fast-treenode/) 8 | [![Sponsor](https://img.shields.io/github/sponsors/TimurKady)](https://github.com/sponsors/TimurKady) 9 | 10 | ## About The Treenode Framework 11 | ### Overview 12 | 13 | **Treenode Framework** is an advanced tree management system for Django applications.It is designed to handle large-scale, deeply nested, and highly dynamic tree structures while maintaining excellent performance, data integrity, and ease of use. 14 | 15 | Unlike traditional solutions, **Treenode Framework** is built for serious real-world applications where trees may consist of: 16 | 17 | - Number nodes to 50-100 thousands nodes, 18 | - Nesting depths to 1,000 levels, 19 | - Complex mutation patterns (bulk moves, merges, splits), 20 | - Real-time data access via API. 21 | 22 | Its core philosophy: **maximum scalability, minimum complexity**. 23 | 24 | ### Key Features 25 | #### Common operations 26 | The `django-fast-Treenode` package supports all the basic operations needed to work with tree structures: 27 | 28 | - Extracting **ancestors** (queryset, list, pks, count); 29 | - Extracting **children** (queryset, list, pks, count); 30 | - Extracting **descendants** (queryset, list, pks, count); 31 | - Extracting a **family**: ancestors, the node itself and its descendants (queryset, list, pks, count); 32 | - Enumerating all the nodes (queryset, dicts); 33 | - **Adding** a new node at a **certain position** on the tree; 34 | - Automatic **sorting of node order** by the value of the specified field; 35 | - **Deleting** an node; 36 | - **Pruning**: Removing a whole section of a tree; 37 | - **Grafting**: Adding a whole section to a tree; 38 | - Finding the **root** for any node; 39 | - Finding the **lowest common ancestor** of two nodes; 40 | - Finding the **shortest path** between two nodes. 41 | 42 | Due to its high performance and ability to support deep nesting and large tree sizes, the `django-fast-treeode` package can be used for any tasks that involve the use of tree-like data, with virtually no restrictions. 43 | 44 | ### Where Massive Trees Really Matter? 45 | 46 | **Treenode Framework** is designed to handle not only toy examples, but also real trees with strict requirements for the number of nodes and their nesting. 47 | 48 | Typical applications include: 49 | 50 | - **Digital Twin Systems** for industrial asset management (plants, machinery, vehicles), where full structural decomposition is critical for maintenance planning and cost optimization. 51 | - **Decision Support Systems** in medicine, law, and insurance, where large and dynamic decision trees drive critical reasoning processes. 52 | - **AI Planning Engines** based on hierarchical task networks, allowing intelligent agents to decompose and execute complex strategies. 53 | - **Biological and Genetic Research**, where large phylogenetic trees model evolutionary relationships or genetic hierarchies. 54 | 55 | In all these domains, scalable and fast tree management is not a luxury — it's a necessity. 56 | 57 | ### Why Treenode Framework? 58 | At the moment, django-fast-treeenode is, if not the best, then one of the best packages for working with tree data under Djangjo. 59 | 60 | - **High performance**: [tests show](docs/about.md#benchmark-tests) that on trees of 5k-10k nodes with a nesting depth of 500-600 levels, **Treenode Framework** (`django-fast-Treenode`) shows **performance 4-7 times better** than the main popular packages. 61 | - **Flexible API**: today contains the widest set of methods for working with a tree in comparison with other packages. 62 | - **Convenient administration**: the admin panel interface was developed taking into account the experience of using other packages. It provides convenience and intuitiveness with ease of programming. 63 | - **Scalability**: **Treenode Framework** suitable for solving simple problems such as menus, directories, parsing arithmetic expressions, as well as complex problems such as program optimization, image layout, multi-step decision making problems, or machine learning.. 64 | - **Lightweight**: All functionality is implemented within the package without heavyweight dependencies such as `djangorestframework` or `django-import-export`. 65 | 66 | All this makes **Treenode Framework** a prime candidate for your needs. 67 | 68 | ## Quick Start 69 | To get started quickly, you need to follow these steps: 70 | 71 | - Simply install the package via `pip`: 72 | ```sh 73 | pip install django-fast-Treenode 74 | ``` 75 | - Once installed, add `'treenode'` to your `INSTALLED_APPS` in **settings.py**: 76 | ```python {title="settings.py"} 77 | INSTALLED_APPS = [ 78 | ... 79 | 'treenode', 80 | ... 81 | ] 82 | ``` 83 | 84 | - Open **models.py** and create your own tree class: 85 | ``` 86 | from Treenode.models import TreenodeModel 87 | 88 | class MyTree(TreenodeModel): 89 | name = models.CharField(max_length=255) 90 | display_field = "name" 91 | ``` 92 | 93 | - Open **admin.py** and create a model for the admin panel 94 | ``` 95 | from django.contrib import admin 96 | from Treenode.admin import TreenodeModelAdmin 97 | from .models import MyTree 98 | 99 | @admin.register(MyTree) 100 | class MyTreeAdmin(TreenodeModelAdmin): 101 | list_display = ("name",) 102 | search_fields = ("name",) 103 | ``` 104 | 105 | - Then, apply migrations: 106 | ```sh 107 | python manage.py makemigrations 108 | python manage.py migrate 109 | ``` 110 | 111 | - Run server 112 | ```sh 113 | python manage.py runserver 114 | ``` 115 | Everything is ready, enjoy 🎉! 116 | 117 | ## Documentation 118 | Full documentation is available at **[ReadTheDocs](https://django-fast-Treenode.readthedocs.io/)**. 119 | 120 | Quick access links: 121 | * [Installation, configuration and fine tuning](https://django-fast-Treenode.readthedocs.io/installation/) 122 | * [Model Inheritance and Extensions](https://django-fast-Treenode.readthedocs.io/models/) 123 | * [Working with Admin Classes](https://django-fast-Treenode.readthedocs.io/admin/) 124 | * [API Reference](https://django-fast-Treenode.readthedocs.io/api/) 125 | * [Import & Export](https://django-fast-Treenode.readthedocs.io/import_export/) 126 | * [Caching and working with cache](https://django-fast-Treenode.readthedocs.io/cache/) 127 | * [Migration and upgrade guide](https://django-fast-Treenode.readthedocs.io/migration/) 128 | 129 | Your wishes, objections, comments are welcome. 130 | 131 | ## License 132 | Released under [MIT License](https://github.com/TimurKady/django-fast-Treenode/blob/main/LICENSE). 133 | 134 | ## Credits 135 | Thanks to everyone who contributed to the development and testing of this package, as well as the Django community for their inspiration and support. 136 | 137 | Special thanks to [Fabio Caccamo](https://github.com/fabiocaccamo) for the idea behind creating a fast Django application for handling hierarchies. 138 | 139 | Also special thanks to everyone who supports the project with their [sponsorship donations](https://github.com/sponsors/TimurKady). 140 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimurKady/django-fast-treenode/b1880330f0d49aa1a418df70e5aa7827ea1baae2/docs/.gitignore -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | touch .nojekyll 2 | git add .nojekyll 3 | git commit -m "Disable Jekyll for GitHub Pages" 4 | git push origin main 5 | -------------------------------------------------------------------------------- /docs/admin.md: -------------------------------------------------------------------------------- 1 | ## Working with Admin Classes 2 | 3 | ### Using `TreeNodeModelAdmin` 4 | The easiest way to integrate tree structures into Django’s admin panel is by inheriting from `TreeNodeModelAdmin`. This base class provides all the necessary functionality for managing hierarchical data. 5 | 6 | ##### admin.py: 7 | ```python 8 | from django.contrib import admin 9 | from treenode.admin import TreeNodeModelAdmin 10 | 11 | from .models import Category 12 | 13 | @admin.register(Category) 14 | class CategoryAdmin(TreeNodeModelAdmin): 15 | 16 | # Set the display mode: 'accordion', 'breadcrumbs', or 'indentation' 17 | treenode_display_mode = TreeNodeModelAdmin.TREENODE_DISPLAY_MODE_ACCORDION 18 | # treenode_display_mode = TreeNodeModelAdmin.TREENODE_DISPLAY_MODE_BREADCRUMBS 19 | 20 | list_display = ("name",) 21 | search_fields = ("name",) 22 | ``` 23 | 24 | The tree structure in the admin panel **loads dynamically as nodes are expanded**. This allows handling **large datasets** efficiently, preventing performance issues. 25 | 26 | You can choose from three display modes: 27 | 28 | - **`TREENODE_DISPLAY_MODE_ACCORDION` (default)** 29 | Expands/collapses nodes dynamically. 30 | - **`TREENODE_DISPLAY_MODE_BREADCRUMBS`** 31 | Displays the tree as a sequence of **breadcrumbs**, making it easy to navigate. 32 | 33 | The accordion mode is **always active**, and the setting only affects how nodes are displayed. 34 | 35 | **Why Dynamic Loading**: Traditional pagination does not work well for **deep hierarchical trees**, as collapsed trees may contain a **huge number of nodes**, which is in the hundreds of thousands. The dynamic approach allows efficient loading, reducing database load while keeping large trees manageable. 36 | 37 | #### Search Functionality 38 | The search bar helps quickly locate nodes within large trees. As you type, **an AJAX request retrieves up to 20 results** based on relevance. If you don’t find the desired node, keep typing to refine the search until fewer than 20 results remain. 39 | 40 | ### Working with Forms 41 | 42 | #### Using TreeNodeForm 43 | If you need to customize forms for tree-based models, inherit from `TreeNodeForm`. It provides: 44 | 45 | - A **custom tree widget** for selecting parent nodes. 46 | - Automatic **exclusion of self and descendants** from the parent selection to prevent circular references. 47 | 48 | ##### forms.py: 49 | ```python 50 | from treenode.forms import TreeNodeForm 51 | from .models import Category 52 | 53 | class CategoryForm(TreeNodeForm): 54 | """Form for Category model with hierarchical selection.""" 55 | 56 | class Meta(TreeNodeForm.Meta): 57 | model = Category 58 | ``` 59 | 60 | Key Considerations: 61 | 62 | - This form automatically ensures that **a node cannot be its own parent**. 63 | - It uses **`TreeWidget`**, a custom hierarchical dropdown for selecting parent nodes. 64 | - If you need a form for another tree-based model, use the **dynamic factory method**: 65 | 66 | ```python 67 | CategoryForm = TreeNodeForm.factory(Category) 68 | ``` 69 | 70 | This method ensures that the form correctly associates with different tree models dynamically. 71 | 72 | 73 | ### Using TreeWidget Widget 74 | 75 | #### The TreeWidget Class 76 | The `TreeWidget` class is a **custom Select2-like widget** that enables hierarchical selection in forms. 77 | 78 | ##### widgets.py 79 | 80 | ```python 81 | from django import forms 82 | from treenode.widgets import TreeWidget 83 | from .models import Category 84 | 85 | class CategorySelectionForm(forms.Form): 86 | parent = forms.ModelChoiceField( 87 | queryset=Category.objects.all(), 88 | widget=TreeWidget(), 89 | required=False 90 | ) 91 | ``` 92 | 93 | !!! note 94 | - **Customizable Data Source**: The `data-url` attribute can be adjusted to fetch tree data from a custom endpoint. 95 | - **Requires jQuery**: The widget relies on AJAX requests, so ensure jQuery is available when using it outside Django’s admin. 96 | - **Dynamically Fetches Data**: It loads the tree structure asynchronously, preventing performance issues with large datasets. 97 | 98 | If you plan to use this widget in non-admin templates, make sure the necessary **JavaScript and CSS files** are included: 99 | ```html 100 | 101 | 102 | ``` 103 | 104 | By following these guidelines, you can seamlessly integrate `TreeNodeModelAdmin`, `TreeNodeForm`, and `TreeWidget` into your Django project, ensuring efficient management of hierarchical data. 105 | -------------------------------------------------------------------------------- /docs/apifirst.md: -------------------------------------------------------------------------------- 1 | ## API-First Support 2 | 3 | ### Approach 4 | 5 | **The TreeNode Framework** embraces a true **API-First** approach. This means that as soon as you create a model inherited from `TreeNodeModel`, you automatically get a fully functional set of RESTful API endpoints — without writing any serializers, views, routers, or manually configuring anything. 6 | 7 | No extra setup. No DRF overhead. No boilerplate code. Just **model ➔ ready-to-use API**, instantly. 8 | 9 | What Makes It Special: 10 | 11 | - **Zero configuration:** No need for serializers, views, or routers. 12 | - **Uniform structure:** All tree models behave consistently. 13 | - **High performance:** Optimized lazy updates minimize database load. 14 | - **Developer happiness:** Focus on your logic, not boilerplate code. 15 | 16 | Create your model. Run the server. Enjoy your instant API. 17 | 18 | ### How It Works 19 | 20 | The framework automatically scans your project for models based on `TreeNodeModel` and dynamically generates API routes. 21 | 22 | You do not need to: 23 | 24 | - Create view classes 25 | - Write serializers 26 | - Manually register URLs 27 | - Configure routers 28 | 29 | As soon as the server starts, all tree-enabled models are discovered and their endpoints are created. This happens through the `AutoTreeAPI` engine built into the framework. 30 | 31 | For each `TreeNodeModel`, the following endpoints are available: 32 | 33 | | Method | Endpoint | Description | 34 | |:---------|:----------------------------------------|:------------| 35 | | `GET` | `/api//?flat=true` | List all nodes, ordered by tree structure | 36 | | `POST` | `/api//` | Create a new node | 37 | | `GET` | `/api///` | Retrieve a single node | 38 | | `PUT` | `/api///` | Replace a node completely | 39 | | `PATCH` | `/api///` | Update a node partially | 40 | | `DELETE` | `/api///?cascade=true|false` | Delete a node (with or without subtree) | 41 | | `GET` | `/api//tree/?flat=true` | Retrieve the entire tree | 42 | | `GET` | `/api//tree/?annotated=true` | Retrieve the tree with depth [annotations](api.md#get_tree_annotated) | 43 | | `GET` | `/api///children/` | Retrieve direct children of a node | 44 | | `GET` | `/api///descendants/` | Retrieve all descendants of a node | 45 | | `GET` | `/api///family/` | Retrieve the family | 46 | 47 | !!! note 48 | * All API endpoints are automatically generated under the `treenode` namespace (f.e., `treenode/api//`). 49 | * `` refers to the lowercased model name (e.g., `category`, `department`, `location`, etc.). 50 | 51 | !!! danger "Important Note for Production Environments" 52 | In production environments, **API endpoints must not remain open**. Always secure your API using [authentication mechanisms](#basic-access-control) or any other appropriate method. 53 | 54 | Leaving APIs open in production exposes your system to unauthorized access, data leaks, and potential security breaches. 55 | 56 | 57 | --- 58 | 59 | ### Example Usage 60 | 61 | Create a node: 62 | 63 | ```bash 64 | POST treenode/api/category/ 65 | { 66 | "name": "New Node", 67 | "parent_id": 123, 68 | "priority": 0 69 | } 70 | ``` 71 | 72 | Retrieve all nodes: 73 | 74 | ```bash 75 | GET treenode/api/category/ 76 | ``` 77 | 78 | Get the tree structure: 79 | 80 | ```bash 81 | GET treenode/api/category/tree/ 82 | ``` 83 | 84 | Move a node: 85 | 86 | Simply `PATCH` its `parent_id` and/or `priority` field: 87 | 88 | ```bash 89 | PATCH treenode/api/category/456/ 90 | { 91 | "parent_id": 789, 92 | "priority": 1 93 | } 94 | ``` 95 | 96 | Delete a node (with or without descendants): 97 | 98 | ```bash 99 | DELETE treenode/api/category/456/?cascade=true 100 | ``` 101 | 102 | - `cascade=true` (default): Delete the node and all its descendants. 103 | - `cascade=false`: Move the node's children up before deleting it. 104 | 105 | All endpoints use standard JSON format for input and output. 106 | 107 | No complicated payloads. No custom formats. **TreeNode Framework** believes that models should define your API — not the other way around. 108 | 109 | --- 110 | 111 | ### Basic Access Control 112 | TreeNode Framework follows an API-First philosophy: API endpoints are generated automatically for each tree model, without the need to manually register views or routes. 113 | 114 | However, API protection is currently basic — based on login sessions using Django's standard authentication system (login_required). This is simple but effective for many internal or admin-side applications. 115 | 116 | In the future, full token-based authentication (e.g., JWT) will be introduced for more robust and flexible security. 117 | 118 | #### How to Secure Your API Step-by-Step 119 | Since TreeNode Framework does not provide an authentication system itself, you need to set up basic login endpoints in your project. 120 | 121 | Here’s how you can do it: 122 | 123 | **Step 1. Enable login protection** 124 | 125 | Either: 126 | 127 | - Set `api_login_required` = True in your tree model, or 128 | - Set `TREENODE_API_LOGIN_REQUIRED = True` in **settings.py** of your project . 129 | 130 | **Step 2. Add login and logout views** 131 | 132 | In your **urls.py**, add: 133 | 134 | ```python 135 | from django.contrib.auth import views as auth_views 136 | 137 | urlpatterns = [ 138 | path('accounts/login/', auth_views.LoginView.as_view(), name='login'), 139 | path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'), 140 | ] 141 | ``` 142 | 143 | This creates standard login and logout endpoints using Django’s built-in authentication system. 144 | 145 | **Step 3. Configure your login URL** 146 | 147 | In your **settings.py**: 148 | 149 | ```python 150 | LOGIN_URL = '/accounts/login/' 151 | ``` 152 | 153 | This tells Django where to redirect unauthorized users trying to access protected API endpoints. 154 | 155 | !!! note 156 | Token support (JWT) will be introduced in future releases for full API-level security. Until then, make sure you correctly configure login protection if you are deploying the API to a production environment. 157 | 158 | -------------------------------------------------------------------------------- /docs/cache.md: -------------------------------------------------------------------------------- 1 | ## TreeNode Cache Management 2 | 3 | ### Caching Strategy 4 | 5 | The caching mechanism in TreeNode Framework has undergone a clear evolution from a naive first-generation strategy to a robust, controlled system. 6 | 7 | Initially, it used Django's default cache to store individual query results. This was simple and fast, but quickly led to uncontrolled memory growth, especially under frequent tree operations, eventually degrading overall cache performance. 8 | 9 | In the current version, caching is managed through a fixed-size FIFO queue, shared across all TreeNodeModel instances. This ensures that the oldest entries are automatically discarded when the memory limit is reached. 10 | 11 | While FIFO is not the most sophisticated strategy, it strikes an effective balance between performance and simplicity, providing predictable behavior and stable memory usage under high load — a significant improvement over the earlier unbounded approach. 12 | 13 | --- 14 | 15 | ### Key Features 16 | 17 | **Global Cache Limit**: The setting `TREENODE_CACHE_LIMIT` defines the maximum cache size (in MB) for **all models** inheriting from `TreeNodeModel`. Default is **100MB** if not explicitly set in `settings.py`. 18 | 19 | **settings.py** 20 | ``` python 21 | TREENODE_CACHE_LIMIT = 100 22 | ``` 23 | 24 | **Automatic Management**. In most cases, users don’t need to manually manage cache operations.All methods that somehow change the state of models reset the tree cache automatically. 25 | 26 | **Manual Cache Clearing**. If for some reason you need to reset the cache, you can do it in two ways: 27 | 28 | - **Clear cache for a single model**: Use `clear_cache()` at the model level: 29 | ```python 30 | MyTreeNodeModel.clear_cache() 31 | ``` 32 | - **Clear cache for all models**: Use the global `treenode_cache.clear()` method: 33 | ```python 34 | from treenode.cache import treenode_cache 35 | treenode_cache.clear() 36 | ``` 37 | 38 | --- 39 | 40 | ### Ceche API 41 | 42 | #### `@cached_method` Decorator 43 | 44 | The `@cached_method` decorator is available for caching method results in **class and instance methods** of models inheriting from `TreeNodeModel`. This decorator helps optimize performance by reducing redundant computations. 45 | 46 | ```python 47 | from treenode.cache import cached_method 48 | from treenode.models import TreeNodeModel 49 | 50 | class Category(TreeNodeModel): 51 | name = models.CharField(max_length=50) 52 | 53 | @cached_method 54 | def my_tree_method(self): 55 | # Your code is here 56 | ``` 57 | 58 | In this example, `my_tree_method()` is cached. 59 | 60 | **Important:** The decorator should **only** be used with `TreeNodeModel` subclasses. Applying it to other classes will cause unexpected behavior. 61 | 62 | 63 | #### Accessing Cache via `treenode_cache` 64 | 65 | A global cache instance `treenode_cache` provides direct cache manipulation methods, allowing you to generate cache keys, store, retrieve, and invalidate cached values. 66 | 67 | Methods available in `treenode_cache`: 68 | 69 | #### generate_cache_key() 70 | Generates a unique cache key for caching method results. The key is based on model name, method name, object identifier, and method parameters. 71 | 72 | ```python 73 | cache_key = treenode_cache.generate_cache_key( 74 | label=Category._meta.label, # Model label 75 | func_name=self..__name__, 76 | unique_id=42, # This can be the object.pk. In a desperate situation, use id(self) 77 | attr="some string value" 78 | ) 79 | ``` 80 | This ensures that the cache key follows Django's conventions and remains unique. 81 | 82 | #### set() 83 | Stores a value in the cache. 84 | 85 | ```python 86 | treenode_cache.set(cache_key, {'name': 'Root', 'id': 1}) 87 | ``` 88 | This caches a dictionary object under the generated key. 89 | 90 | #### get() 91 | Retrieves a cached value by its cache key. 92 | 93 | ```python 94 | cached_value = treenode_cache.get(cache_key) 95 | if cached_value is None: 96 | cached_value = compute_expensive_query() 97 | treenode_cache.set(cache_key, cached_value) 98 | ``` 99 | 100 | #### invalidate() 101 | Removes all cached entries for a **specific model**. 102 | 103 | ```python 104 | treenode_cache.invalidate(Category._meta.label) 105 | ``` 106 | This clears all cache entries related to `Category` instances. 107 | 108 | #### clear() 109 | Clears **all** cached entries in the system. 110 | 111 | ```python 112 | treenode_cache.clear() 113 | ``` 114 | This completely resets the cache, removing **all stored values**. 115 | 116 | Best Practices: 117 | 118 | - **Always use `generate_cache_key()`** instead of hardcoding cache keys to ensure consistency. 119 | - **Use `invalidate()` instead of `clear()`** when targeting a specific model’s cache. 120 | - **Apply `@cached_method` wisely**, ensuring it is used **only for** `TreeNodeModel`-based methods to avoid conflicts. 121 | - **Be mindful of cache size**, as excessive caching can lead to memory bloat. 122 | 123 | By leveraging these caching utilities, `django-fast-treenode` ensures efficient handling of hierarchical data while maintaining high performance. 124 | 125 | -------------------------------------------------------------------------------- /docs/dnd.md: -------------------------------------------------------------------------------- 1 | ## How to Use Drag-and-Drop 2 | 3 | **The Treenode Framework** offers a flexible and intuitive Drag-and-Drop (D-n-D) interface for rearranging nodes within the tree. 4 | However, to use it correctly and avoid unexpected results, please follow these guidelines: 5 | 6 | #### How to use 7 | 8 | **How to Drag**: 9 | 10 | - To move a node, **grab it by the drag handle button** (`≡` or `↕`). 11 | - **Do not** try to drag the row itself — always use the handle. 12 | - Begin dragging **normally** — do not press any keys yet. 13 | 14 | **How to Drop**. There are two distinct drop modes: 15 | 16 | - **Insert after a node**: Drag normally (no keys). The dragged node becomes a sibling **after** the target node. 17 | - **Insert as a child**: Hold **Shift** while dragging. The dragged node becomes a **child** of the target node. 18 | - While dragging: 19 | - A **purple cursor** indicates "Insert after" mode (default). 20 | - A **green cursor** appears when **Shift** is pressed, indicating "Insert as child" mode. 21 | 22 | ![Insert After Example](./insert-after.jpg) 23 | 24 | _The purple cursor indicates "Insert after" mode. The node will be inserted as a sibling after the target node._ 25 | 26 | ![Insert as Child Example](./insert-as-child.jpg) 27 | 28 | _The green cursor indicates "Insert as child" mode. The node will become a child of the target node._ 29 | 30 | !!! tip 31 | - **First start dragging, then press Shift.** 32 | Do not press Shift before starting the drag action — otherwise the shift state might not be properly recognized. 33 | - **Inserting after a parent node does not make the node the first child.** 34 | If you drag a node and drop it after a parent node without Shift: 35 | 36 | - The node becomes a **sibling** to the parent's other children. 37 | - It will be placed **at the end of the sibling list**, not at the top. 38 | 39 | - **To make a node the first child** of another node, you must: 40 | 41 | 1. Start dragging, 42 | 2. Press **Shift** during the drag, 43 | 3. Drop onto the target parent node. 44 | 45 | --- 46 | 47 | #### Common Mistakes to Avoid 48 | 49 | | Mistake | What happens | Correct way | 50 | |:---------------------------|:----------------------------|:---------------------------------| 51 | | Pressing Shift before starting drag | Shift state not detected properly | First drag, then press Shift | 52 | | Dropping after a parent node without Shift | Node becomes last among siblings | Use Shift to insert as a child | 53 | | Trying to drag the entire row | Dragging won't start | Always use the drag handle | 54 | 55 | 56 | -------------------------------------------------------------------------------- /docs/import_export.md: -------------------------------------------------------------------------------- 1 | ## Import&Export Functionality 2 | ### Overview 3 | 4 | **The Treenode Framework** includes **built-in export and import features** for easier data migration. Supported Formats: `csv`, `json`, `xlsx`, `yaml` and `tsv`. The system supports importing and exporting data for any models, allowing users to efficiently manage and update data while preserving its structure and relationships. 5 | 6 | ### Data Processing Logic 7 | When importing data into the system, **two key fields** must be present: 8 | 9 | - **`id`** – the unique identifier of the record. 10 | - **`parent`** – the identifier of the parent node. 11 | 12 | It is also desirable to have a **`priority`** field in the import file, which specifies the ordinal number of the node among the descendants of its parent. It is recommended to explicitly assign a `priority` value to each node during import. 13 | 14 | If no `priority` is provided, the system will automatically assign it based on the `sorting_field` setting. If `sorting_field` is set to `priority`, the final ordering of nodes may not be strictly predictable and will generally follow the order of nodes as they appear in the import file. 15 | 16 | These fields ensure the correct construction of the hierarchical data structure. 17 | 18 | !!! important 19 | Starting from Django 5.0, it is no longer allowed to create model instances with a manually specified value for an `id` field of type `AutoField`. 20 | As a result, during import operations: 21 | 22 | - If the specified `id` exists in the database, the record **will be updated**. 23 | - If the specified `id` does not exist, a new record will be created, but it **will be assigned a different (auto-generated) `id`**. 24 | 25 | Don't forget to thank the developers for this very useful improvement and for caring about us when you visit the Django community 26 | 27 | This import mechanism is designed to allow users to: 28 | 29 | - **Export data**, edit it (e.g., in a CSV, Excel, or JSON file). 30 | - **Upload the modified file** without disrupting the model structure. 31 | - **Update only the changed data**, keeping relationships with other models intact (e.g., without altering primary and foreign key values). 32 | 33 | This approach provides **flexible data management**, enabling users to safely apply modifications without manually updating each record in the system. 34 | 35 | !!! important 36 | Exporting objects with M2M fields may lead to serialization issues. 37 | Some formats (e.g., CSV) do not natively support many-to-many relationships. If you encounter errors, consider exporting data in `json` or `yaml` format, which better handle nested structures. 38 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Treenode Framework 2 | **A hybrid open-source framework for working with trees in Django** 3 | 4 | Welcome to the **django-fast-treenode** documentation! 5 | 6 | --- 7 | 8 | ### Overview 9 | 10 | **Treenode Framework** is an advanced tree management system for Django applications. It is designed to handle large-scale, deeply nested, and highly dynamic tree structures while maintaining excellent performance, data integrity, and ease of use. 11 | 12 | Unlike traditional solutions, **Treenode Framework** is built for serious real-world applications where trees may consist of: 13 | 14 | - Number nodes to 50-100 thousands nodes, 15 | - Nesting depths to 1,000 levels, 16 | - Complex mutation patterns (bulk moves, merges, splits), 17 | - Real-time data access via API. 18 | 19 | Its core philosophy: **maximum scalability, minimum complexity**. 20 | 21 | --- 22 | 23 | ### Where Massive Trees Really Matter? 24 | 25 | **Treenode Framework** is designed to handle not only toy examples, but also real trees with strict requirements for the number of nodes and their nesting. 26 | 27 | Typical applications include: 28 | 29 | - **Digital Twin Systems** for industrial asset management (plants, machinery, vehicles), where full structural decomposition is critical for maintenance planning and cost optimization. 30 | - **Decision Support Systems** in medicine, law, and insurance, where large and dynamic decision trees drive critical reasoning processes. 31 | - **AI Planning Engines** based on hierarchical task networks, allowing intelligent agents to decompose and execute complex strategies. 32 | - **Biological and Genetic Research**, where large phylogenetic trees model evolutionary relationships or genetic hierarchies. 33 | 34 | In all these domains, scalable and fast tree management is not a luxury — it's a necessity. 35 | 36 | ### Why TreeNode Framework? 37 | At the moment, django-fast-treeenode is, if not the best, then one of the best packages for working with tree data under Djangjo. 38 | 39 | - **High performance**: [tests show](about.md#benchmark-tests) that on trees of 5k-10k nodes with a nesting depth of 500-600 levels, `django-fast-treenode` shows **performance 4-7 times better** than the main popular packages. 40 | - **Flexible API**: today `django-fast-treenode` contains the widest set of methods for working with a tree in comparison with other packages. 41 | - **Convenient administration**: the admin panel interface was developed taking into account the experience of using other packages. It provides convenience and intuitiveness with ease of programming. 42 | - **Scalability**: `django-fast-treenode` suitable for solving simple problems such as menus, directories, parsing arithmetic expressions, as well as complex problems such as program optimization, image layout, multi-step decision making problems, or machine learning.. 43 | - **Lightweight**: All functionality is implemented within the package without heavyweight dependencies such as `djangorestframework` or `django-import-export`. 44 | 45 | All this makes `django-fast-treenode` a prime candidate for your needs. 46 | 47 | ### Quick Start 48 | To get started quickly, you need to follow these steps: 49 | 50 | - Simply install the package via `pip`: 51 | ```sh 52 | pip install django-fast-treenode 53 | ``` 54 | - Once installed, add `'treenode'` to your `INSTALLED_APPS` in **settings.py**: 55 | ```python {title="settings.py"} 56 | INSTALLED_APPS = [ 57 | ... 58 | 'treenode', 59 | ... 60 | ] 61 | ``` 62 | 63 | - Open **models.py** and create your own tree class: 64 | ``` 65 | from treenode.models import TreeNodeModel 66 | 67 | class MyTree(TreeNodeModel): 68 | name = models.CharField(max_length=255) 69 | display_field = "name" 70 | ``` 71 | 72 | - Open **admin.py** and create a model for the admin panel 73 | ``` 74 | from django.contrib import admin 75 | from treenode.admin import TreeNodeModelAdmin 76 | from .models import MyTree 77 | 78 | @admin.register(MyTree) 79 | class MyTreeAdmin(TreeNodeModelAdmin): 80 | list_display = ("name",) 81 | search_fields = ("name",) 82 | ``` 83 | 84 | - Then, apply migrations: 85 | ```sh 86 | python manage.py makemigrations 87 | python manage.py migrate 88 | ``` 89 | 90 | - Run server 91 | ```sh 92 | python manage.py runserver 93 | ``` 94 | 95 | Everything is ready, enjoy 🎉! 96 | 97 | --- 98 | 99 | ### Key Features 100 | #### Common operations 101 | The `django-fast-treenode` package supports all the basic operations needed to work with tree structures: 102 | 103 | - Extracting **ancestors** (queryset, list, pks, count); 104 | - Extracting **children** (queryset, list, pks, count); 105 | - Extracting **descendants** (queryset, list, pks, count); 106 | - Extracting a **family**: ancestors, the node itself and its descendants (queryset, list, pks, count); 107 | - Enumerating all the nodes (queryset, dicts); 108 | - **Adding** a new node at a **certain position** on the tree; 109 | - Automatic **sorting of node order** by the value of the specified field; 110 | - **Deleting** an node; 111 | - **Pruning**: Removing a whole section of a tree; 112 | - **Grafting**: Adding a whole section to a tree; 113 | - Finding the **root** for any node; 114 | - Finding the **lowest common ancestor** of two nodes; 115 | - Finding the **shortest path** between two nodes. 116 | 117 | Due to its high performance and ability to support deep nesting and large tree sizes, the `django-fast-treeode` package can be used for any tasks that involve the use of tree-like data, with virtually no restrictions. 118 | 119 | #### Additional features 120 | The `django-fast-treenode` package has several additional features, some of which are unique to similar packages: 121 | 122 | - **API-first** support (without DRF); 123 | - Flexible **Admin Class**: tree widgets support and drag and drop functionality; 124 | - **Import and Export** tree data to/from file (CSV, JSON, TSV, XLSX, YAML). 125 | 126 | All these features are available "out of the box". 127 | 128 | --- 129 | 130 | 131 | 132 | --- 133 | 134 | ### Supported Versions 135 | #### Supported Django Versions 136 | 137 | The project supports Django versions starting from **Django 4.0** and higher: 138 | 139 | - Django 4.0, 4.1, 4.2 (LTS) 140 | - Django 5.0 141 | - Django 5.1 142 | - Django 5.2 (support is ready and tested) 143 | 144 | The project is designed for long-term compatibility with future Django versions without requiring architectural changes. 145 | 146 | #### Supported Databases and Minimum Versions 147 | 148 | All SQL queries are adapted through a universal compatibility layer, ensuring support for major databases without the need to rewrite SQL code. 149 | 150 | | Database | Minimum Version | Status | Notes | 151 | |:------------------|:------------------|:----------------|:-------------------| 152 | | **PostgreSQL** | ≥ 10.0 | Full tested | Recommended ≥ 12 | 153 | | **MySQL** | ≥ 8.0 | Partially tested | | 154 | | **MariaDB** | ≥ 10.5 | Not tested | | 155 | | **SQLite** | ≥ 3.35.0 | Partially tested | | 156 | | **Oracle** | ≥ 12c R2 | Full tested | Recommended ≥ 19c | 157 | | **MS SQL Server** | ≥ 2016 | Partially tested | | 158 | 159 | 160 | The project is **ready for production use** across all modern versions of Django and major relational databases without manual SQL corrections. 161 | 162 | ### License 163 | Released under the [MIT License](https://github.com/TimurKady/django-fast-treenode/blob/main/LICENSE). 164 | -------------------------------------------------------------------------------- /docs/insert-after.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimurKady/django-fast-treenode/b1880330f0d49aa1a418df70e5aa7827ea1baae2/docs/insert-after.jpg -------------------------------------------------------------------------------- /docs/insert-as-child.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimurKady/django-fast-treenode/b1880330f0d49aa1a418df70e5aa7827ea1baae2/docs/insert-as-child.jpg -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | This section describes the process of installing TreeNode Framework into a new Django project or upgrading previous versions of the package, including basic requirements, setup steps, and database migration. 4 | Specifically, it covers ways to migrate existing projects implemented using alternative applications to TreeNode Framework. 5 | 6 | ### Installation Steps 7 | If you are installing `django-fast-treenode` for the first time, the process is straightforward. Simply install the package via `pip`: 8 | 9 | ```sh 10 | pip install django-fast-treenode 11 | ``` 12 | 13 | If you are upgrading to a new version of `django-fast-treenode` or switching from `django-treenode`, it is essential to run migrations to ensure data consistency: 14 | 15 | ```sh 16 | pip install --upgrade django-fast-treenode 17 | ``` 18 | 19 | Once installed, add `'treenode'` to your `INSTALLED_APPS` in **settings.py**: 20 | 21 | ```python 22 | INSTALLED_APPS = [ 23 | ... 24 | 'treenode', 25 | ... 26 | ] 27 | ``` 28 | 29 | Now you can start fine-tuning the Treenode Framework or skip the next step and start [working with your own models](models.md#modelinheritance). 30 | 31 | --- 32 | 33 | ### Initial Configuration 34 | 35 | In the standard delivery, TreeNode Framework does not require additional configuration. All key parameters are optimized for working out of the box. 36 | Together, some settings that affect the operation of **Treenode Framework** can be changed by setting their values ​​in your project's settings file **settings.py**. 37 | 38 | #### Cache Size Allocation (`TREENODE_CACHE_LIMIT`) 39 | 40 | This setting controls the memory allocation for caching all tree model instances. 41 | 42 | The cache stores all tree-related data for models that inherit from TreeNodeModel. By default, the value of the `TREENODE_CACHE_LIMIT` parameter is 100 MB. 43 | 44 | You can control the cache size with `TREENODE_CACHE_LIMIT` by specifying its limit in megabytes. For example: 45 | 46 | ```python 47 | TREENODE_CACHE_LIMIT = 256 48 | ``` 49 | 50 | For more details on how caching works and how to fine-tune its behavior, refer to the [Caching and Cache Management](cache.md) Guide. 51 | 52 | 53 | #### The length on Materialized Path segment (`TREENODE_SEGMENT_LENGTH`) 54 | 55 | When constructing a node path (for example: `000.001.003`), each path segment (`"000"`, `"001"`, `"003"`) has a fixed length, determined by the `TREENODE_SEGMENT_LENGTH` constant. 56 | 57 | By default, the segment length is **3 characters**. This allows each parent node to have up to **4096 child nodes**. If you need to support more children at a single level, you can increase the value of `TREENODE_SEGMENT_LENGTH` by adding the appropriate setting to your project's **settings.py**: 58 | 59 | ```python 60 | TREENODE_SEGMENT_LENGTH = 4 61 | ``` 62 | 63 | !!! warning 64 | **Be careful**: increasing `TREENODE_SEGMENT_LENGTH` causes the materialized path field (which is indexed by the database) to become longer. Due to maximum index size limitations in most database management systems (DBMS), this can lead to database-level errors when the tree is deeply nested. 65 | 66 | In practice, for most use cases, a value of 3 is optimal. 67 | It allows efficient work with trees of 5,000–10,000 nodes, while supporting a maximum depth of approximately 1000 levels. 68 | 69 | Changing the segment length affects the balance between the width and depth of the tree: 70 | 71 | - A smaller value (e.g., 2) allows for greater depth but reduces the number of children per node (up to 256). 72 | - A larger value (e.g., 5) increases the number of possible children (up to 1,048,576) but reduces the maximum depth due to the increased path string size. 73 | 74 | 75 | | TREENODE_SEGMENT_LENGTH | Children per node (max) | Depth limit (aprox.) | 76 | |:-----------------------:|-------------------------:|:-------------------:| 77 | | 2 | 256 | ~3000 levels | 78 | | 3 | 4096 | ~1000 levels | 79 | | 4 | 65,536 | ~250 levels | 80 | | 5 | 1,048,576 | ~100 levels | 81 | 82 | Now the Treenode Framework is ready to work. You can proceed to describe your Django models by inheriting them from the abstract TreeNodeModel class and extending them according to the task you are solving. 83 | 84 | #### Login-based security to models APIs (`TREENODE_API_LOGIN_REQUIRED`) 85 | 86 | This setting enables global [API security for all tree models](apifirst.md) at once. When set to `True`, applies login-based security to all tree model APIs. Defaults to False 87 | 88 | **settings.py**: 89 | 90 | ```python 91 | TREENODE_API_LOGIN_REQUIRED = True 92 | ``` 93 | This forces all TreeNode Framework APIs to require authentication. 94 | 95 | If [`api_login_required`](models.md#clsapi_login_required) is set for a model, `api_login_required` takes precedence. If the setting does not exist at the model level, the global setting is used. 96 | 97 | -------------------------------------------------------------------------------- /docs/models.md: -------------------------------------------------------------------------------- 1 | ## Model Inheritance and Extensions 2 | 3 | ### Model Inheritance 4 | 5 | Creating your own tree model is very easy. Simply inherit from `TreeNodeModel`. Below is an example of a basic category model: 6 | 7 | **models.py** 8 | 9 | ```python 10 | from django.db import models 11 | from treenode.models import TreeNodeModel 12 | 13 | class Category(TreeNodeModel): 14 | treenode_display_field = "name" # Defines the field used for display in the admin panel 15 | 16 | name = models.CharField(max_length=50) 17 | 18 | class Meta(TreeNodeModel.Meta): # Preserve TreeNodeModel's indexing settings 19 | verbose_name = "Category" 20 | verbose_name_plural = "Categories" 21 | ``` 22 | 23 | !!! important 24 | Always specify `TreeNodeModel.Meta` as the parent of your model’s `Meta` class. Failing to do so will result in incorrect database indexing and other negative consequences. 25 | 26 | --- 27 | 28 | ### Class and Instance Attributes 29 | 30 | This section describes the class and instance attributes available when interacting with a tree model. Understanding these attributes will help you avoid subtle bugs and write cleaner, more efficient code. 31 | 32 | #### `node.parent` 33 | The core field of any tree node model is the `parent` field. It is a `ForeignKey` that establishes a many-to-one relationship between nodes, forming the tree hierarchy. 34 | 35 | !!! tip 36 | Although the `parent` field is always up-to-date and can be accessed directly, it is good practice to use the `get_parent()` and `set_parent()` methods for better consistency. 37 | 38 | #### `cls.treenode_display_field` 39 | Defines the field used to display nodes in the Django admin panel. If not set, nodes are shown generically as `"Node "`. 40 | 41 | #### `cls.sorting_field` 42 | Specifies the field used to sort sibling nodes. Defaults to `"priority"`, but can be customized. 43 | 44 | !!! warning 45 | The `priority` field exists on each model instance. Although it is accessible, you should **never** read or set its value directly. 46 | 47 | Due to internal caching mechanisms, the visible value might not match the actual database value. Using `refresh_from_db()` is expensive and clears model caches. Instead, always use the `get_priority()` and `set_priority()` methods. 48 | 49 | If you specify another field, for example `name`, the tree will be sorted by that field alphabetically: 50 | 51 | ```python 52 | class Category(TreeNodeModel): 53 | sorting_field = "name" 54 | ... 55 | ``` 56 | 57 | #### `cls.sorting_direction` 58 | An optional attribute that controls the default sorting order (ascending or descending) for siblings. 59 | 60 | It must be set to a value from the [**SortingChoices**](#sortingchoices-class) class. 61 | 62 | Other internal attributes should not be modified directly. Use the provided public methods instead. 63 | 64 | 65 | ### `cls.api_login_required` 66 | Each model in the tree can define the `api_login_required` class attribute. The `True` value enables [API access control for each model](apifirst.md#) via Django's login system (`login_required`). In this case, all API endpoints for the model will require the user to be logged in. 67 | 68 | ```python 69 | class Category(TreeNodeModel): 70 | api_login_required = False 71 | ... 72 | ``` 73 | 74 | !!! warning 75 | If `api_login_required` attribute is not explicitly defined, the API for the model **remains open** by default. 76 | In production environments, **API endpoints must not remain open**. 77 | 78 | --- 79 | 80 | ### Built-in Classes 81 | 82 | #### SortingChoices Class 83 | 84 | `SortingChoices` is a built-in helper class defined inside `TreeNodeModel`. It provides clear constants for sorting direction: 85 | 86 | | Value | Meaning | 87 | |:------|:--------| 88 | | `SortingChoices.ASC` | Sort ascending (lowest to highest priority). Default. | 89 | | `SortingChoices.DESC` | Sort descending (highest to lowest priority). | 90 | 91 | You can reference it inside your model: 92 | 93 | ```python 94 | class Category(TreeNodeModel): 95 | sorting_direction = SortingChoices.DESC 96 | ``` 97 | 98 | Since `SortingChoices` is attached to the model class, no separate import is required. 99 | 100 | --- 101 | 102 | #### Meta Class 103 | 104 | When extending `TreeNodeModel`, always inherit from `TreeNodeModel.Meta` to preserve essential database indexing. 105 | 106 | To add your own indexes: 107 | 108 | ```python 109 | class Category(TreeNodeModel): 110 | treenode_display_field = "name" 111 | name = models.CharField(max_length=50) 112 | 113 | class Meta(TreeNodeModel.Meta): 114 | verbose_name = "Category" 115 | verbose_name_plural = "Categories" 116 | 117 | indexes = list(TreeNodeModel._meta.indexes) + [ 118 | models.Index(fields=["name"]), 119 | ] 120 | ``` 121 | 122 | This approach ensures full Django integration, efficient queries, and correct hierarchical behavior. 123 | 124 | --- 125 | 126 | ### Model Extending 127 | 128 | Extending `TreeNodeModel` is flexible and straightforward. You can freely add custom fields and methods. 129 | 130 | Example: 131 | 132 | ```python 133 | class Category(TreeNodeModel): 134 | treenode_display_field = "name" 135 | sorting_field = "name" 136 | sorting_direction = SortingChoices.DESC 137 | 138 | name = models.CharField(max_length=50) 139 | code = models.CharField(max_length=5) 140 | 141 | class Meta(TreeNodeModel.Meta): 142 | verbose_name = "Category" 143 | verbose_name_plural = "Categories" 144 | 145 | def get_full_string(self): 146 | return f"{self.code} - {self.name}" 147 | ``` 148 | 149 | The framework places no restrictions on functionality expansion. 150 | 151 | --- 152 | 153 | ### Model Manager 154 | 155 | When creating a custom model manager, always extend `TreeNodeModelManager` instead of modifying internal components like `TreeNodeQuerySet`. 156 | 157 | Example of a safe custom manager: 158 | 159 | ```python 160 | from treenode.managers import TreeNodeModelManager 161 | 162 | class CustomTreeNodeManager(TreeNodeModelManager): 163 | def active(self): 164 | """Return only active nodes.""" 165 | return self.get_queryset().filter(is_active=True) 166 | ``` 167 | 168 | Or if you want to override `get_queryset()` itself: 169 | 170 | ```python 171 | class CustomTreeNodeManager(TreeNodeModelManager): 172 | def get_queryset(self): 173 | return super().get_queryset().filter(is_active=True) 174 | ``` 175 | 176 | Following these principles ensures your managers remain **safe**, **future-proof**, and **compatible** with the tree structure. 177 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs 2 | mkdocs-material 3 | mkdocs-macros-plugin 4 | mkdocstrings 5 | pymdown-extensions -------------------------------------------------------------------------------- /docs/roadmap.md: -------------------------------------------------------------------------------- 1 | ## Roadmap 2 | 3 | The **`django-fast-treenode`** package will continue to evolve from its original concept—combining the benefits of hybrid models into a high-performance solution for managing and visualizing hierarchical data in Django projects. 4 | 5 | The focus is on **speed, usability, and flexibility**. 6 | 7 | #### Roadmap for 3.x Series 8 | 9 | The 3.x release series will focus on strengthening TreeNode Framework in terms of security, usability, and performance scalability, while maintaining backward compatibility and architectural cleanliness. 10 | 11 | * **Version 3.1 — Background Task Worker (Production Mode)** 12 | 13 | - Introduce a centralized queue manager with a multiprocessing or Redis-based backend. 14 | - Add a built-in worker process for safe and efficient task execution in production environments. 15 | - Provide a fallback auto-run mode for DEBUG environments (using `atexit` or thread-based handler). 16 | - Ensure task queue consistency across multiple WSGI workers or scripts. 17 | 18 | * **Version 3.2 — JWT Authentication for API** 19 | 20 | - Introduce optional JWT-based token authentication for the auto-generated API. 21 | - Allow easy activation through a single setting (`TREENODE_API_USE_JWT = True`). 22 | - Preserve backward compatibility: API remains open unless explicitly protected. 23 | - Foundation for future security features (e.g., user-specific trees, audit trails). 24 | 25 | * **Version 3.3 — Admin Usability Improvements** 26 | 27 | Focus: enhance operational safety and optimize workflows for large-scale trees. 28 | 29 | - **Safe Import Preview**: Implement a staging layer for imports, allowing users to review and confirm imported data before committing changes. 30 | - **Incremental Export**: Support selective export of nodes modified after a specified date or revision marker. 31 | 32 | * **Version 3.4 — Soft Deletion Support** 33 | 34 | Focus: improve real-world resilience without sacrificing performance. 35 | 36 | - Add optional support for "soft delete" behavior (`is_deleted` field). 37 | - Modify core queries and cache invalidation logic to respect soft-deleted nodes. 38 | - Add a new task type to the internal task queue system for bulk logical deletions. 39 | 40 | * **Version 3.5 — Cache System Enhancements** 41 | 42 | Focus: lay the foundation for scaling Treenode Framework to extreme node counts (>100,000 nodes). 43 | 44 | - General improvements to the in-memory cache system. 45 | - Research and implement better object size tracking for memory management. 46 | - Explore disk-based, Redis-based, or hybrid caching strategies for massive trees. 47 | 48 | Each step in the 3.x roadmap is intended to strengthen the framework's key principles: **security, usability, scalability, simplicity**. 49 | 50 | #### Long-Term Vision 51 | 52 | * **Version 4.0 – Improved Architecture** 53 | 54 | The main debut idea of version 4.0 is to further develop the hybrid approach. This version will implement a new architectural solution that is designed to increase the speed of selecting descendants, and therefore moving subtrees, and remove the existing restrictions on the maximum nesting depth of the tree (currently the recommended value when using up to 1000 levels). 55 | 56 | - Speed up the operation of extracting descendants. 57 | - Speed up operations for moving large subtrees. 58 | - Performance optimization when working with trees that have extreme depth (more than 2000 levels). 59 | 60 | * **Version 5.0 – Beyond Django ORM** 61 | 62 | Decoupling tree structure management from Django’s ORM to increase flexibility and adaptability. 63 | 64 | - **Multi-Backend Storage Support**: Introduce support for alternative storage engines beyond Django ORM, such as SQLAlchemy, custom PostgreSQL functions, and other database frameworks. 65 | - **Redis Integration for In-Memory Trees**: Implement an optional Redis-based tree storage system, allowing high-speed in-memory hierarchy operations. 66 | - **JSON-Based Storage Option**: Enable lightweight embedded tree storage using JSON structures, facilitating easier use in API-driven and microservice architectures. 67 | - **ORM-Agnostic API Layer**: Design an API-first approach that allows tree structures to function independently from Django models, making the package usable in broader contexts. 68 | 69 | So, each milestone is designed to improve performance, scalability, and flexibility, ensuring that the package remains relevant for modern web applications, API-driven architectures, and high-performance data processing environments support. 70 | 71 | Stay tuned for updates! 72 | 73 | Your wishes, objections, and comments are welcome. 74 | -------------------------------------------------------------------------------- /docs/using.md: -------------------------------------------------------------------------------- 1 | ## Using the TreeNode Framework API 2 | 3 | The **Treetode Framework** is built for high efficiency. But to unlock its full potential, you need to think like an engineer, not just a coder. Working in logical blocks rather than isolated actions ensures maximum performance and minimal database load. 4 | 5 | **Quick Strategy**: First INSET or MOVE everything that needs to be → then READ 6 | 7 | #### Work in Logical Blocks 8 | 9 | Try to group related operations into blocks instead of interleaving reads and writes. 10 | 11 | For example: 12 | 13 | - Insert multiple nodes **first**, then **read** from the tree. 14 | - Perform a series of moves **first**, then **query** the updated structure. 15 | 16 | Since the framework uses a lazy update mechanism, reading from the tree automatically triggers all pending updates through the internal task optimizer. 17 | 18 | This means you **pay the update cost only once**, rather than after every operation. 19 | 20 | #### Insert Nodes in Batches 21 | 22 | When adding many nodes: 23 | 24 | - Create and save them sequentially. 25 | - Avoid reading from the tree between inserts unless necessary. 26 | - After all insertions, perform a single read (e.g., fetching children or descendants). 27 | 28 | This allows the framework to optimize and execute updates in a single efficient batch. 29 | 30 | #### Move Nodes Before Reading 31 | 32 | When reorganizing the tree: 33 | 34 | - Perform all move operations first. 35 | - Then trigger any read operation like `cls.objects.filter()`, `get_ancestors()`, etc. 36 | 37 | Reading will automatically apply and optimize all pending changes in one pass. 38 | 39 | #### Trust the Task Optimizer 40 | 41 | The internal task optimizer automatically: 42 | 43 | - Merges compatible operations. 44 | - Reorders tasks when possible for minimal SQL load. 45 | - Skips redundant updates if no real changes occurred. 46 | 47 | There is no need to manually *"flush"* or *"commit"* tasks — reading from the tree will handle everything automatically. 48 | 49 | Following these practices will ensure: 50 | 51 | - Maximum speed when building or reorganizing large trees. 52 | - Minimal database overhead. 53 | - Smooth and predictable performance, even with tens of thousands of nodes. 54 | 55 | Use the framework's lazy update design to your advantage — **think in terms of blocks, not individual operations.** -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: django-fast-treenode 2 | repo_url: https://github.com/TimurKady/django-fast-treenode 3 | repo_name: django-fast-treenode 4 | docs_dir: docs 5 | 6 | theme: 7 | name: readthedocs 8 | palette: 9 | scheme: slate 10 | features: 11 | - navigation.instant 12 | - navigation.tracking 13 | - navigation.expand 14 | - search.highlight 15 | - search.share 16 | 17 | nav: 18 | - Home: index.md 19 | - About Treenode Framework: about.md 20 | - Installation: 21 | - Installation & Configuration: installation.md 22 | - Migration Guide: migration.md 23 | - Models: 24 | - Model Inheritance & Extensions: models.md 25 | - API Reference: api.md 26 | - Using API: using.md 27 | - Admin Classes: 28 | - Working with Admin Classes: admin.md 29 | - Drag-n-drop: dnd.md 30 | - Import & Export: import_export.md 31 | - Advanced: 32 | - API-First support: apifirst.md 33 | - Caching: cache.md 34 | - Customization Guide: customization.md 35 | - Development: 36 | - Roadmap: roadmap.md 37 | 38 | 39 | markdown_extensions: 40 | - toc: 41 | permalink: true 42 | - pymdownx.superfences 43 | - pymdownx.highlight 44 | - pymdownx.inlinehilite 45 | - pymdownx.details 46 | - pymdownx.keys 47 | - pymdownx.emoji 48 | - admonition 49 | 50 | plugins: 51 | - search 52 | - mkdocstrings 53 | 54 | extra: 55 | social: 56 | - icon: fontawesome/brands/github 57 | link: https://github.com/TimurKady/django-fast-treenode 58 | 59 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-fast-treenode" 7 | version = "3.0.8" 8 | description = "Treenode Framework for supporting tree (hierarchical) data structure in Django projects" 9 | readme = "README.md" 10 | authors = [{ name = "Timur Kady", email = "timurkady@yandex.com" }] 11 | license = { file = "LICENSE" } 12 | requires-python = ">=3.9" 13 | dependencies = [ 14 | 'Django>=5.0', 15 | 'msgpack>=1.0.0', 16 | 'openpyxl>=3.0.0', 17 | 'pyyaml>=5.1', 18 | ] 19 | classifiers = [ 20 | 'Development Status :: 5 - Production/Stable', 21 | 'Intended Audience :: Developers', 22 | 'Programming Language :: Python :: 3', 23 | 'Programming Language :: Python :: 3.9', 24 | 'Programming Language :: Python :: 3.10', 25 | 'Programming Language :: Python :: 3.11', 26 | 'Programming Language :: Python :: 3.12', 27 | 'Programming Language :: Python :: 3.13', 28 | 'Programming Language :: Python :: 3.14', 29 | 'Framework :: Django', 30 | 'Framework :: Django :: 4.0', 31 | 'Framework :: Django :: 4.1', 32 | 'Framework :: Django :: 4.2', 33 | 'Framework :: Django :: 5.0', 34 | 'Framework :: Django :: 5.1', 35 | 'Framework :: Django :: 5.2', 36 | 'License :: OSI Approved :: MIT License', 37 | 'Operating System :: OS Independent', 38 | ] 39 | 40 | [project.urls] 41 | Homepage = "https://github.com/TimurKady/django-fast-treenode" 42 | Documentation = "https://django-fast-treenode.readthedocs.io/" 43 | Source = "https://github.com/TimurKady/django-fast-treenode" 44 | Issues = "https://github.com/TimurKady/django-fast-treenode/issues" 45 | 46 | 47 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=3.2 2 | msgpack>=1.0.0 3 | openpyxl>=3.0.0 4 | pyyaml>=5.1 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='django-fast-treenode', 5 | version='3.0.8', 6 | description='Treenode Framework for supporting tree (hierarchical) data structure in Django projects', 7 | long_description=open('README.md', encoding='utf-8').read(), 8 | long_description_content_type='text/markdown', 9 | author='Timur Kady', 10 | author_email='timurkady@yandex.com', 11 | url='https://django-fast-treenode.readthedocs.io/', 12 | packages=find_packages(exclude=('tests', 'tests.*')), 13 | include_package_data=True, 14 | license='MIT', 15 | license_files=['LICENSE'], 16 | install_requires=[ 17 | 'Django>=5.0', 18 | 'msgpack>=1.0.0', 19 | 'openpyxl>=3.0.0', 20 | 'pyyaml>=5.1', 21 | ], 22 | classifiers=[ 23 | 'Development Status :: 5 - Production/Stable', 24 | 'Intended Audience :: Developers', 25 | 'Programming Language :: Python :: 3', 26 | 'Programming Language :: Python :: 3.9', 27 | 'Programming Language :: Python :: 3.10', 28 | 'Programming Language :: Python :: 3.11', 29 | 'Programming Language :: Python :: 3.12', 30 | 'Programming Language :: Python :: 3.13', 31 | 'Programming Language :: Python :: 3.14', 32 | 'Framework :: Django', 33 | 'Framework :: Django :: 4.0', 34 | 'Framework :: Django :: 4.1', 35 | 'Framework :: Django :: 4.2', 36 | 'Framework :: Django :: 5.0', 37 | 'Framework :: Django :: 5.1', 38 | 'Framework :: Django :: 5.2', 39 | 'License :: OSI Approved :: MIT License', 40 | 'Operating System :: OS Independent', 41 | ], 42 | python_requires='>=3.9', 43 | ) 44 | 45 | 46 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django Fast Treenode Complex Test. 3 | 4 | """ 5 | -------------------------------------------------------------------------------- /tests/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | class TestsConfig(AppConfig): 4 | default_auto_field = 'django.db.models.BigAutoField' 5 | name = 'tests' -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from treenode.models import TreeNodeModel 3 | 4 | 5 | class TestModel(TreeNodeModel): 6 | """Test model for checking the operation of TreeNode.""" 7 | 8 | name = models.CharField(max_length=255, unique=True) 9 | treenode_display_field = "name" 10 | 11 | class Meta: 12 | verbose_name = "TestModel" 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-django 3 | Django>=4.2 -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | BASE_DIR = Path(__file__).resolve().parent.parent 5 | 6 | SECRET_KEY = "test-secret-key" 7 | DEBUG = True 8 | ALLOWED_HOSTS = ["*"] 9 | 10 | INSTALLED_APPS = [ 11 | "django.contrib.contenttypes", 12 | "django.contrib.auth", 13 | "django.contrib.sessions", 14 | "django.contrib.staticfiles", 15 | "treenode", 16 | 'tests.apps.TestsConfig', 17 | ] 18 | 19 | MIDDLEWARE = [ 20 | "django.middleware.common.CommonMiddleware", 21 | ] 22 | 23 | 24 | DATABASES = { 25 | "default": { 26 | "ENGINE": "django.db.backends.postgresql", 27 | "NAME": os.environ.get("POSTGRES_DB", "testdb"), 28 | "USER": os.environ.get("POSTGRES_USER", "postgres"), 29 | "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "postgres"), 30 | "HOST": os.environ.get("POSTGRES_HOST", "localhost"), 31 | "PORT": os.environ.get("POSTGRES_PORT", "5432"), 32 | } 33 | } 34 | 35 | 36 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Избавляемся от варнинга 37 | 38 | CACHES = { 39 | "default": { 40 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 41 | } 42 | } 43 | 44 | TREENODE_CACHE_LIMIT = 100 # Оптимизация кеша дерева 45 | 46 | LANGUAGE_CODE = "en-us" 47 | TIME_ZONE = "UTC" 48 | USE_TZ = True 49 | 50 | 51 | -------------------------------------------------------------------------------- /tests/test_suite.py: -------------------------------------------------------------------------------- 1 | # tests/test_treenode.py 2 | from django.test import TestCase 3 | from django.db import transaction 4 | from .models import TestModel 5 | 6 | PATH_DIGITS = 3 7 | 8 | 9 | def hex_path(parts): 10 | """Преобразуем массив индексов в нулепад-hex-строку.""" 11 | return ".".join(f"{n:0{PATH_DIGITS}X}" for n in parts) 12 | 13 | 14 | class TreeNodeModelTests(TestCase): 15 | """Проверяем основные операции TreeNodeModel.""" 16 | 17 | @classmethod 18 | def setUpTestData(cls): 19 | """ 20 | Создаём тестовое дерево один раз на весь класс. 21 | Django сделает его снимок, и в каждом тесте база 22 | будет возвращаться в это состояние автоматически. 23 | """ 24 | cls.root = TestModel.objects.create(name="root", priority=0) 25 | cls.a = TestModel.objects.create(name="A", parent=cls.root, priority=1) 26 | cls.b = TestModel.objects.create(name="B", parent=cls.root, priority=2) 27 | cls.c = TestModel.objects.create(name="C", parent=cls.a, priority=1) 28 | cls.d = TestModel.objects.create(name="D", parent=cls.a, priority=2) 29 | 30 | _ = cls.a.get_order() 31 | _ = cls.c.get_order() 32 | 33 | # --- 1. Creating nodes ------------------------------------------------- 34 | 35 | def test_count_after_creation(self): 36 | self.assertEqual(TestModel.objects.count(), 5) 37 | 38 | # --- 2. _path and _depth -------------------------------------------------- 39 | 40 | def test_path_and_depth_saved(self): 41 | with self.subTest("Depth values"): 42 | self.assertEqual(self.a.get_depth(), 1) 43 | self.assertEqual(self.c.get_depth(), 2) 44 | 45 | with self.subTest("Path format"): 46 | self.assertIn(".", self.a.get_order()) 47 | self.assertIn(".", self.c.get_order()) 48 | 49 | # --- 3. Ancestors and Descendants ------------------------------------------------ 50 | 51 | def test_ancestors_and_descendants(self): 52 | TestModel.tasks.add("update", None) 53 | TestModel.tasks.run() 54 | 55 | ancestors = set( 56 | self.c.get_ancestors_queryset().values_list("pk", flat=True) 57 | ) 58 | expected_anc = {self.root.pk, self.a.pk, self.c.pk} 59 | self.assertEqual(ancestors, expected_anc) 60 | 61 | descendants = set( 62 | self.root.get_descendants_queryset(include_self=True) 63 | .values_list("pk", flat=True) 64 | ) 65 | expected_desc = { 66 | self.root.pk, self.a.pk, self.b.pk, self.c.pk, self.d.pk 67 | } 68 | 69 | test = descendants == expected_desc 70 | 71 | self.assertEqual(test, True) 72 | 73 | # --- 4. Moving a node ------------------------------------------------ 74 | 75 | def test_move_node(self): 76 | # перемещаем c под b и проверяем 77 | with transaction.atomic(): 78 | self.c.move_to(self.b) 79 | 80 | self.c.refresh_from_db() 81 | self.assertEqual(self.c.parent_id, self.b.pk) 82 | self.assertEqual(self.c.get_depth(), self.b.get_depth() + 1) 83 | self.assertTrue( 84 | self.c.get_order().startswith(f"{self.b.get_order()}.") 85 | ) 86 | self.assertEqual(self.c.get_order().count("."), self.c.get_depth()) 87 | 88 | # --- 5. Removing a node --------------------------------------------------- 89 | 90 | def test_delete_subtree(self): 91 | self.a.delete(cascade=False) 92 | 93 | tree_data = TestModel.get_tree_json() 94 | 95 | self.root.check_tree_integrity() 96 | qs = TestModel.objects.filter(pk__in=[self.a.pk, self.c.pk]).all() 97 | 98 | self.assertFalse(TestModel.objects.filter(pk=self.a.pk).exists()) 99 | self.assertTrue(TestModel.objects.filter(pk=self.c.pk).exists()) 100 | -------------------------------------------------------------------------------- /treenode/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'treenode.apps.TreeNodeConfig' 2 | -------------------------------------------------------------------------------- /treenode/admin/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .admin import TreeNodeModelAdmin 3 | 4 | __all__ = ["TreeNodeModelAdmin"] 5 | -------------------------------------------------------------------------------- /treenode/admin/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Admin Model Class 4 | 5 | Modified admin panel for django-fast-treenode. Solves the following problems: 6 | - Set list_per_page = 10000 to display all elements at once. 7 | - Hidden standard pagination via CSS 8 | - Disabled counting the total number of elements to speed up loading 9 | - Accordion works regardless of the display mode 10 | Two modes are supported: 11 | - Indented - with indents and icons 12 | - Breadcrumbs - with breadcrumbs 13 | All modes have links to editing objects. 14 | 15 | - Expand buttons for nodes with children 16 | 17 | Additional features: 18 | - Control panel with "Expand All" / "Collapse All" buttons 19 | - Saving the state of the tree between page transitions 20 | - Smooth animations when expanding/collapsing 21 | - Counting the total number of nodes in the tree 22 | - Recursive hiding of grandchildren when collapsing the parent 23 | 24 | Version: 3.1.0 25 | Author: Timur Kady 26 | Email: timurkady@yandex.com 27 | """ 28 | 29 | 30 | from django.contrib import admin 31 | from django.db import models 32 | from django.urls import reverse 33 | from django.utils.html import escape 34 | from django.utils.safestring import mark_safe 35 | from django.utils.translation import gettext_lazy as _ 36 | 37 | from .mixin import AdminMixin 38 | from ..forms import TreeNodeForm 39 | from ..widgets import TreeWidget 40 | from .importer import TreeNodeImporter 41 | from .exporter import TreeNodeExporter 42 | 43 | import logging 44 | 45 | logger = logging.getLogger(__name__) 46 | 47 | 48 | class TreeNodeModelAdmin(AdminMixin, admin.ModelAdmin): 49 | """Admin for TreeNodeModel.""" 50 | 51 | # Режимы отображения 52 | TREENODE_DISPLAY_MODE_ACCORDION = 'accordion' 53 | TREENODE_DISPLAY_MODE_BREADCRUMBS = 'breadcrumbs' 54 | treenode_display_mode = TREENODE_DISPLAY_MODE_ACCORDION 55 | 56 | form = TreeNodeForm 57 | importer_class = None 58 | exporter_class = None 59 | ordering = [] 60 | 61 | formfield_overrides = { 62 | models.ForeignKey: {'widget': TreeWidget()}, 63 | } 64 | 65 | change_list_template = "treenode/admin/treenode_changelist.html" 66 | import_export = True 67 | 68 | class Media: 69 | """Meta Class.""" 70 | 71 | css = {"all": ( 72 | "css/treenode_admin.css", 73 | "vendors/jquery-ui/jquery-ui.css", 74 | )} 75 | js = ( 76 | "vendors/jquery-ui/jquery-ui.js", 77 | # "js/lz-string.min.js", 78 | "js/treenode_admin.js", 79 | ) 80 | 81 | def __init__(self, model, admin_site): 82 | """Init method.""" 83 | super().__init__(model, admin_site) 84 | 85 | if not self.list_display: 86 | self.list_display = [field.name for field in model._meta.fields] 87 | 88 | self.TreeNodeImporter = self.importer_class or TreeNodeImporter 89 | self.TreeNodeExporter = self.exporter_class or TreeNodeExporter 90 | 91 | def drag(self, obj): 92 | """Drag and drop сolumn.""" 93 | return mark_safe('') 94 | 95 | drag.short_description = _("Move") 96 | 97 | def toggle(self, obj): 98 | """Toggle column.""" 99 | if obj.get_children_count() > 0: 100 | return mark_safe( 101 | f'' # noqa 102 | ) 103 | return mark_safe('
 
') 104 | 105 | toggle.short_description = _("Expand") 106 | 107 | def get_changelist(self, request, **kwargs): 108 | """Get changelist.""" 109 | ChangeList = super().get_changelist(request, **kwargs) 110 | 111 | class NoPaginationChangeList(ChangeList): 112 | """Suppress pagination.""" 113 | 114 | def get_results(self, request): 115 | """Get result.""" 116 | super().get_results(request) 117 | self.paginator.show_all = True 118 | self.result_count = len(self.result_list) 119 | self.full_result_count = len(self.result_list) 120 | self.can_show_all = False 121 | self.multi_page = False 122 | self.actions = self.model_admin.get_actions(request) 123 | 124 | return NoPaginationChangeList 125 | 126 | def get_changelist_instance(self, request): 127 | """ 128 | Get changelist instance. 129 | 130 | Make sure our custom ChangeList is used without pagination. 131 | """ 132 | ChangeList = self.get_changelist(request) 133 | 134 | return ChangeList( 135 | request, 136 | self.model, 137 | self.get_list_display(request), 138 | self.get_list_display_links( 139 | request, 140 | self.get_list_display(request) 141 | ), 142 | self.get_list_filter(request), 143 | self.date_hierarchy, 144 | self.search_fields, 145 | self.list_select_related, 146 | self.list_per_page, 147 | self.list_max_show_all, 148 | self.list_editable, 149 | self, 150 | sortable_by=self.get_sortable_by(request), 151 | search_help_text=self.get_search_help_text(request), 152 | ) 153 | 154 | def get_queryset(self, request): 155 | """Get queryset.""" 156 | qs = super().get_queryset(request) 157 | return qs.select_related('parent')\ 158 | .prefetch_related('children')\ 159 | .order_by('_path') 160 | 161 | def get_list_display(self, request): 162 | """Get list_display.""" 163 | def treenode_field(obj): 164 | return self._get_treenode_field_display(request, obj) 165 | 166 | description = str(self.model._meta.verbose_name) 167 | treenode_field.short_description = description 168 | 169 | return (self.drag, self.toggle, treenode_field) 170 | 171 | def get_form(self, request, obj=None, **kwargs): 172 | """Get Form method.""" 173 | form = super().get_form(request, obj, **kwargs) 174 | if "parent" in form.base_fields: 175 | form.base_fields["parent"].widget = TreeWidget() 176 | return form 177 | 178 | def get_search_fields(self, request): 179 | """Get search fields.""" 180 | return [getattr(self.model, 'display_field', 'id') or 'id'] 181 | 182 | def _get_treenode_field_display(self, request, obj): 183 | """ 184 | Generate HTML to display tree nodes. 185 | 186 | Depending on the selected display mode (accordion or breadcrumbs), 187 | do the following: 188 | - For accordion mode: add indents and icons. 189 | - For breadcrumbs mode: display breadcrumb path. 190 | """ 191 | level = obj.get_depth() 192 | display_field = getattr(obj, "display_field", None) 193 | edit_url = reverse( 194 | f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change", 195 | args=[obj.pk] 196 | ) 197 | icon = "" 198 | text = "" 199 | padding = "" 200 | closing = "" 201 | 202 | if self.treenode_display_mode == self.TREENODE_DISPLAY_MODE_ACCORDION: 203 | icon = "📄 " if obj.is_leaf() else "📁 " 204 | text = getattr(obj, display_field, str(obj)) 205 | padding = f'' 206 | closing = "" 207 | elif self.treenode_display_mode == self.TREENODE_DISPLAY_MODE_BREADCRUMBS: # noqa 208 | if display_field: 209 | breadcrumbs = obj.get_breadcrumbs(attr=display_field) 210 | else: 211 | breadcrumbs = [str(item) for item in obj.get_ancestors()] 212 | 213 | text = "/" + "/".join([escape(label) for label in breadcrumbs]) 214 | 215 | content = f'{padding}{icon}{escape(text)}{closing}' # noqa 216 | return mark_safe(content) 217 | 218 | def get_list_per_page(self, request): 219 | """Get list per page.""" 220 | return 999999 221 | 222 | # The End 223 | -------------------------------------------------------------------------------- /treenode/admin/changelist.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Sorted ChangeList Class for TreeNodeModelAdmin. 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from django.contrib.admin.views.main import ChangeList 11 | from django.forms.models import modelformset_factory 12 | from django.db.models import Q 13 | 14 | 15 | class TreeNodeChangeList(ChangeList): 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | 19 | def get_ordering(self, request, queryset): 20 | """ 21 | Override ordering. 22 | 23 | Overrides the sort order of objects in the list. 24 | Django Admin sorts by `-pk` (descending) by default. 25 | This method removes `-pk` so that objects are not sorted by ID. 26 | """ 27 | # Remove the default '-pk' ordering if present. 28 | ordering = list(super().get_ordering(request, queryset)) 29 | if '-pk' in ordering: 30 | ordering.remove('-pk') 31 | return tuple(ordering) 32 | 33 | def get_results(self, request): 34 | super().get_results(request) 35 | model_name = self.model._meta.model_name 36 | 37 | # Добавляем атрибуты к результатам 38 | object_ids = [r.pk for r in self.result_list] 39 | objects_dict = { 40 | obj.pk: obj 41 | for obj in self.model_admin.model.objects.filter(pk__in=object_ids) 42 | } 43 | 44 | for result in self.result_list: 45 | result.obj = objects_dict.get(result.pk) 46 | # Добавляем атрибуты строк 47 | result.row_attrs = f'data-node-id="{result.pk}" data-parent-of="{result.obj.parent_id or ""}" class="model-{model_name} pk-{result.pk}"' 48 | -------------------------------------------------------------------------------- /treenode/admin/exporter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Exporter Module 4 | 5 | This module provides functionality for stream exporting tree-structured data 6 | to various formats, including CSV, JSON, TSV, XLSX, YAML. 7 | 8 | Version: 3.0.0 9 | Author: Timur Kady 10 | Email: timurkady@yandex.com 11 | """ 12 | 13 | import csv 14 | import json 15 | import yaml 16 | from django.core.serializers.json import DjangoJSONEncoder 17 | from django.http import StreamingHttpResponse 18 | from io import BytesIO, StringIO 19 | from openpyxl import Workbook 20 | 21 | 22 | class TreeNodeExporter: 23 | """Exporter for tree-structured data to various formats.""" 24 | 25 | def __init__(self, model, filename="tree_nodes", fileformat="csv"): 26 | """ 27 | Initialize exporter. 28 | 29 | :param queryset: Django QuerySet to export. 30 | :param filename: Base name for the output file. 31 | :param fileformat: Export format (csv, json, xlsx, yaml, tvs). 32 | """ 33 | self.filename = filename 34 | self.format = fileformat 35 | self.model = model 36 | self.queryset = model.objects.get_queryset() 37 | self.fields = self.get_ordered_fields() 38 | 39 | def get_ordered_fields(self): 40 | """ 41 | Define and return the ordered list of fields for export. 42 | 43 | Required fields come first, blocked fields are omitted. 44 | """ 45 | fields = sorted([field.name for field in self.model._meta.fields]) 46 | required_fields = ["id", "parent", "priority"] 47 | blocked_fields = ["_path", "_depth"] 48 | 49 | other_fields = [ 50 | field for field in fields 51 | if field not in required_fields and field not in blocked_fields 52 | ] 53 | return required_fields + other_fields 54 | 55 | def get_obj(self): 56 | """Yield rows from queryset as row data dict.""" 57 | queryset = self.queryset.order_by('_path').only(*self.fields) 58 | for obj in queryset.iterator(): 59 | yield obj 60 | 61 | def get_serializable_row(self, obj): 62 | """Get serialized object.""" 63 | fields = self.fields 64 | raw_data = {} 65 | for field in fields: 66 | if field == "parent": 67 | raw_data["parent"] = getattr(obj, "parent_id", None) 68 | else: 69 | raw_data[field] = getattr(obj, field, None) 70 | serialized = json.loads(json.dumps(raw_data, cls=DjangoJSONEncoder)) 71 | return serialized 72 | 73 | def csv_stream_data(self, delimiter=","): 74 | """Stream CSV or TSV data.""" 75 | yield "\ufeff" # BOM for Excel 76 | buffer = StringIO() 77 | writer = csv.DictWriter( 78 | buffer, 79 | fieldnames=self.fields, 80 | delimiter=delimiter 81 | ) 82 | writer.writeheader() 83 | yield buffer.getvalue() 84 | buffer.seek(0) 85 | buffer.truncate(0) 86 | 87 | for obj in self.get_obj(): 88 | row = self.get_serializable_row(obj) 89 | writer.writerow(row) 90 | yield buffer.getvalue() 91 | buffer.seek(0) 92 | buffer.truncate(0) 93 | 94 | def json_stream_data(self): 95 | """Stream JSON data.""" 96 | yield "[\n" 97 | first = True 98 | for obj in self.get_obj(): 99 | row = self.get_serializable_row(obj) 100 | if not first: 101 | yield ",\n" 102 | else: 103 | first = False 104 | yield json.dumps(row, ensure_ascii=False) 105 | yield "\n]" 106 | 107 | def tsv_stream_data(self, chunk_size=1000): 108 | """Stream TSV (tab-separated values) data.""" 109 | yield from self.csv_stream_data(delimiter="\t") 110 | 111 | def yaml_stream_data(self): 112 | """Stream YAML data.""" 113 | yield "---\n" 114 | for obj in self.get_obj(): 115 | row = self.get_serializable_row(obj) 116 | yield yaml.safe_dump([row], allow_unicode=True) 117 | 118 | def xlsx_stream_data(self): 119 | """Stream XLSX data.""" 120 | wb = Workbook() 121 | ws = wb.active 122 | ws.append(self.fields) 123 | 124 | for obj in self.get_obj(): 125 | row = self.get_serializable_row(obj) 126 | ws.append([row.get(f, "") for f in self.fields]) 127 | 128 | output = BytesIO() 129 | wb.save(output) 130 | output.seek(0) 131 | yield output.getvalue() 132 | 133 | def process_record(self): 134 | """ 135 | Create a StreamingHttpResponse based on selected format. 136 | 137 | :param chunk_size: Batch size for iteration. 138 | :return: StreamingHttpResponse object. 139 | """ 140 | if self.format == 'xlsx': 141 | response = StreamingHttpResponse( 142 | streaming_content=self.xlsx_stream_data(), 143 | content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; charset=utf-8" # noqa: D501 144 | ) 145 | elif self.format == 'tsv': 146 | response = StreamingHttpResponse( 147 | streaming_content=self.csv_stream_data(delimiter="\t"), 148 | content_type="text/tab-separated-values; charset=utf-8" 149 | ) 150 | elif self.format == 'csv': 151 | response = StreamingHttpResponse( 152 | streaming_content=self.csv_stream_data(delimiter=","), 153 | content_type="text/csv; charset=utf-8" 154 | ) 155 | elif self.format == 'yaml': 156 | response = StreamingHttpResponse( 157 | streaming_content=self.yaml_stream_data(), 158 | content_type=f"application/{self.format}; charset=utf-8" 159 | ) 160 | else: 161 | response = StreamingHttpResponse( 162 | streaming_content=self.json_stream_data(), 163 | content_type=f"application/{self.format}; charset=utf-8" 164 | ) 165 | 166 | response['Content-Disposition'] = f'attachment; filename="{self.filename}"' # noqa: D501 167 | return response 168 | 169 | 170 | # The End 171 | -------------------------------------------------------------------------------- /treenode/admin/importer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Importer Module 4 | 5 | This module provides functionality for importing tree-structured data 6 | from various formats, including CSV, JSON, XLSX, YAML, and TSV. 7 | 8 | Features: 9 | - Supports field mapping and data type conversion for model compatibility. 10 | - Handles ForeignKey relationships and ManyToMany fields. 11 | - Validates and processes raw data before saving to the database. 12 | - Uses bulk operations for efficient data insertion and updates. 13 | - Supports transactional imports to maintain data integrity. 14 | 15 | Version: 3.0.0 16 | Author: Timur Kady 17 | Email: timurkady@yandex.com 18 | """ 19 | 20 | # Новый модуль импорта для древовидных структур в Django 21 | 22 | import csv 23 | import json 24 | import yaml 25 | import openpyxl 26 | from io import BytesIO, StringIO 27 | from django.db import transaction 28 | from django.core.exceptions import ValidationError, ObjectDoesNotExist 29 | 30 | from ..cache import treenode_cache as cache 31 | 32 | 33 | class TreeNodeImporter: 34 | """Importer of tree data from various formats.""" 35 | 36 | def __init__(self, model, file, file_format): 37 | """Init.""" 38 | self.model = model 39 | self.file = file 40 | self.format = file_format.lower() 41 | self.rows = [] 42 | self.rows_by_id = {} 43 | self.result = {"created": 0, "updated": 0, "errors": []} 44 | 45 | def parse(self): 46 | """Parse a file.""" 47 | if self.format == "xlsx": 48 | content = self.file.read() # binary 49 | self.rows = self._parse_xlsx(content) 50 | else: 51 | text = self.file.read() 52 | if isinstance(text, bytes): 53 | text = text.decode("utf-8") 54 | 55 | if self.format == "csv": 56 | self.rows = list(csv.DictReader(StringIO(text))) 57 | elif self.format == "tsv": 58 | self.rows = list(csv.DictReader(StringIO(text), delimiter="\t")) 59 | elif self.format == "json": 60 | self.rows = json.loads(text) 61 | elif self.format == "yaml": 62 | self.rows = yaml.safe_load(text) 63 | else: 64 | raise ValueError("Unsupported file format") 65 | 66 | self._build_hierarchy() 67 | 68 | def _parse_xlsx(self, content): 69 | """Parse the xlsx format.""" 70 | wb = openpyxl.load_workbook(BytesIO(content), read_only=True) 71 | ws = wb.active 72 | headers = [cell.value for cell in next( 73 | ws.iter_rows(min_row=1, max_row=1))] 74 | return [ 75 | dict(zip(headers, row)) 76 | for row in ws.iter_rows(min_row=2, values_only=True) 77 | ] 78 | 79 | def _build_hierarchy(self): 80 | """ 81 | Build and check hierarchy. 82 | 83 | Calculates _depth and _path based on parent and priority. Checks that 84 | each parent exists in either the imported set or the base. The _path 85 | is built based on priority, as in the main package. 86 | """ 87 | self.rows_by_id = {str(row.get("id")): row for row in self.rows} 88 | 89 | def build_path_and_depth(row, visited=None): 90 | if visited is None: 91 | visited = set() 92 | row_id = str(row.get("id")) 93 | if row_id in visited: 94 | raise ValueError(f"Cycle detected at row {row_id}") 95 | visited.add(row_id) 96 | 97 | parent_id = str(row.get("parent")) if row.get("parent") else None 98 | if not parent_id: 99 | row["_depth"] = 0 100 | row["_path"] = str(row.get("priority", "0")).zfill(4) 101 | return row["_path"] 102 | 103 | parent_row = self.rows_by_id.get(parent_id) 104 | if parent_row: 105 | parent_path = build_path_and_depth(parent_row, visited) 106 | else: 107 | try: 108 | self.model.objects.get(pk=parent_id) 109 | parent_path = "fromdb" 110 | except ObjectDoesNotExist: 111 | self.result["errors"].append( 112 | f"Parent {parent_id} for node {row_id} not found.") 113 | parent_path = "invalid" 114 | 115 | row["_depth"] = parent_path.count( 116 | ".") + 1 if parent_path != "invalid" else 0 117 | priority = str(row.get("priority", "0")).zfill(4) 118 | row["_path"] = parent_path + "." + \ 119 | priority if parent_path != "invalid" else priority 120 | return row["_path"] 121 | 122 | for row in self.rows: 123 | try: 124 | build_path_and_depth(row) 125 | except Exception as e: 126 | self.result["errors"].append(str(e)) 127 | 128 | def import_tree(self): 129 | """Import tree nodes level by level.""" 130 | with transaction.atomic(): 131 | rows_by_level = {} 132 | for row in self.rows: 133 | level = row.get("_depth", 0) 134 | rows_by_level.setdefault(level, []).append(row) 135 | 136 | id_map = {} 137 | for depth in sorted(rows_by_level.keys()): 138 | to_create = [] 139 | for row in rows_by_level[depth]: 140 | pk = row.get("id") 141 | 142 | if "parent" in row and (row["parent"] == "" or row["parent"] is None): 143 | row["parent"] = None 144 | 145 | if "parent" in row: 146 | temp_parent_id = row.pop("parent") 147 | if temp_parent_id is not None: 148 | # Используем уже созданный ID родителя 149 | row["parent_id"] = id_map.get( 150 | temp_parent_id, temp_parent_id) 151 | 152 | try: 153 | obj = self.model(**row) 154 | obj.full_clean() 155 | to_create.append(obj) 156 | except ValidationError as e: 157 | self.result["errors"].append( 158 | f"Validation error for {pk}: {e}") 159 | 160 | created = self.model.objects.bulk_create(to_create) 161 | for obj in created: 162 | id_map[obj.pk] = obj.pk 163 | self.result["created"] += len(created) 164 | 165 | self.model.tasks.add("update", None) 166 | cache.invalidate(self.model._meta.label) 167 | 168 | return self.result 169 | 170 | 171 | # The End 172 | -------------------------------------------------------------------------------- /treenode/apps.py: -------------------------------------------------------------------------------- 1 | """ 2 | TreeNode configuration definition module. 3 | 4 | Customization: 5 | - checks the correctness of the sorting fields 6 | - checks the correctness of model inheritance 7 | - starts asynchronous loading of node data into the cache 8 | 9 | Version: 3.0.0 10 | Author: Timur Kady 11 | Email: timurkady@yandex.com 12 | """ 13 | 14 | from django.apps import apps, AppConfig 15 | 16 | 17 | class TreeNodeConfig(AppConfig): 18 | """Config Class.""" 19 | 20 | default_auto_field = "django.db.models.BigAutoField" 21 | name = "treenode" 22 | 23 | def ready(self): 24 | """Ready method.""" 25 | from .models import TreeNodeModel 26 | 27 | # Models checking 28 | subclasses = [ 29 | m for m in apps.get_models() 30 | if issubclass(m, TreeNodeModel) and m is not TreeNodeModel 31 | ] 32 | 33 | for model in subclasses: 34 | 35 | field_names = {f.name for f in model._meta.get_fields()} 36 | 37 | # Check display_field is correct 38 | if model.display_field is not None: 39 | if model.display_field not in field_names: 40 | raise ValueError( 41 | f'Invalid display_field "{model.display_field}. "' 42 | f'Available fields: {field_names}') 43 | 44 | # Check sorting_field is correct 45 | if model.sorting_field is not None: 46 | if model.sorting_field not in field_names: 47 | raise ValueError( 48 | f'Invalid sorting_field "{model.sorting_field}. "' 49 | f'Available fields: {field_names}') 50 | 51 | # Check if Meta is a descendant of TreeNodeModel.Meta 52 | if not issubclass(model.Meta, TreeNodeModel.Meta): 53 | raise ValueError( 54 | f'{model.__name__} must inherit Meta class ' + 55 | 'from TreeNodeModel.Meta.' 56 | ) 57 | -------------------------------------------------------------------------------- /treenode/forms.py: -------------------------------------------------------------------------------- 1 | """ 2 | TreeNode Form Module. 3 | 4 | This module defines the TreeNodeForm class, which dynamically determines 5 | the TreeNode model. 6 | It utilizes TreeWidget and automatically excludes the current node and its 7 | descendants from the parent choices. 8 | 9 | Functions: 10 | - __init__: Initializes the form and filters out invalid parent choices. 11 | - factory: Dynamically creates a form class for a given TreeNode model. 12 | 13 | Version: 3.0.0 14 | Author: Timur Kady 15 | Email: timurkady@yandex.com 16 | """ 17 | 18 | from django.forms import ModelForm 19 | from django.forms.models import ModelChoiceField, ModelChoiceIterator 20 | from django.utils.translation import gettext_lazy as _ 21 | 22 | from .widgets import TreeWidget 23 | 24 | 25 | class SortedModelChoiceIterator(ModelChoiceIterator): 26 | """Iterator Class for ModelChoiceField.""" 27 | 28 | def __iter__(self): 29 | """Return sorted choices based on tn_order.""" 30 | qs_list = list(self.queryset.order_by('_path').all()) 31 | 32 | # Iterate yield (value, label) pairs. 33 | for obj in qs_list: 34 | yield ( 35 | self.field.prepare_value(obj), 36 | self.field.label_from_instance(obj) 37 | ) 38 | 39 | 40 | class SortedModelChoiceField(ModelChoiceField): 41 | """ModelChoiceField Class for tn_paret field.""" 42 | 43 | to_field_name = None 44 | 45 | def _get_choices(self): 46 | if hasattr(self, '_choices'): 47 | return self._choices 48 | 49 | choices = list(SortedModelChoiceIterator(self)) 50 | if self.empty_label is not None: 51 | choices.insert(0, ("", self.empty_label)) 52 | return choices 53 | 54 | def _set_choices(self, value): 55 | self._choices = value 56 | 57 | choices = property(_get_choices, _set_choices) 58 | 59 | 60 | class TreeNodeForm(ModelForm): 61 | """TreeNodeModelAdmin Form Class.""" 62 | 63 | def __init__(self, *args, **kwargs): 64 | """Init Form.""" 65 | super(TreeNodeForm, self).__init__(*args, **kwargs) 66 | self.model = self.instance._meta.model 67 | 68 | if 'parent' not in self.fields: 69 | return 70 | 71 | exclude_pks = [] 72 | if self.instance.pk: 73 | exclude_pks = self.instance.query( 74 | objects='descendants', 75 | include_self=True 76 | ) 77 | 78 | queryset = self.model.objects\ 79 | .exclude(pk__in=exclude_pks)\ 80 | .order_by('_path')\ 81 | .all() 82 | 83 | self.fields['parent'].queryset = queryset 84 | self.fields["parent"].required = False 85 | self.fields["parent"].empty_label = _("Root") 86 | 87 | original_field = self.fields["parent"] 88 | 89 | self.fields["parent"] = SortedModelChoiceField( 90 | queryset=queryset, 91 | label=original_field.label, 92 | widget=original_field.widget, 93 | empty_label=original_field.empty_label, 94 | required=False 95 | ) 96 | self.fields["parent"].widget.model = self.model 97 | 98 | # If there is a current value, set it 99 | if self.instance and self.instance.pk and self.instance.parent: 100 | self.fields["parent"].initial = self.instance.parent 101 | 102 | class Meta: 103 | widgets = { 104 | 'parent': TreeWidget(), 105 | } 106 | 107 | # The End 108 | -------------------------------------------------------------------------------- /treenode/managers/__init__.py: -------------------------------------------------------------------------------- 1 | from .managers import TreeNodeManager 2 | from .queries import TreeQueryManager 3 | from .tasks import TreeTaskManager 4 | 5 | __all__ = ['TreeNodeManager', 'TreeQueryManager', 'TreeTaskManager'] 6 | -------------------------------------------------------------------------------- /treenode/managers/managers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Manager and Query Set Customization Module 4 | 5 | This module defines custom managers and query sets for the TreeNodeModel. 6 | It includes operations for synchronizing additional fields associated with 7 | the Materialized Path method implementation. 8 | 9 | Version: 3.0.0 10 | Author: Timur Kady 11 | Email: timurkady@yandex.com 12 | """ 13 | 14 | from django.db import models # , transaction 15 | from django.utils.translation import gettext_lazy as _ 16 | import logging 17 | 18 | from ..cache import treenode_cache as cache 19 | 20 | 21 | class TreeNodeQuerySet(models.QuerySet): 22 | """TreeNodeModel QuerySet.""" 23 | 24 | def create(self, **kwargs): 25 | """Create an object.""" 26 | obj = self.model(**kwargs) 27 | obj.save() 28 | return obj 29 | 30 | def get_or_create(self, defaults=None, **kwargs): 31 | """Get or create an object.""" 32 | defaults = defaults or {} 33 | created = False 34 | 35 | try: 36 | obj = super().get(**kwargs) 37 | except models.DoesNotExist: 38 | params = {k: v for k, v in kwargs.items() if "__" not in k} 39 | params.update( 40 | {k: v() if callable(v) else v for k, v in defaults.items()} 41 | ) 42 | obj = self.model(**params) 43 | obj.save() 44 | created = True 45 | return obj, created 46 | 47 | def update(self, **kwargs): 48 | """Update method for TreeNodeQuerySet. 49 | 50 | If kwargs contains updates for 'parent' or 'priority', 51 | then specialized bulk_update logic is used, which: 52 | - Updates allowed fields directly; 53 | - If parent is updated, calls _bulk_move (updates _path and parent); 54 | - If priority is updated (without parent), updates sibling order. 55 | Otherwise, the standard update is called. 56 | """ 57 | forbidden = {'_path', '_depth'} 58 | if forbidden.intersection(kwargs.keys()): 59 | raise ValueError( 60 | _(f"Fields cannot be updated directly: {', '.join(forbidden)}") 61 | ) 62 | 63 | result = 0 64 | excluded_fields = {"parent", "priority", "_path", "_depth"} 65 | params = {key: value for key, 66 | value in kwargs.items() if key not in excluded_fields} 67 | 68 | if params: 69 | # Normal update 70 | result = super().update(**params) 71 | 72 | cache.invalidate(self.model._meta.label) 73 | return result 74 | 75 | def update_or_create(self, defaults=None, **kwargs): 76 | """Update or create an object.""" 77 | params = {**(defaults or {}), **kwargs} 78 | created = False 79 | try: 80 | obj = super().get(**kwargs) 81 | obj.update(**params) 82 | except models.DoesNotExist: 83 | obj = self.model(**params) 84 | obj.save() 85 | created = True 86 | return obj, created 87 | 88 | def _raw_update(self, **kwargs): 89 | """ 90 | Bypass custom update() logic (e.g. field protection). 91 | 92 | WARNING: Unsafe low-level update bypassing all TreeNode protections. 93 | Use only when bypassing _path/_depth/priority safety checks is 94 | intentional. 95 | """ 96 | result = models.QuerySet(self.model, using=self.db).update(**kwargs) 97 | return result 98 | 99 | # batch_size=None, **kwargs): 100 | def _raw_bulk_update(self, objs, fields, *args, **kwargs): 101 | """ 102 | Bypass custom bulk_update logic (e.g. field protection). 103 | 104 | WARNING: Unsafe low-level update bypassing all TreeNode protections. 105 | Use only when bypassing _path/_depth/priority safety checks is 106 | intentional. 107 | """ 108 | base_qs = models.QuerySet(self.model, using=self.db) 109 | result = base_qs.bulk_update(objs, fields, *args, **kwargs) 110 | 111 | return result 112 | 113 | def _raw_delete(self, using=None): 114 | return models.QuerySet(self.model, using=using or self.db)\ 115 | ._raw_delete(using=using or self.db) 116 | 117 | def __iter__(self): 118 | """Iterate queryset.""" 119 | try: 120 | if len(self.model.tasks.queue) > 0: 121 | # print("🌲 TreeNodeQuerySet: auto-run (iter)") 122 | self.model.tasks.run() 123 | except Exception as e: 124 | logging.error("⚠️ Tree flush failed silently (iter): %s", e) 125 | return super().__iter__() 126 | 127 | def _fetch_all(self): 128 | """Extract data for a queryset from the database.""" 129 | try: 130 | tasks = self.model.tasks 131 | if len(tasks.queue) > 0: 132 | # print("🌲 TreeNodeQuerySet: auto-run (_fetch_all)") 133 | tasks.run() 134 | except Exception as e: 135 | logging.error("⚠️ Tree flush failed silently: %s", e) 136 | super()._fetch_all() 137 | 138 | # ------------------------------------------------------------------ 139 | # 140 | # Managers 141 | # 142 | # ------------------------------------------------------------------ 143 | 144 | 145 | class TreeNodeManager(models.Manager): 146 | """Tree Manager Class.""" 147 | 148 | def get_queryset(self): 149 | """Get QuerySet.""" 150 | return TreeNodeQuerySet(self.model, using=self._db).order_by( 151 | "_depth", "priority" 152 | ) 153 | 154 | def bulk_create(self, objs, *args, **kwargs): 155 | """Create objects in bulk and schedule tree rebuilds.""" 156 | result = super().bulk_create(objs, *args, **kwargs) 157 | 158 | # Collect parent_ids, stop at first None 159 | parent_ids = set() 160 | for obj in objs: 161 | pid = obj.parent_id 162 | if pid is None: 163 | self.model.tasks.queue.clear() 164 | self.model.tasks.add("update", None) 165 | break 166 | parent_ids.add(pid) 167 | else: 168 | for pid in parent_ids: 169 | self.model.tasks.add("update", pid) 170 | 171 | # self.model.tasks.run() 172 | return result 173 | 174 | def bulk_update(self, objs, fields, batch_size=None): 175 | """Update objects in bulk and schedule tree rebuilds if needed.""" 176 | result = super().bulk_update(objs, fields, batch_size) 177 | 178 | parent_ids = set() 179 | for obj in objs: 180 | pid = obj.parent_id 181 | if pid is None: 182 | self.model.tasks.queue.clear() 183 | self.model.tasks.add("update", None) 184 | break 185 | parent_ids.add(pid) 186 | else: 187 | for pid in parent_ids: 188 | self.model.tasks.add("update", pid) 189 | 190 | # self.model.tasks.run() 191 | return result 192 | 193 | def _raw_update(self, *args, **kwargs): 194 | """ 195 | Update objects in bulk. 196 | 197 | WARNING: Unsafe low-level update bypassing all TreeNode protections. 198 | Use only when bypassing _path/_depth/priority safety checks is 199 | intentional. 200 | """ 201 | return models.QuerySet(self.model, using=self.db)\ 202 | .update(*args, **kwargs) 203 | 204 | def _raw_bulk_update(self, *args, **kwargs): 205 | """ 206 | Update objects in bulk. 207 | 208 | WARNING: Unsafe low-level update bypassing all TreeNode protections. 209 | Use only when bypassing _path/_depth/priority safety checks is 210 | inte 211 | """ 212 | return models.QuerySet(self.model, using=self.db)\ 213 | .bulk_update(*args, **kwargs) 214 | 215 | 216 | # The End 217 | -------------------------------------------------------------------------------- /treenode/managers/tasks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode TaskQuery manager 4 | 5 | Version: 3.0.1 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | import atexit 11 | from django.db import connection, transaction 12 | 13 | from ..utils.db import TreePathCompiler 14 | 15 | 16 | class TreeTaskQueue: 17 | """TreeTaskQueue Class.""" 18 | 19 | def __init__(self, model): 20 | """Init the task query.""" 21 | self.model = model 22 | self.queue = [] 23 | self._running = False 24 | 25 | # Register the execution queue when the interpreter exits 26 | atexit.register(self._atexit_run) 27 | 28 | def _atexit_run(self): 29 | """Run queue on interpreter exit if pending tasks exist.""" 30 | if self.queue and not self._running: 31 | try: 32 | self.run() 33 | except Exception as e: 34 | # Don't crash on completion, just log 35 | print(f"[TreeTaskQueue] Error during atexit: {e}") 36 | 37 | def add(self, mode, parent_id): 38 | """Add task to the queue. 39 | 40 | Parameters: 41 | mode (str): Task type (currently only "update"). 42 | parent_id (int|None): ID of parent node to update from (None = full tree). 43 | """ 44 | self.queue.append({"mode": mode, "parent_id": parent_id}) 45 | 46 | def run(self): 47 | """Run task queue. 48 | 49 | This method collects all queued tasks, optimizes them, and performs 50 | a recursive rebuild of tree paths and depths using SQL. Locks the 51 | required rows before running. 52 | 53 | Uses Django's `transaction.atomic()` to ensure that any recursive CTE 54 | execution or SAVEPOINT creation works properly under PostgreSQL. 55 | """ 56 | if len(self.queue) == 0: 57 | return 58 | 59 | self._running = True 60 | try: 61 | optimized = self._optimize() 62 | if not optimized: 63 | return 64 | 65 | parent_ids = [t["parent_id"] for t in optimized if t["parent_id"] is not None] 66 | 67 | with transaction.atomic(): 68 | if any(t["parent_id"] is None for t in optimized): 69 | try: 70 | with connection.cursor() as cursor: 71 | cursor.execute( 72 | f"SELECT id FROM {self.model._meta.db_table} WHERE parent_id IS NULL FOR UPDATE NOWAIT" 73 | ) 74 | except Exception as e: 75 | print(f"[TreeTaskQueue] Skipped (root locked): {e}") 76 | return 77 | else: 78 | try: 79 | with connection.cursor() as cursor: 80 | for parent_id in parent_ids: 81 | cursor.execute( 82 | f"SELECT id FROM {self.model._meta.db_table} WHERE id = %s FOR UPDATE NOWAIT", 83 | [parent_id], 84 | ) 85 | except Exception as e: 86 | print(f"[TreeTaskQueue] Skipped (parent locked): {e}") 87 | return 88 | 89 | for task in optimized: 90 | if task["mode"] == "update": 91 | TreePathCompiler.update_path( 92 | model=self.model, 93 | parent_id=task["parent_id"] 94 | ) 95 | 96 | except Exception as e: 97 | print(f"[TreeTaskQueue] Error in run: {e}") 98 | connection.rollback() 99 | finally: 100 | self.queue.clear() 101 | self._running = False 102 | 103 | def _optimize(self): 104 | """Return optimized task queue (ID-only logic). 105 | 106 | Attempts to merge redundant or overlapping subtree updates into 107 | the minimal set of unique parent IDs that need to be rebuilt. 108 | If it finds a common root, it returns a single task for full rebuild. 109 | """ 110 | result_set = set() 111 | id_set = set() 112 | 113 | for task in self.queue: 114 | if task["mode"] == "update": 115 | parent_id = task["parent_id"] 116 | if parent_id is None: 117 | return [{"mode": "update", "parent_id": None}] 118 | else: 119 | id_set.add(parent_id) 120 | 121 | id_list = list(id_set) 122 | 123 | while id_list: 124 | current = id_list.pop(0) 125 | merged = False 126 | for other in id_list[:]: 127 | ancestor = self._get_common_ancestor(current, other) 128 | if ancestor is not None: 129 | if ancestor in self._get_root_ids(): 130 | return [{"mode": "update", "parent_id": None}] 131 | if ancestor not in id_set: 132 | id_list.append(ancestor) 133 | id_set.add(ancestor) 134 | id_list.remove(other) 135 | merged = True 136 | break 137 | if not merged: 138 | result_set.add(current) 139 | 140 | return [{"mode": "update", "parent_id": pk} for pk in sorted(result_set)] 141 | 142 | def _get_root_ids(self): 143 | """Return root node IDs.""" 144 | with connection.cursor() as cursor: 145 | cursor.execute( 146 | f"SELECT id FROM {self.model._meta.db_table} WHERE parent_id IS NULL") 147 | return [row[0] for row in cursor.fetchall()] 148 | 149 | def _get_parent_id(self, node_id): 150 | """Return parent ID for a given node.""" 151 | with connection.cursor() as cursor: 152 | cursor.execute( 153 | f"SELECT parent_id FROM {self.model._meta.db_table} WHERE id = %s", [node_id]) 154 | row = cursor.fetchone() 155 | return row[0] if row else None 156 | 157 | def _get_ancestor_path(self, node_id): 158 | """Return list of ancestor IDs including the node itself, using recursive SQL.""" 159 | table = self.model._meta.db_table 160 | 161 | sql = f""" 162 | WITH RECURSIVE ancestor_cte AS ( 163 | SELECT id, parent_id, 0 AS depth 164 | FROM {table} 165 | WHERE id = %s 166 | 167 | UNION ALL 168 | 169 | SELECT t.id, t.parent_id, a.depth + 1 170 | FROM {table} t 171 | JOIN ancestor_cte a ON t.id = a.parent_id 172 | ) 173 | SELECT id FROM ancestor_cte ORDER BY depth DESC 174 | """ 175 | 176 | with connection.cursor() as cursor: 177 | cursor.execute(sql, [node_id]) 178 | rows = cursor.fetchall() 179 | 180 | return [row[0] for row in rows] 181 | 182 | def _get_common_ancestor(self, id1, id2): 183 | """Return common ancestor ID between two nodes.""" 184 | path1 = self._get_ancestor_path(id1) 185 | path2 = self._get_ancestor_path(id2) 186 | common = None 187 | for a, b in zip(path1, path2): 188 | if a == b: 189 | common = a 190 | else: 191 | break 192 | return common 193 | 194 | 195 | class TreeTaskManager: 196 | """Handle to TreeTaskQueue.""" 197 | 198 | def __get__(self, instance, owner): 199 | """Get query for instance.""" 200 | if not hasattr(owner, "_task_queue"): 201 | owner._task_queue = TreeTaskQueue(owner) 202 | return owner._task_queue 203 | 204 | 205 | # The End 206 | -------------------------------------------------------------------------------- /treenode/models/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | The TreeNode Model 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | from .models import TreeNodeModel 10 | 11 | __all__ = ["TreeNodeModel"] 12 | -------------------------------------------------------------------------------- /treenode/models/decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeeNodeModel Class Decorators 4 | 5 | - Decorator `@cached_method` for caching method results. 6 | 7 | Version: 3.0.0 8 | Author: Timur Kady 9 | Email: timurkady@yandex.com 10 | """ 11 | 12 | 13 | _TIMEOUT = 10 14 | _INTERVAL = 0.2 15 | 16 | _UNSET = object() 17 | 18 | 19 | class classproperty(object): 20 | """Classproperty class.""" 21 | 22 | def __init__(self, getter): 23 | """Init.""" 24 | self.getter = getter 25 | 26 | def __get__(self, instance, owner): 27 | """Get.""" 28 | return self.getter(owner) 29 | 30 | 31 | def lazy_property(source, default=_UNSET): 32 | """ 33 | Декоратор ленивого свойства, которое берёт значение из другого поля. 34 | 35 | source — имя поля (например, 'parent_id') 36 | default — значение по умолчанию. Если не задан, берётся self.source 37 | """ 38 | def decorator(func): 39 | def getter(self): 40 | attr_name = '_' + func.__name__ 41 | if not hasattr(self, attr_name): 42 | if default is _UNSET: 43 | value = getattr(self, source) 44 | else: 45 | value = default 46 | setattr(self, attr_name, value) 47 | return getattr(self, attr_name) 48 | 49 | def setter(self, value): 50 | attr_name = '_' + func.__name__ 51 | setattr(self, attr_name, value) 52 | 53 | return property(getter, setter) 54 | return decorator 55 | -------------------------------------------------------------------------------- /treenode/models/factory.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Factory 4 | 5 | This module provides a metaclass that automatically associates a model with 6 | a service table and creates a set of indexes for the database 7 | 8 | Features: 9 | - Dynamically creates and assigns a service model. 10 | - Facilitates the formation of indexes taking into account the DB vendor. 11 | 12 | Version: 3.0.0 13 | Author: Timur Kady 14 | Email: timurkady@yandex.com 15 | """ 16 | 17 | 18 | from django.db import models, connection 19 | from django.db.models import Func, F 20 | 21 | 22 | class TreeNodeModelBase(models.base.ModelBase): 23 | """Base Class for TreeNodeModel.""" 24 | 25 | def __new__(mcls, name, bases, attrs, **kwargs): 26 | """Create a New Class.""" 27 | new_class = super().__new__(mcls, name, bases, attrs, **kwargs) 28 | if not new_class._meta.abstract: 29 | class_name = name.lower() 30 | # Create an index with the desired name 31 | """Set DB Indexes with unique names per model.""" 32 | vendor = connection.vendor 33 | indexes = [] 34 | 35 | if vendor == 'postgresql': 36 | indexes.append(models.Index( 37 | fields=['_path'], 38 | name=f'idx_{class_name}_path_ops', 39 | opclasses=['text_pattern_ops'] 40 | )) 41 | elif vendor in {'mysql'}: 42 | indexes.append(models.Index( 43 | Func(F('_path'), function='md5'), 44 | name=f'idx_{class_name}_path_hash' 45 | )) 46 | else: 47 | indexes.append(models.Index( 48 | fields=['_path'], 49 | name=f'idx_{class_name}_path' 50 | )) 51 | 52 | # Update the list of indexes 53 | new_class._meta.indexes += indexes 54 | 55 | return new_class 56 | 57 | # The End 58 | -------------------------------------------------------------------------------- /treenode/models/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .ancestors import TreeNodeAncestorsMixin 4 | from .children import TreeNodeChildrenMixin 5 | from .descendants import TreeNodeDescendantsMixin 6 | from .family import TreeNodeFamilyMixin 7 | from .logical import TreeNodeLogicalMixin 8 | from .node import TreeNodeNodeMixin 9 | from .properties import TreeNodePropertiesMixin 10 | from .roots import TreeNodeRootsMixin 11 | from .siblings import TreeNodeSiblingsMixin 12 | from .tree import TreeNodeTreeMixin 13 | from .update import RawSQLMixin 14 | 15 | 16 | __all__ = [ 17 | "TreeNodeAncestorsMixin", "TreeNodeChildrenMixin", "TreeNodeFamilyMixin", 18 | "TreeNodeDescendantsMixin", "TreeNodeLogicalMixin", "TreeNodeNodeMixin", 19 | "TreeNodePropertiesMixin", "TreeNodeRootsMixin", "TreeNodeSiblingsMixin", 20 | "TreeNodeTreeMixin", "RawSQLMixin" 21 | ] 22 | 23 | 24 | # The End 25 | -------------------------------------------------------------------------------- /treenode/models/mixins/ancestors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Ancestors Mixin 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from django.db import models 11 | from ...cache import cached_method 12 | 13 | 14 | class TreeNodeAncestorsMixin(models.Model): 15 | """TreeNode Ancestors Mixin.""" 16 | 17 | class Meta: 18 | """Moxin Meta Class.""" 19 | 20 | abstract = True 21 | 22 | def get_ancestors_queryset(self, include_self=True): 23 | """Get all ancestors of a node.""" 24 | pks = self.query("ancestors", include_self) 25 | return self._meta.model.objects.filter(pk__in=pks) 26 | 27 | @cached_method 28 | def get_ancestors_pks(self, include_self=True, depth=None): 29 | """Get the ancestors pks list.""" 30 | return self.query("ancestors", include_self) 31 | 32 | @cached_method 33 | def get_ancestors(self, include_self=True): 34 | """Get a list of all ancestors of a node.""" 35 | node = self if include_self else self.parent 36 | ancestors = [] 37 | while node: 38 | ancestors.append(node) 39 | node = node.parent 40 | return ancestors[::-1] 41 | 42 | @cached_method 43 | def get_ancestors_count(self, include_self=True): 44 | """Get the ancestors count.""" 45 | return self.query( 46 | objects="ancestors", 47 | include_self=include_self, 48 | mode='count' 49 | ) 50 | 51 | def get_common_ancestor(self, target): 52 | """Find lowest common ancestor between self and other node.""" 53 | if self._path == target._path: 54 | return self 55 | 56 | self_path_pks = self.query("ancestors") 57 | target_path_pks = target.query("ancestors") 58 | common = [] 59 | 60 | for a, b in zip(self_path_pks, target_path_pks): 61 | if a == b: 62 | common.append(a) 63 | else: 64 | break 65 | 66 | if not common: 67 | return None 68 | 69 | ancestor_id = common[-1] 70 | return self._meta.model.objects.get(pk=ancestor_id) 71 | 72 | # The End 73 | -------------------------------------------------------------------------------- /treenode/models/mixins/children.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Children Mixin 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from django.db import models 11 | from ...cache import cached_method 12 | 13 | 14 | ''' 15 | try: 16 | profile 17 | except NameError: 18 | def profile(func): 19 | """Profile.""" 20 | return func 21 | ''' 22 | 23 | 24 | class TreeNodeChildrenMixin(models.Model): 25 | """TreeNode Ancestors Mixin.""" 26 | 27 | class Meta: 28 | """Moxin Meta Class.""" 29 | 30 | abstract = True 31 | 32 | def add_child(self, position=None, **kwargs): 33 | """ 34 | Add a child to the node. 35 | 36 | position: 37 | Can be 'first-child', 'last-child', 'sorted-child' or integer value. 38 | 39 | Parameters: 40 | **kwargs – Object creation data that will be passed to the inherited 41 | Node model 42 | instance – Instead of passing object creation data, you can pass 43 | an already-constructed (but not yet saved) model instance to be 44 | inserted into the tree. 45 | 46 | Returns: 47 | The created node object. It will be save()d by this method. 48 | """ 49 | instance = kwargs.get("instance") 50 | if instance is None: 51 | instance = self._meta.model(**kwargs) 52 | 53 | parent, priority = self._meta.model._get_place(self, position) 54 | 55 | instance.parent = self 56 | instance.priority = priority 57 | instance.save() 58 | 59 | def get_children_queryset(self): 60 | """Get the children queryset.""" 61 | return self._meta.model.objects.filter(parent_id=self.id) 62 | 63 | @cached_method 64 | def get_children(self): 65 | """Get a list containing all children.""" 66 | queryset = self._meta.model.objects.filter(parent_id=self.id) 67 | return list(queryset) 68 | 69 | @cached_method 70 | def get_children_pks(self): 71 | """Get the children pks list.""" 72 | return self.query("children") 73 | 74 | @cached_method 75 | def get_children_count(self): 76 | """Get the children count.""" 77 | return self.query(objects="children", mode='count') 78 | 79 | @cached_method 80 | def get_first_child(self): 81 | """Get the first child node or None if it has no children.""" 82 | return self.get_children_queryset().first() 83 | 84 | @cached_method 85 | def get_last_child(self): 86 | """Get the last child node or None if it has no children.""" 87 | return self.get_children_queryset().last() 88 | 89 | # The End 90 | -------------------------------------------------------------------------------- /treenode/models/mixins/descendants.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Descendants Mixin 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from django.db import models 11 | from ...cache import cached_method 12 | 13 | 14 | ''' 15 | try: 16 | profile 17 | except NameError: 18 | def profile(func): 19 | """Profile.""" 20 | return func 21 | ''' 22 | 23 | 24 | class TreeNodeDescendantsMixin(models.Model): 25 | """TreeNode Descendants Mixin.""" 26 | 27 | class Meta: 28 | """Moxin Meta Class.""" 29 | 30 | abstract = True 31 | 32 | def get_descendants_queryset(self, include_self=False, depth=None): 33 | """Get the descendants queryset.""" 34 | path = self.get_order() # calls refresh and gets the current _path 35 | 36 | # from_path = path + '.' 37 | # to_path = path + '/' 38 | 39 | # options = {'_path__gte': from_path, '_path__lt': to_path} 40 | # if depth: 41 | # options["_depth__lt"] = depth 42 | # queryset = self._meta.model.objects.filter(**options) 43 | # if include_self: 44 | # return self._meta.model.objects.filter(pk=self.pk) | queryset 45 | # else: 46 | # return queryset 47 | 48 | suffix = "" if include_self else '.' 49 | path += suffix 50 | queryset = self._meta.model.objects.filter(_path__startswith=path) 51 | return queryset 52 | 53 | @cached_method 54 | def get_descendants_pks(self, include_self=False, depth=None): 55 | """Get the descendants pks list.""" 56 | return self.query("descendants", include_self) 57 | 58 | # @profile 59 | @cached_method 60 | def get_descendants(self, include_self=False, depth=None): 61 | """Get a list containing all descendants.""" 62 | # descendants_pks = self.query("descendants", include_self) 63 | # queryset = self._meta.model.objects.filter(pk__in=descendants_pks) 64 | queryset = self.get_descendants_queryset(include_self, depth) 65 | return list(queryset) 66 | 67 | @cached_method 68 | def get_descendants_count(self, include_self=False, depth=None): 69 | """Get the descendants count.""" 70 | return self.query( 71 | objects="descendants", 72 | include_self=include_self, 73 | mode='count' 74 | ) 75 | 76 | 77 | # The End 78 | -------------------------------------------------------------------------------- /treenode/models/mixins/family.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Descendants Mixin 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from django.db import models 11 | from django.db.models import Q 12 | from ...cache import cached_method 13 | 14 | 15 | ''' 16 | try: 17 | profile 18 | except NameError: 19 | def profile(func): 20 | """Profile.""" 21 | return func 22 | ''' 23 | 24 | 25 | class TreeNodeFamilyMixin(models.Model): 26 | """TreeNode Family Mixin.""" 27 | 28 | class Meta: 29 | """Moxin Meta Class.""" 30 | 31 | abstract = True 32 | 33 | def get_family_queryset(self): 34 | """ 35 | Return node family. 36 | 37 | Return a QuerySet containing the ancestors, itself and the descendants, 38 | in tree order. 39 | """ 40 | return self._meta.model.objects.filter( 41 | Q(pk__in=self._get_path()) | 42 | Q(_path__startswith=self._path+'.') 43 | ) 44 | 45 | @cached_method 46 | def get_family_pks(self): 47 | """ 48 | Return node family. 49 | 50 | Return a pk-list containing the ancestors, the model itself and 51 | the descendants, in tree order. 52 | """ 53 | return self.query(objects="family") 54 | 55 | # @profile 56 | @cached_method 57 | def get_family(self): 58 | """ 59 | Return node family. 60 | 61 | Return a list containing the ancestors, the model itself and 62 | the descendants, in tree order. 63 | """ 64 | ancestors = self.get_ancestors() 65 | descendants = self.get_descendants() 66 | return ancestors.extend(descendants) 67 | 68 | @cached_method 69 | def get_family_count(self): 70 | """Return number of nodes in family.""" 71 | return self.query(objects="family", mode='count') 72 | 73 | # The End 74 | -------------------------------------------------------------------------------- /treenode/models/mixins/logical.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Logical methods Mixin 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from django.db import models 11 | 12 | 13 | class TreeNodeLogicalMixin(models.Model): 14 | """TreeNode Logical Mixin.""" 15 | 16 | class Meta: 17 | """Moxin Meta Class.""" 18 | 19 | abstract = True 20 | 21 | def is_ancestor_of(self, target): 22 | """Check if self is an ancestor of other node.""" 23 | return target.id in target.query("ancestors", include_self=False) 24 | 25 | def is_child_of(self, target): 26 | """Return True if the current node is child of target_obj.""" 27 | return self._parent_id == target.id 28 | 29 | def is_descendant_of(self, target): 30 | """Check if self is a descendant of other node.""" 31 | return target.id in target.query("descendants", include_self=False) 32 | 33 | def is_first_child(self): 34 | """Return True if the current node is the first child.""" 35 | return self.priority == 0 36 | 37 | def has_children(self): 38 | """Return True if the node has children.""" 39 | return self.query(objects="children", mode='exist') 40 | 41 | def is_last_child(self): 42 | """Return True if the current node is the last child.""" 43 | siblings_pks = self.query("siblings", include_self=True) 44 | return siblings_pks[-1] == self.id 45 | 46 | def is_leaf(self): 47 | """Return True if the current node is a leaf.""" 48 | return not self.has_children() 49 | 50 | def is_parent_of(self, target): 51 | """Return True if the current node is parent of target_obj.""" 52 | return self.id == target._parent_id 53 | 54 | def is_root(self): 55 | """Return True if the current node is root.""" 56 | return self.parent is None 57 | 58 | def is_root_of(self, target): 59 | """Return True if the current node is root of target_obj.""" 60 | return self.pk == target.query("ancestors")[0] 61 | 62 | def is_sibling_of(self, target): 63 | """Return True if the current node is sibling of target_obj.""" 64 | if target.parent is None and self.parent is None: 65 | # Both objects are roots 66 | return True 67 | return (self._parent_id == target._parent_id) 68 | 69 | 70 | # The End 71 | -------------------------------------------------------------------------------- /treenode/models/mixins/properties.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Properties Mixin 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from ..decorators import classproperty 11 | 12 | 13 | class TreeNodePropertiesMixin: 14 | """ 15 | TreeNode Properties Mixin. 16 | 17 | Public properties. 18 | All properties map a get_{{property}}() method. 19 | """ 20 | 21 | @property 22 | def ancestors(self): 23 | """Get a list with all ancestors; self included.""" 24 | return self.get_ancestors() 25 | 26 | @property 27 | def ancestors_count(self): 28 | """Get the ancestors count.""" 29 | return self.get_ancestors_count() 30 | 31 | @property 32 | def ancestors_pks(self): 33 | """Get the ancestors pks list; self included.""" 34 | return self.get_ancestors_pks() 35 | 36 | @property 37 | def breadcrumbs(self): 38 | """Get the breadcrumbs to current node(self, included).""" 39 | return self.get_breadcrumbs() 40 | 41 | @property 42 | def children(self): 43 | """Get a list containing all children; self included.""" 44 | return self.get_children() 45 | 46 | @property 47 | def children_count(self): 48 | """Get the children count.""" 49 | return self.get_children_count() 50 | 51 | @property 52 | def children_pks(self): 53 | """Get the children pks list.""" 54 | return self.get_children_pks() 55 | 56 | @property 57 | def depth(self): 58 | """Get the node depth.""" 59 | return self.get_depth() 60 | 61 | @property 62 | def descendants(self): 63 | """Get a list containing all descendants; self not included.""" 64 | return self.get_descendants() 65 | 66 | @property 67 | def descendants_count(self): 68 | """Get the descendants count; self not included.""" 69 | return self.get_descendants_count() 70 | 71 | @property 72 | def descendants_pks(self): 73 | """Get the descendants pks list; self not included.""" 74 | return self.get_descendants_pks() 75 | 76 | @property 77 | def first_child(self): 78 | """Get the first child node.""" 79 | return self.get_first_child() 80 | 81 | @property 82 | def index(self): 83 | """Get the node index.""" 84 | return self.get_index() 85 | 86 | @property 87 | def last_child(self): 88 | """Get the last child node.""" 89 | return self.get_last_child() 90 | 91 | @property 92 | def left(self): 93 | """Get the node to left.""" 94 | return self.get_left() 95 | 96 | @property 97 | def right(self): 98 | """Get the node to right.""" 99 | return self.get_right() 100 | 101 | @property 102 | def level(self): 103 | """Get the node level.""" 104 | return self.get_level() 105 | 106 | # @property 107 | # def parent(self): 108 | # """Get node parent.""" 109 | # return self.tn_parent 110 | 111 | @property 112 | def parent_pk(self): 113 | """Get node parent pk.""" 114 | return self.get_parent_pk() 115 | 116 | # @property 117 | # def priority(self): 118 | # """Get node priority.""" 119 | # return self.get_priority() 120 | 121 | @classproperty 122 | def roots(cls): 123 | """Get a list with all root nodes.""" 124 | return cls.get_roots() 125 | 126 | @property 127 | def root(self): 128 | """Get the root node for the current node.""" 129 | return self.get_root() 130 | 131 | @property 132 | def root_pk(self): 133 | """Get the root node pk for the current node.""" 134 | return self.get_root_pk() 135 | 136 | @property 137 | def siblings(self): 138 | """Get a list with all the siblings.""" 139 | return self.get_siblings() 140 | 141 | @property 142 | def siblings_count(self): 143 | """Get the siblings count.""" 144 | return self.get_siblings_count() 145 | 146 | @property 147 | def siblings_pks(self): 148 | """Get the siblings pks list.""" 149 | return self.get_siblings_pks() 150 | 151 | @classproperty 152 | def tree(cls): 153 | """Get an n-dimensional dict representing the model tree.""" 154 | return cls.get_tree() 155 | 156 | @property 157 | def order(self): 158 | """Return the materialized path.""" 159 | return self.get_order() 160 | 161 | 162 | # The End 163 | -------------------------------------------------------------------------------- /treenode/models/mixins/roots.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Roots Mixin 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from django.db import models 11 | from ...cache import cached_method 12 | 13 | 14 | ''' 15 | try: 16 | profile 17 | except NameError: 18 | def profile(func): 19 | """Profile.""" 20 | return func 21 | ''' 22 | 23 | 24 | class TreeNodeRootsMixin(models.Model): 25 | """TreeNode Roots Mixin.""" 26 | 27 | class Meta: 28 | """Moxin Meta Class.""" 29 | 30 | abstract = True 31 | 32 | @classmethod 33 | def add_root(cls, position=None, **kwargs): 34 | """ 35 | Add a root node to the tree. 36 | 37 | Adds a new root node at the specified position. If no position is 38 | specified, the new node will be the last element in the root. 39 | Parameters: 40 | position: can be 'first-root', 'last-root', 'sorted-root' or integer 41 | value. 42 | **kwargs – Object creation data that will be passed to the inherited 43 | Node model 44 | instance – Instead of passing object creation data, you can pass 45 | an already-constructed (but not yet saved) model instance to be 46 | inserted into the tree. 47 | 48 | Returns: 49 | The created node object. It will be save()d by this method. 50 | """ 51 | if isinstance(position, int): 52 | priority = position 53 | else: 54 | if position not in ['first-root', 'last-root', 'sorted-root']: 55 | raise ValueError(f"Invalid position format: {position}") 56 | 57 | parent, priority = cls._get_place(None, position) 58 | 59 | instance = kwargs.get("instance") 60 | if instance is None: 61 | instance = cls(**kwargs) 62 | 63 | parent, priority = cls._get_place(None, position) 64 | instance.parent = None 65 | instance.priority = priority 66 | instance.save() 67 | return instance 68 | 69 | @classmethod 70 | def get_roots_queryset(cls): 71 | """Get root nodes queryset with preloaded children.""" 72 | return cls.objects.filter(parent_id__isnull=True) 73 | 74 | @classmethod 75 | def get_roots_pks(cls): 76 | """Get a list with all root nodes.""" 77 | queryset = cls.objects.filter(parent_id__isnull=True) 78 | return queryset.values_list("id", flat=True) 79 | 80 | @classmethod 81 | @cached_method 82 | def get_roots(cls): 83 | """Get a list with all root nodes.""" 84 | return list(cls.objects.filter(parent__isnull=True)) 85 | 86 | @classmethod 87 | def get_roots_count(cls): 88 | """Get a list with all root nodes.""" 89 | return cls.objects.filter(parent__isnull=True).count() 90 | 91 | @classmethod 92 | def get_first_root(cls): 93 | """Return the first root node in the tree or None if it is empty.""" 94 | roots = cls.get_roots_queryset() 95 | return roots.first() 96 | 97 | @classmethod 98 | def get_last_root(cls): 99 | """Return the last root node in the tree or None if it is empty.""" 100 | roots = cls.get_roots_queryset() 101 | return roots.last() 102 | 103 | @classmethod 104 | def sort_roots(cls): 105 | """ 106 | Re-sort root nodes. 107 | 108 | Sorts all nodes with parent_id IS NULL using a raw SQL query with 109 | a window function. 110 | The new ordering is computed based on the model's sorting_field 111 | (defaulting to 'priority'). 112 | It updates the 'priority' field for all root nodes. 113 | """ 114 | from django.db import connection 115 | 116 | db_table = cls._meta.db_table 117 | ordering_field = cls.sorting_field 118 | 119 | # Only root nodes have parent_id IS NULL 120 | where_clause = "parent_id IS NULL" 121 | params = [] 122 | 123 | query = f""" 124 | WITH ranked AS ( 125 | SELECT id, 126 | ROW_NUMBER() OVER (ORDER BY {ordering_field}) - 1 AS new_priority 127 | FROM {db_table} 128 | WHERE {where_clause} 129 | ) 130 | UPDATE {db_table} AS t 131 | SET priority = ranked.new_priority 132 | FROM ranked 133 | WHERE t.id = ranked.id; 134 | """ 135 | 136 | with connection.cursor() as cursor: 137 | cursor.execute(query, params) 138 | 139 | 140 | # The End 141 | -------------------------------------------------------------------------------- /treenode/models/mixins/siblings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Siblings Mixin 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from django.db import models 11 | from ...cache import cached_method 12 | 13 | 14 | ''' 15 | try: 16 | profile 17 | except NameError: 18 | def profile(func): 19 | """Profile.""" 20 | return func 21 | ''' 22 | 23 | 24 | class TreeNodeSiblingsMixin(models.Model): 25 | """TreeNode Siblings Mixin.""" 26 | 27 | class Meta: 28 | """Moxin Meta Class.""" 29 | 30 | abstract = True 31 | 32 | def add_sibling(self, position=None, **kwargs): 33 | """ 34 | Add a new node as a sibling to the current node object. 35 | 36 | Returns the created node object or None if failed. It will be saved 37 | by this method. 38 | """ 39 | instance = kwargs.get("instance") 40 | if instance is None: 41 | instance = self._meta.model(**kwargs) 42 | 43 | parent, priority = self._meta.model._get_place(self.patent, position) 44 | 45 | instance.parent = parent 46 | instance.priority = priority 47 | instance.save() 48 | if isinstance(position, str) and 'sorted' in position: 49 | self._sort_siblings() 50 | return instance 51 | 52 | def get_siblings_queryset(self, include_self=True): 53 | """Get Siblings QuerySet.""" 54 | qs = self._meta.model.objects.filter(parent_id=self._parent_id) 55 | return qs if include_self else qs.exclude(pk=self.id) 56 | 57 | # @profile 58 | @cached_method 59 | def get_siblings(self, include_self=True): 60 | """Get a list with all the siblings.""" 61 | queryset = self._meta.model.objects.filter(parent=self.parent) 62 | queryset = queryset if include_self else queryset.exclude(pk=self.pk) 63 | return [n for n in queryset] 64 | 65 | @cached_method 66 | def get_siblings_pks(self, include_self=True): 67 | """Get the siblings pks list.""" 68 | return self.query("siblings", include_self) 69 | 70 | @cached_method 71 | def get_siblings_count(self): 72 | """Get the siblings count.""" 73 | qs = self.query("siblings") 74 | return qs.count() 75 | 76 | @cached_method 77 | def get_first_sibling(self): 78 | """Return the first sibling in the tree, or None.""" 79 | qs = self._meta.model.objects.filter(parent_id=self._parent_id) 80 | return qs.first() 81 | 82 | @cached_method 83 | def get_previous_sibling(self): 84 | """Return the previous sibling in the tree, or None.""" 85 | options = {'parent_id': self._parent_id, 'priority__lt': self.priority} 86 | return self._meta.model.objects.filter(**options).last() 87 | 88 | def get_next_sibling(self): 89 | """Return the next sibling in the tree, or None.""" 90 | options = {'parent_id': self._parent_id, 'priority__gt': self.priority} 91 | return self._meta.model.objects.filter(**options).first() 92 | 93 | def get_last_sibling(self): 94 | """ 95 | Return the fist node’s sibling. 96 | 97 | Method can return the node itself if it was the leftmost sibling. 98 | """ 99 | qs = self._meta.model.objects.filter(parent_id=self._parent_id) 100 | return qs.last() 101 | 102 | # The End 103 | -------------------------------------------------------------------------------- /treenode/models/mixins/update.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Raw SQL Mixin 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from django.db import models, connection 11 | 12 | from ...settings import SEGMENT_LENGTH, BASE 13 | from ...utils.db.sqlcompat import SQLCompat 14 | 15 | 16 | class RawSQLMixin(models.Model): 17 | """Raw SQL Mixin.""" 18 | 19 | class Meta: 20 | """Meta Class.""" 21 | 22 | abstract = True 23 | 24 | def refresh(self): 25 | """Refresh key fields from DB.""" 26 | task_query = self._meta.model.tasks 27 | if len(task_query.queue) > 0: 28 | task_query.run() 29 | 30 | table = self._meta.db_table 31 | sql = f"SELECT priority, _path, _depth FROM {table} WHERE id = %s" 32 | with connection.cursor() as cursor: 33 | cursor.execute(sql, [self.pk]) 34 | row = cursor.fetchone() 35 | self.priority, self._path, self._depth = row 36 | 37 | self._parent_id = self.parent_pk 38 | self._priority = self.priority 39 | 40 | def _shift_siblings_forward(self): 41 | """ 42 | Shift the priority of all siblings starting from self.priority. 43 | 44 | Uses direct SQL for maximum speed. 45 | """ 46 | if (self.priority is None) or (self.priority >= BASE - 1): 47 | return 48 | 49 | db_table = self._meta.db_table 50 | 51 | if self.parent_id is None: 52 | where_clause = "parent_id IS NULL" 53 | params = [self.priority] 54 | else: 55 | where_clause = "parent_id = %s" 56 | params = [self.parent_id, self.priority] 57 | 58 | sql = f""" 59 | UPDATE {db_table} 60 | SET priority = priority + 1 61 | WHERE {where_clause} AND priority >= %s 62 | """ 63 | 64 | with connection.cursor() as cursor: 65 | cursor.execute(sql, params) 66 | 67 | def _update_path(self, parent_id): 68 | """ 69 | Rebuild subtree starting from parent_id. 70 | 71 | If parent_id=None, then the whole tree is rebuilt. 72 | Only fields are used: parent_id and id. All others (priority, _path, 73 | _depth) are recalculated. 74 | """ 75 | db_table = self._meta.db_table 76 | 77 | sorting_field = self.sorting_field 78 | sorting_fields = ["priority", "id"] if sorting_field == "priority" else [sorting_field] # noqa: D501 79 | sort_expr = ", ".join([ 80 | field if "." in field else f"c.{field}" 81 | for field in sorting_fields 82 | ]) 83 | 84 | cte_header = "(id, parent_id, new_priority, new_path, new_depth)" 85 | 86 | row_number_expr = "ROW_NUMBER() OVER (ORDER BY {sort_expr}) - 1" 87 | hex_expr = SQLCompat.to_hex(row_number_expr) 88 | lpad_expr = SQLCompat.lpad(hex_expr, SEGMENT_LENGTH, "'0'") 89 | 90 | if parent_id is None: 91 | new_path_expr = lpad_expr 92 | base_sql = f""" 93 | SELECT 94 | c.id, 95 | c.parent_id, 96 | {row_number_expr} AS new_priority, 97 | {new_path_expr} AS new_path, 98 | 0 AS new_depth 99 | FROM {db_table} AS c 100 | WHERE c.parent_id IS NULL 101 | """ 102 | params = [] 103 | else: 104 | path_expr = SQLCompat.concat("p._path", "'.'", lpad_expr) 105 | base_sql = f""" 106 | SELECT 107 | c.id, 108 | c.parent_id, 109 | {row_number_expr} AS new_priority, 110 | {path_expr} AS new_path, 111 | p._depth + 1 AS new_depth 112 | FROM {db_table} c 113 | JOIN {db_table} p ON c.parent_id = p.id 114 | WHERE p.id = %s 115 | """ 116 | params = [parent_id] 117 | 118 | recursive_row_number_expr = "ROW_NUMBER() OVER (PARTITION BY c.parent_id ORDER BY {sort_expr}) - 1" # noqa: D501 119 | recursive_hex_expr = SQLCompat.to_hex(recursive_row_number_expr) 120 | recursive_lpad_expr = SQLCompat.lpad( 121 | recursive_hex_expr, SEGMENT_LENGTH, "'0'") 122 | recursive_path_expr = SQLCompat.concat( 123 | "t.new_path", "'.'", recursive_lpad_expr) 124 | 125 | recursive_sql = f""" 126 | SELECT 127 | c.id, 128 | c.parent_id, 129 | {recursive_row_number_expr} AS new_priority, 130 | {recursive_path_expr} AS new_path, 131 | t.new_depth + 1 AS new_depth 132 | FROM {db_table} c 133 | JOIN tree_cte t ON c.parent_id = t.id 134 | """ 135 | 136 | final_sql = f""" 137 | WITH RECURSIVE tree_cte {cte_header} AS ( 138 | {base_sql} 139 | UNION ALL 140 | {recursive_sql} 141 | ) 142 | UPDATE {db_table} AS orig 143 | SET 144 | priority = t.new_priority, 145 | _path = t.new_path, 146 | _depth = t.new_depth 147 | FROM tree_cte t 148 | WHERE orig.id = t.id; 149 | """ 150 | 151 | self.sqlq.append((final_sql.format(sort_expr=sort_expr), params)) 152 | 153 | 154 | # The End 155 | -------------------------------------------------------------------------------- /treenode/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNodeModel and TreeCache settings. 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from django.conf import settings 11 | 12 | 13 | CACHE_LIMIT = getattr(settings, "TREENODE_CACHE_LIMIT", 100) * 1024 * 1024 14 | 15 | # The length on Materialized Path segment 16 | SEGMENT_LENGTH = getattr(settings, "TREENODE_SEGMENT_LENGTH", 3) 17 | 18 | # Serialization dictionary: hexadecimal encoding, fixed segment size 19 | SEGMENT_BASE = 16 20 | 21 | # Nubber children per one tree node 22 | BASE = SEGMENT_BASE ** SEGMENT_LENGTH # 4096 23 | 24 | 25 | TREENODE_PAD_CHAR = getattr(settings, "TREENODE_PAD_CHAR", "'0'") 26 | 27 | 28 | # The End 29 | -------------------------------------------------------------------------------- /treenode/signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Signals Module 4 | 5 | Version: 2.1.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from contextlib import contextmanager 11 | 12 | 13 | @contextmanager 14 | def disable_signals(signal, sender): 15 | """Temporarily disable execution of signal generation.""" 16 | # Save current signal handlers 17 | old_receivers = signal.receivers[:] 18 | signal.receivers = [] 19 | try: 20 | yield 21 | finally: 22 | # Restore handlers 23 | signal.receivers = old_receivers 24 | 25 | 26 | # Tne End 27 | -------------------------------------------------------------------------------- /treenode/static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimurKady/django-fast-treenode/b1880330f0d49aa1a418df70e5aa7827ea1baae2/treenode/static/.gitkeep -------------------------------------------------------------------------------- /treenode/static/css/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /treenode/static/css/tree_widget.css: -------------------------------------------------------------------------------- 1 | /* 2 | TreeNode Select2 Widget Stylesheet 3 | 4 | This stylesheet customizes the Select2 dropdown widget for hierarchical 5 | data representation in Django admin. It ensures compatibility with both 6 | light and dark themes. 7 | 8 | Features: 9 | - Dark theme styling for the dropdown and selected items. 10 | - Consistent color scheme for better readability. 11 | - Custom styling for search fields and selection indicators. 12 | - Enhances usability in tree-based data selection. 13 | 14 | Main styles: 15 | .tree-widget-display: styles the display area of ​​the selected item, adding 16 | padding, borders, and background. 17 | .tree-dropdown-arrow: styles the dropdown arrow. 18 | .tree-widget-dropdown: defines the style of the dropdown, including positioning, 19 | size, and shadows. 20 | .tree-search-wrapper: decorates the search area inside the dropdown. 21 | .tree-search: styles the search input. 22 | .tree-clear-button: button to clear the search input. 23 | .tree-list and .tree-node: styles for the list and its items. 24 | .expand-button: button to expand child elements. 25 | 26 | Dark theme: 27 | Uses the .dark-theme class to apply dark styles. Changes the background, 28 | borders, and text color for the corresponding elements. Ensures comfortable use 29 | of the widget in dark mode. 30 | 31 | Version: 2.0.0 32 | Author: Timur Kady 33 | Email: timurkady@yandex.com 34 | 35 | */ 36 | 37 | .form-row.field-parent { 38 | position: relative; 39 | overflow: visible !important; 40 | z-index: auto; 41 | } 42 | 43 | .tree-widget { 44 | position: relative; 45 | width: 100%; 46 | font-family: Arial, sans-serif; 47 | } 48 | 49 | .tree-widget-display { 50 | display: flex; 51 | align-items: center; 52 | justify-content: space-between; 53 | border: 1px solid #aaa; 54 | border-radius: 4px; 55 | background-color: #fff; 56 | cursor: pointer; 57 | transition: border-color 0.2s; 58 | } 59 | 60 | .tree-widget-display:hover { 61 | border-color: #333; 62 | } 63 | 64 | .tree-dropdown-arrow { 65 | font-size: 0.8em; 66 | color: #888; 67 | display: inline-block; 68 | height: 100%; 69 | background-color: lightgrey; 70 | padding: 8px; 71 | margin: 0; 72 | border-radius: 0 4px 4px 0; 73 | } 74 | 75 | .tree-widget-dropdown { 76 | display: none; 77 | position: absolute; 78 | top: 100%; 79 | left: 0; 80 | width: 100%; 81 | max-height: 242px; 82 | overflow-y: auto; 83 | border: 1px solid #aaa; 84 | border-top: none; 85 | border-radius: 0 3px 3px 0; 86 | background-color: #fff; 87 | z-index: 1000; 88 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 89 | } 90 | 91 | .tree-search-wrapper { 92 | display: flex; 93 | align-items: center; 94 | padding: 6px; 95 | border-bottom: 1px solid #ddd; 96 | background-color: #f9f9f9; 97 | } 98 | 99 | .tree-search-icon { 100 | margin-right: 6px; 101 | font-size: 1.3em; 102 | } 103 | 104 | .tree-search { 105 | flex-grow: 1; 106 | padding: 6px; 107 | border: 1px solid #ccc; 108 | border-radius: 4px; 109 | font-size: 1em; 110 | } 111 | 112 | .tree-search-clear{ 113 | border: none; 114 | border-radius: 3px; 115 | background: none; 116 | font-size: 1.8em; 117 | cursor: pointer; 118 | } 119 | 120 | .tree-clear-button { 121 | background: none; 122 | border: none; 123 | font-size: 1.2em; 124 | color: #888; 125 | cursor: pointer; 126 | margin-left: 6px; 127 | } 128 | 129 | .tree-list { 130 | list-style: none; 131 | margin: 0 !important; 132 | padding: 0 !important; 133 | } 134 | 135 | .tree-node { 136 | display: block !important; 137 | padding: 6px 0px !important; 138 | cursor: pointer; 139 | transition: background-color 0.2s; 140 | } 141 | 142 | .tree-node:hover { 143 | background-color: #f0f0f0; 144 | } 145 | 146 | .tree-node[data-level="1"] { 147 | padding-left: 20px; 148 | } 149 | 150 | .tree-node[data-level="2"] { 151 | padding-left: 40px; 152 | } 153 | 154 | .expand-button { 155 | display: inline-block; 156 | width: 18px; 157 | height: 18px; 158 | background: var(--button-bg); 159 | color: var(--button-fg); 160 | border-radius: 3px; 161 | border: none; 162 | margin: 0px 5px; 163 | cursor: pointer; 164 | font-size: 12px; 165 | line-height: 18px; 166 | padding: 0px; 167 | opacity: 0.8; 168 | } 169 | 170 | .no-expand { 171 | display: inline-block; 172 | width: 18px; 173 | height: 18px; 174 | border: none; 175 | margin: 0px 5px; 176 | } 177 | 178 | .selected-node { 179 | margin: 6px 12px; 180 | } 181 | 182 | /* Тёмная тема */ 183 | .dark-theme .tree-widget-display { 184 | background-color: #333; 185 | border-color: #555; 186 | color: #eee; 187 | } 188 | 189 | .dark-theme .tree-dropdown-arrow { 190 | color: #ccc; 191 | } 192 | 193 | .dark-theme .tree-widget-dropdown { 194 | background-color: #444; 195 | border-color: #555; 196 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5); 197 | } 198 | 199 | .dark-theme .tree-search-wrapper { 200 | background-color: #555; 201 | border-bottom-color: #666; 202 | } 203 | 204 | .dark-theme .tree-search { 205 | background-color: #666; 206 | border-color: #777; 207 | color: #eee; 208 | } 209 | 210 | .dark-theme .tree-search::placeholder { 211 | color: #ccc; 212 | } 213 | 214 | .dark-theme .tree-clear-button { 215 | color: #ccc; 216 | } 217 | 218 | .dark-theme .tree-node { 219 | color: #eee; 220 | } 221 | 222 | .dark-theme .tree-node:hover { 223 | background-color: #555; 224 | } 225 | 226 | .dark-theme .expand-button { 227 | color: #ccc; 228 | } 229 | 230 | .dark-theme .selected-node { 231 | color: #eee; 232 | } 233 | -------------------------------------------------------------------------------- /treenode/static/css/treenode_admin.css: -------------------------------------------------------------------------------- 1 | /* 2 | TreeNode Admin Stylesheet 3 | 4 | This CSS file defines styles for the TreeNode admin interface, 5 | including tree structure visualization, interactive toggles, 6 | and theme support. 7 | 8 | Features: 9 | - Tree node toggle styling for expanding/collapsing nodes. 10 | - Supports both light and dark themes. 11 | - Smooth hover effects and animations. 12 | - Consistent layout adjustments for better UI interaction. 13 | - Visual feedback drag-n-drop operations. 14 | 15 | Version: 3.0.0 16 | Author: Timur Kady 17 | Email: timurkady@yandex.com 18 | 19 | */ 20 | 21 | 22 | @keyframes anim { 23 | 0% {width: 0px; height: 0px;} 24 | 100% {width: 100px; height: 100px;} 25 | } 26 | 27 | .field-drag, .field-toggle { 28 | width: 18px !important; 29 | text-align: center !important; 30 | padding: 8px 0px !important; 31 | } 32 | 33 | .treenode-space { 34 | display: inline-block; 35 | width: 18px; 36 | height: 18px; 37 | margin: 0px 3px !important; 38 | background-color: transparent; 39 | border: 1px solid transparent; 40 | padding: 1px; 41 | } 42 | 43 | .treenode-toggle { 44 | display: inline-block; 45 | width: 18px; 46 | height: 18px; 47 | background: var(--button-bg); 48 | color: var(--button-fg); 49 | border-radius: 3px; 50 | border: none; 51 | margin: 0px 5px; 52 | cursor: pointer; 53 | font-size: 12px; 54 | line-height: 18px; 55 | padding: 0px; 56 | opacity: 0.8; 57 | transition: opacity 0.2s ease, color 0.2s ease; 58 | } 59 | 60 | .treenode-toggle[expanded="true"] { 61 | color: green; 62 | } 63 | 64 | 65 | .treenode-toggle:hover { 66 | opacity: 1.0; 67 | } 68 | 69 | .treenode-toolbar{ 70 | display: flex; 71 | } 72 | 73 | .treenode-toolbar { 74 | margin: 15px 0px; 75 | } 76 | 77 | .treenode-button { 78 | padding: 5px !important; 79 | margin-left: 15px !important; 80 | } 81 | 82 | tr.treenode-hidden { 83 | display: none; 84 | } 85 | 86 | td.action-checkbox{ 87 | text-align: center; 88 | } 89 | 90 | .dark-theme .treenode-toggle { 91 | color: #ccc; 92 | background-color: #444; 93 | border: 1px solid #555; 94 | } 95 | 96 | .dark-theme .treenode-toggle:hover { 97 | background-color: #333; 98 | color: #fff; 99 | } 100 | 101 | .dark-theme .treenode-toggle { 102 | color: #ccc; 103 | background-color: #444; 104 | border: 1px solid #555; 105 | } 106 | 107 | .dark-theme .treenode-toggle:hover { 108 | background-color: #333; 109 | color: #fff; 110 | } 111 | 112 | .treenode-drag-handle { 113 | display: inline-block; 114 | text-align: center; 115 | font-weight: bold; 116 | font-size: 10px; 117 | width: 10px; 118 | height: 10px; 119 | line-height: 10px; 120 | padding: 1px; 121 | cursor: ns-resize; 122 | opacity: 0.75; 123 | } 124 | 125 | .treenode-drag-handle:hover { 126 | opacity: 1.0; 127 | } 128 | 129 | .dark-theme treenode-drag-handle { 130 | color: #ccc; 131 | } 132 | 133 | .treenode-wrapper { 134 | display: inline-block; 135 | } 136 | 137 | tr.treenode-placeholder td { 138 | background-color: #eef; 139 | border-top: 1px solid var(--hairline-color); 140 | border-bottom: 1px solid var(--hairline-color); 141 | border-left: 0px; 142 | margin: 0; 143 | padding: 0; 144 | height: 30px; 145 | } 146 | 147 | tr.target-as-child{ 148 | border-left: 4px solid #4caf50; 149 | transition: border-left 0.2s ease; 150 | } 151 | 152 | tr.target-as-child td { 153 | background-color: #d9fbe3 !important; 154 | transition: background 0.2s ease; 155 | } 156 | 157 | tr.flash-insert td { 158 | animation: flash-green 0.6s ease-in-out; 159 | } 160 | 161 | @keyframes flash-green { 162 | 0% { background-color: #dbffe0; } 163 | 100% { background-color: transparent; } 164 | } 165 | 166 | tr.dragging td { 167 | opacity: 0.6; 168 | background-color: #f8f8f8; 169 | } 170 | 171 | .column-treenode_field { 172 | width: 100%; 173 | } 174 | 175 | 176 | -------------------------------------------------------------------------------- /treenode/static/css/treenode_tabs.css: -------------------------------------------------------------------------------- 1 | .tabs { 2 | list-style: none; 3 | padding: 0 !important; 4 | margin:0 !important; 5 | display: flex; 6 | gap: 0em; 7 | border-bottom: 1px solid var(--border-color); 8 | } 9 | 10 | .tab { 11 | padding: 0.5em 1em; 12 | cursor: pointer; 13 | color: var(--object-tools-fg); 14 | background: var(--object-tools-bg); 15 | border-radius: 4px; 16 | } 17 | 18 | .tab.active { 19 | background: var(--primary); 20 | font-weight: bold; 21 | } 22 | 23 | .tab-content { 24 | border: 1px solid var(--border-color); 25 | padding: 1em; 26 | border-radius: 4px; 27 | margin-top: 1em; 28 | } 29 | 30 | .tabs li.tab { 31 | list-style-type: none; 32 | margin: 0; 33 | border-radius: 5px 5px 0 0; 34 | } 35 | 36 | .tab-content a.button { 37 | display: inline-block; 38 | text-align: center; 39 | padding: 10px 10px !important; 40 | width: 110px; 41 | 42 | } 43 | 44 | .tab-content form > .form-row { 45 | height: 60px; 46 | } 47 | 48 | .tab-content .button.default { 49 | display: inline-block; 50 | width: 130px; 51 | } -------------------------------------------------------------------------------- /treenode/static/js/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /treenode/static/js/lz-string.min.js: -------------------------------------------------------------------------------- 1 | var LZString=function(){function o(o,r){if(!t[o]){t[o]={};for(var n=0;ne;e++){var s=r.charCodeAt(e);n[2*e]=s>>>8,n[2*e+1]=s%256}return n},decompressFromUint8Array:function(o){if(null===o||void 0===o)return i.decompress(o);for(var n=new Array(o.length/2),e=0,t=n.length;t>e;e++)n[e]=256*o[2*e]+o[2*e+1];var s=[];return n.forEach(function(o){s.push(r(o))}),i.decompress(s.join(""))},compressToEncodedURIComponent:function(o){return null==o?"":i._compress(o,6,function(o){return e.charAt(o)})},decompressFromEncodedURIComponent:function(r){return null==r?"":""==r?null:(r=r.replace(/ /g,"+"),i._decompress(r.length,32,function(n){return o(e,r.charAt(n))}))},compress:function(o){return i._compress(o,16,function(o){return r(o)})},_compress:function(o,r,n){if(null==o)return"";var e,t,i,s={},p={},u="",c="",a="",l=2,f=3,h=2,d=[],m=0,v=0;for(i=0;ie;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++),s[c]=f++,a=String(u)}if(""!==a){if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++)}for(t=2,e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;for(;;){if(m<<=1,v==r-1){d.push(n(m));break}v++}return d.join("")},decompress:function(o){return null==o?"":""==o?null:i._decompress(o.length,32768,function(r){return o.charCodeAt(r)})},_decompress:function(o,n,e){var t,i,s,p,u,c,a,l,f=[],h=4,d=4,m=3,v="",w=[],A={val:e(0),position:n,index:1};for(i=0;3>i;i+=1)f[i]=i;for(p=0,c=Math.pow(2,2),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(t=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 2:return""}for(f[3]=l,s=l,w.push(l);;){if(A.index>o)return"";for(p=0,c=Math.pow(2,m),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(l=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 2:return w.join("")}if(0==h&&(h=Math.pow(2,m),m++),f[l])v=f[l];else{if(l!==d)return null;v=s+s.charAt(0)}w.push(v),f[d++]=s+v.charAt(0),h--,s=v,0==h&&(h=Math.pow(2,m),m++)}}};return i}();"function"==typeof define&&define.amd?define(function(){return LZString}):"undefined"!=typeof module&&null!=module&&(module.exports=LZString); -------------------------------------------------------------------------------- /treenode/static/vendors/jquery-ui/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright OpenJS Foundation and other contributors, https://openjsf.org/ 2 | 3 | This software consists of voluntary contributions made by many 4 | individuals. For exact contribution history, see the revision history 5 | available at https://github.com/jquery/jquery-ui 6 | 7 | The following license applies to all parts of this software except as 8 | documented below: 9 | 10 | ==== 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining 13 | a copy of this software and associated documentation files (the 14 | "Software"), to deal in the Software without restriction, including 15 | without limitation the rights to use, copy, modify, merge, publish, 16 | distribute, sublicense, and/or sell copies of the Software, and to 17 | permit persons to whom the Software is furnished to do so, subject to 18 | the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be 21 | included in all copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 24 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 25 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 26 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 27 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 28 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 29 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 30 | 31 | ==== 32 | 33 | Copyright and related rights for sample code are waived via CC0. Sample 34 | code is defined as all source code contained within the demos directory. 35 | 36 | CC0: http://creativecommons.org/publicdomain/zero/1.0/ 37 | 38 | ==== 39 | 40 | All files located in the node_modules and external directories are 41 | externally maintained libraries used by this software which have their 42 | own licenses; we recommend you read them, as their terms may differ from 43 | the terms above. 44 | -------------------------------------------------------------------------------- /treenode/static/vendors/jquery-ui/images/ui-icons_444444_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimurKady/django-fast-treenode/b1880330f0d49aa1a418df70e5aa7827ea1baae2/treenode/static/vendors/jquery-ui/images/ui-icons_444444_256x240.png -------------------------------------------------------------------------------- /treenode/static/vendors/jquery-ui/images/ui-icons_555555_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimurKady/django-fast-treenode/b1880330f0d49aa1a418df70e5aa7827ea1baae2/treenode/static/vendors/jquery-ui/images/ui-icons_555555_256x240.png -------------------------------------------------------------------------------- /treenode/static/vendors/jquery-ui/images/ui-icons_777620_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimurKady/django-fast-treenode/b1880330f0d49aa1a418df70e5aa7827ea1baae2/treenode/static/vendors/jquery-ui/images/ui-icons_777620_256x240.png -------------------------------------------------------------------------------- /treenode/static/vendors/jquery-ui/images/ui-icons_777777_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimurKady/django-fast-treenode/b1880330f0d49aa1a418df70e5aa7827ea1baae2/treenode/static/vendors/jquery-ui/images/ui-icons_777777_256x240.png -------------------------------------------------------------------------------- /treenode/static/vendors/jquery-ui/images/ui-icons_cc0000_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimurKady/django-fast-treenode/b1880330f0d49aa1a418df70e5aa7827ea1baae2/treenode/static/vendors/jquery-ui/images/ui-icons_cc0000_256x240.png -------------------------------------------------------------------------------- /treenode/static/vendors/jquery-ui/images/ui-icons_ffffff_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimurKady/django-fast-treenode/b1880330f0d49aa1a418df70e5aa7827ea1baae2/treenode/static/vendors/jquery-ui/images/ui-icons_ffffff_256x240.png -------------------------------------------------------------------------------- /treenode/static/vendors/jquery-ui/jquery-ui.structure.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * jQuery UI CSS Framework 1.14.1 3 | * https://jqueryui.com 4 | * 5 | * Copyright OpenJS Foundation and other contributors 6 | * Released under the MIT license. 7 | * https://jquery.org/license 8 | * 9 | * https://api.jqueryui.com/category/theming/ 10 | */ 11 | .ui-draggable-handle { 12 | touch-action: none; 13 | } 14 | .ui-sortable-handle { 15 | touch-action: none; 16 | } 17 | -------------------------------------------------------------------------------- /treenode/static/vendors/jquery-ui/jquery-ui.structure.min.css: -------------------------------------------------------------------------------- 1 | /*! jQuery UI - v1.14.1 - 2025-04-13 2 | * https://jqueryui.com 3 | * Copyright OpenJS Foundation and other contributors; Licensed MIT */ 4 | 5 | .ui-draggable-handle{touch-action:none}.ui-sortable-handle{touch-action:none} -------------------------------------------------------------------------------- /treenode/static/vendors/jquery-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jquery-ui", 3 | "title": "jQuery UI", 4 | "description": "A curated set of user interface interactions, effects, widgets, and themes built on top of the jQuery JavaScript Library.", 5 | "version": "1.14.1", 6 | "homepage": "https://jqueryui.com", 7 | "author": { 8 | "name": "OpenJS Foundation and other contributors", 9 | "url": "https://github.com/jquery/jquery-ui/blob/1.14.1/AUTHORS.txt" 10 | }, 11 | "main": "ui/widget.js", 12 | "maintainers": [ 13 | { 14 | "name": "Jörn Zaefferer", 15 | "email": "joern.zaefferer@gmail.com", 16 | "url": "https://bassistance.de" 17 | }, 18 | { 19 | "name": "Mike Sherov", 20 | "email": "mike.sherov@gmail.com", 21 | "url": "https://mike.sherov.com" 22 | }, 23 | { 24 | "name": "TJ VanToll", 25 | "email": "tj.vantoll@gmail.com", 26 | "url": "https://www.tjvantoll.com" 27 | }, 28 | { 29 | "name": "Felix Nagel", 30 | "email": "info@felixnagel.com", 31 | "url": "https://www.felixnagel.com" 32 | }, 33 | { 34 | "name": "Alex Schmitz", 35 | "email": "arschmitz@gmail.com", 36 | "url": "https://github.com/arschmitz" 37 | } 38 | ], 39 | "repository": { 40 | "type": "git", 41 | "url": "git://github.com/jquery/jquery-ui.git" 42 | }, 43 | "bugs": { 44 | "url": "https://github.com/jquery/jquery-ui/issues" 45 | }, 46 | "license": "MIT", 47 | "scripts": { 48 | "build": "grunt build", 49 | "lint": "grunt lint", 50 | "test:server": "node tests/runner/server.js", 51 | "test:unit": "node tests/runner/command.js", 52 | "test": "grunt && npm run test:unit -- -h" 53 | }, 54 | "dependencies": { 55 | "jquery": ">=1.12.0 <5.0.0" 56 | }, 57 | "devDependencies": { 58 | "body-parser": "1.20.3", 59 | "browserstack-local": "1.5.5", 60 | "commitplease": "3.2.0", 61 | "diff": "5.2.0", 62 | "eslint-config-jquery": "3.0.2", 63 | "exit-hook": "4.0.0", 64 | "express": "4.21.1", 65 | "express-body-parser-error-handler": "1.0.7", 66 | "grunt": "1.6.1", 67 | "grunt-bowercopy": "1.2.5", 68 | "grunt-compare-size": "0.4.2", 69 | "grunt-contrib-concat": "2.1.0", 70 | "grunt-contrib-csslint": "2.0.0", 71 | "grunt-contrib-requirejs": "1.0.0", 72 | "grunt-contrib-uglify": "5.2.2", 73 | "grunt-eslint": "24.0.1", 74 | "grunt-git-authors": "3.2.0", 75 | "grunt-html": "17.1.0", 76 | "load-grunt-tasks": "5.1.0", 77 | "rimraf": "6.0.1", 78 | "selenium-webdriver": "4.26.0", 79 | "yargs": "17.7.2" 80 | }, 81 | "keywords": [] 82 | } 83 | -------------------------------------------------------------------------------- /treenode/templates/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /treenode/templates/treenode/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /treenode/templates/treenode/admin/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /treenode/templates/treenode/admin/treenode_ajax_rows.html: -------------------------------------------------------------------------------- 1 | {% for row in rows %} 2 | 3 | {% for cell, class in row.cells %} 4 | {{ cell|safe }} 5 | {% endfor %} 6 | 7 | {% endfor %} 8 | -------------------------------------------------------------------------------- /treenode/templates/treenode/admin/treenode_changelist.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load admin_list %} 3 | {% load i18n %} 4 | {% load treenode_admin %} 5 | {% load static %} 6 | 7 | {% block content %} 8 | 9 | {% block object-tools %} 10 | {% if has_add_permission %} 11 |
    12 | {% block object-tools-items %} 13 | {{ block.super }} 14 |
  • Import
  • 15 |
  • Export
  • 16 | {% endblock %} 17 |
18 | {% endif %} 19 | {% endblock %} 20 | 21 |
22 |
23 | 24 | {# Search bar + expand/collapse buttons #} 25 | {% block search %} 26 |
27 | 40 |
41 | {% endblock %} 42 | 43 | {# Form with list and actions #} 44 |
45 | {% csrf_token %} 46 | 47 | {% if action_form and actions_on_top and cl.show_admin_actions %} 48 | {% admin_actions %} 49 | {% endif %} 50 | 51 | {% block result_list %} 52 | {% tree_result_list cl %} 53 | {% endblock %} 54 | 55 | {% if action_form and actions_on_bottom and cl.show_admin_actions %} 56 | {% admin_actions %} 57 | {% endif %} 58 | 59 | {% block pagination %} 60 | {{ block.super }} 61 | {% endblock %} 62 |
63 |
64 | 65 | {# 📎 Боковая панель фильтров #} 66 | {% block filters %} 67 | {{ block.super }} 68 | {% endblock %} 69 |
70 | {% endblock %} 71 | 72 | -------------------------------------------------------------------------------- /treenode/templates/treenode/admin/treenode_import_export.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load static %} 3 | 4 | {% block extrastyle %} 5 | {{ block.super }} 6 | 7 | 8 | {% endblock %} 9 | 10 | {% block content %} 11 |

Data Exchange Center

12 | 13 |
14 |
    15 |
  • Export
  • 16 |
  • Import
  • 17 |
18 |
19 | 20 |
21 |
22 |
23 | 24 | 31 |
32 |
33 | 34 | Back to list 35 |
36 |
37 |
38 | 39 |
40 |
41 | {% csrf_token %} 42 |
43 | 44 | 45 |
46 |
47 | 48 | Back to list 49 |
50 |
51 |
52 | 53 | {% if errors or created_count or updated_count %} 54 |
    55 | {% if created_count %}
  • Successfully created: {{ created_count }}
  • {% endif %} 56 | {% if updated_count %}
  • Successfully updated: {{ updated_count }}
  • {% endif %} 57 | {% if errors %}
  • Errors: {{ errors|length }}
  • {% endif %} 58 |
59 | {% if errors %} 60 |
61 |
    62 | {% for error in errors %} 63 |
  • {{ error }}
  • 64 | {% endfor %} 65 |
66 |
67 | {% endif %} 68 | {% endif %} 69 | 70 | 71 | 84 | 85 | {% endblock %} 86 | -------------------------------------------------------------------------------- /treenode/templates/treenode/admin/treenode_rows.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |
4 | 5 | 6 | 7 | {% for h in headers %} 8 | 39 | {% endfor %} 40 | 41 | 42 | 43 | 44 | {% for row in rows %} 45 | 46 | {% for cell in row.cells %} 47 | {{ cell }} 48 | {% endfor %} 49 | 50 | {% endfor %} 51 | 52 | 53 |
9 | {% if "action-checkbox-column" in h.class_attrib %} 10 |
11 | 12 | 13 | 14 |
15 |
16 | {% else %} 17 | {% if h.sortable %} 18 |
19 | {% if h.url_remove %} 20 | 21 | {% endif %} 22 | {% if h.url_toggle %} 23 | 24 | {% endif %} 25 |
26 | {% else %} 27 |
28 | {% endif %} 29 |
30 | {% if h.sortable %} 31 | {{ h.text|safe }} 32 | {% else %} 33 | {{ h.text|safe }} 34 | {% endif %} 35 |
36 |
37 | {% endif %} 38 |
54 |
55 | -------------------------------------------------------------------------------- /treenode/templates/treenode/widgets/tree_widget.html: -------------------------------------------------------------------------------- 1 | {# templates/widgets/tree_widget.html #} 2 | {% load widget_tweaks %} 3 | 22 | -------------------------------------------------------------------------------- /treenode/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimurKady/django-fast-treenode/b1880330f0d49aa1a418df70e5aa7827ea1baae2/treenode/templatetags/__init__.py -------------------------------------------------------------------------------- /treenode/templatetags/treenode_admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Custom tags for changelist template. 4 | 5 | Version: 3.1.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | 11 | from django import template 12 | from django.contrib.admin.templatetags import admin_list 13 | from django.utils.html import format_html, mark_safe 14 | 15 | register = template.Library() 16 | 17 | 18 | @register.inclusion_tag("treenode/admin/treenode_rows.html", takes_context=True) 19 | def tree_result_list(context, cl): 20 | """Get result list.""" 21 | headers = list(admin_list.result_headers(cl)) 22 | 23 | # Add a checkbox title manually if it is missing 24 | if cl.actions and not any("action-checkbox-column" in h["class_attrib"] for h in headers): 25 | headers.insert(0, { 26 | "text": "", 27 | "class_attrib": ' class="action-checkbox-column"', 28 | "sortable": False 29 | }) 30 | 31 | rows = [] 32 | 33 | for obj in cl.result_list: 34 | cells = list(admin_list.items_for_result(cl, obj, None)) 35 | 36 | # Insert checkbox manually 37 | checkbox = format_html( 38 | '' 39 | '', 40 | obj.pk 41 | ) 42 | cells.insert(0, checkbox) 43 | 44 | # Replace the toggle cell (3rd after inserting checkbox and move) 45 | is_leaf = getattr(obj, "is_leaf", lambda: True)() 46 | toggle_html = format_html( 47 | '{}', 48 | format_html( 49 | '', 50 | obj.pk 51 | ) if not is_leaf else mark_safe('
 
') 52 | ) 53 | if len(cells) >= 3: 54 | cells.pop(2) 55 | cells.insert(2, toggle_html) 56 | 57 | depth = getattr(obj, "get_depth", lambda: 0)() 58 | parent_id = getattr(obj, "parent_id", "") 59 | is_root = not parent_id 60 | 61 | classes = ["treenode-row"] 62 | if is_root: 63 | classes.append("treenode-root") 64 | else: 65 | classes.append("treenode-hidden") 66 | 67 | row_attrs = { 68 | "class": " ".join(classes), 69 | "data-node-id": obj.pk, 70 | "data-parent-id": parent_id or "", 71 | "data-depth": depth, 72 | } 73 | 74 | rows.append({ 75 | "attrs": " ".join(f'{k}="{v}"' for k, v in row_attrs.items()), 76 | "cells": cells, 77 | "form": None, 78 | "is_leaf": is_leaf, 79 | "node_id": obj.pk, 80 | }) 81 | 82 | return { 83 | **context.flatten(), 84 | "headers": headers, 85 | "rows": rows, 86 | "num_sorted_fields": sum( 87 | 1 for h in headers if h["sortable"] and h["sorted"] 88 | ), 89 | } 90 | -------------------------------------------------------------------------------- /treenode/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /treenode/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode URLs Module. 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from .views import AutoTreeAPI 11 | 12 | app_name = "treenode" 13 | 14 | 15 | urlpatterns = [ 16 | *AutoTreeAPI().discover(), 17 | ] 18 | -------------------------------------------------------------------------------- /treenode/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimurKady/django-fast-treenode/b1880330f0d49aa1a418df70e5aa7827ea1baae2/treenode/utils/__init__.py -------------------------------------------------------------------------------- /treenode/utils/db/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .service import ModelSQLService 3 | from .sqlquery import SQLQueue 4 | from .sqlcompat import SQLCompat 5 | from .compiler import TreePathCompiler 6 | 7 | __all__ = ['ModelSQLService', 'SQLQueue', 'SQLCompat', 'TreePathCompiler'] 8 | -------------------------------------------------------------------------------- /treenode/utils/db/compiler.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Tree update task compiler class. 4 | 5 | Compiles tasks to low-level SQL to update the materialized path (_path), depth 6 | (_depth), and node order (priority) when they are shifted or moved. 7 | 8 | Version: 3.1.0 9 | Author: Timur Kady 10 | Email: timurkady@yandex.com 11 | """ 12 | 13 | from django.db import connection 14 | 15 | from ...settings import SEGMENT_LENGTH 16 | from .sqlcompat import SQLCompat 17 | 18 | 19 | class TreePathCompiler: 20 | """ 21 | Tree Task compiler class. 22 | 23 | Efficient, ORM-free computation of _path, _depth and priority 24 | for tree structures based on Materialized Path. 25 | """ 26 | 27 | @classmethod 28 | def update_path(cls, model, parent_id=None): 29 | """ 30 | Rebuild subtree starting from parent_id. 31 | 32 | If parent_id=None, then the whole tree is rebuilt. 33 | Uses only fields: parent_id and id. All others (priority, _path, 34 | _depth) are recalculated. 35 | """ 36 | db_table = model._meta.db_table 37 | # Will eliminate the risk if the user names the model order or user. 38 | qname = connection.ops.quote_name(db_table) 39 | 40 | sorting_field = model.sorting_field 41 | sorting_fields = ["priority", "id"] if sorting_field == "priority" else [sorting_field] # noqa: D5017 42 | sort_expr = ", ".join([ 43 | f"c.{field}" if "." not in field else field 44 | for field in sorting_fields 45 | ]) 46 | 47 | cte_header = "(id, parent_id, new_priority, new_path, new_depth)" 48 | 49 | row_number_expr = f"ROW_NUMBER() OVER (ORDER BY {sort_expr}) - 1" 50 | hex_expr = SQLCompat.to_hex(row_number_expr) 51 | lpad_expr = SQLCompat.lpad(hex_expr, SEGMENT_LENGTH, "'0'") 52 | 53 | if parent_id is None: 54 | new_path_expr = lpad_expr 55 | base_sql = f""" 56 | SELECT 57 | c.id, 58 | c.parent_id, 59 | {row_number_expr} AS new_priority, 60 | {new_path_expr} AS new_path, 61 | 0 AS new_depth 62 | FROM {qname} AS c 63 | WHERE c.parent_id IS NULL 64 | """ 65 | params = [] 66 | else: 67 | path_expr = SQLCompat.concat("p._path", "'.'", lpad_expr) 68 | base_sql = f""" 69 | SELECT 70 | c.id, 71 | c.parent_id, 72 | {row_number_expr} AS new_priority, 73 | {path_expr} AS new_path, 74 | p._depth + 1 AS new_depth 75 | FROM {qname} c 76 | JOIN {qname} p ON c.parent_id = p.id 77 | WHERE p.id = %s 78 | """ 79 | params = [parent_id] 80 | 81 | recursive_row_number_expr = f"ROW_NUMBER() OVER (PARTITION BY c.parent_id ORDER BY {sort_expr}) - 1" 82 | recursive_hex_expr = SQLCompat.to_hex(recursive_row_number_expr) 83 | recursive_lpad_expr = SQLCompat.lpad( 84 | recursive_hex_expr, SEGMENT_LENGTH, "'0'") 85 | recursive_path_expr = SQLCompat.concat( 86 | "t.new_path", "'.'", recursive_lpad_expr) 87 | 88 | recursive_sql = f""" 89 | SELECT 90 | c.id, 91 | c.parent_id, 92 | {recursive_row_number_expr} AS new_priority, 93 | {recursive_path_expr} AS new_path, 94 | t.new_depth + 1 AS new_depth 95 | FROM {qname} c 96 | JOIN tree_cte t ON c.parent_id = t.id 97 | """ 98 | 99 | final_sql = SQLCompat.update_from( 100 | db_table=db_table, 101 | cte_header=cte_header, 102 | base_sql=base_sql, 103 | recursive_sql=recursive_sql, 104 | update_fields=["priority", "_path", "_depth"] 105 | ) 106 | 107 | with connection.cursor() as cursor: 108 | # Make params read-only 109 | params = tuple(params) 110 | cursor.execute(final_sql, params) 111 | 112 | 113 | # The End 114 | -------------------------------------------------------------------------------- /treenode/utils/db/db_vendor.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | API-First Support Module. 4 | 5 | CRUD and Tree Operations for TreeNode models. 6 | 7 | Version: 3.0.0 8 | Author: Timur Kady 9 | Email: timurkady@yandex.com 10 | """ 11 | 12 | 13 | from django.db import connection 14 | 15 | _vendor = connection.vendor.lower() 16 | 17 | 18 | def is_postgresql(): 19 | """Return True if DB is PostgreSQL.""" 20 | return _vendor == "postgresql" 21 | 22 | 23 | def is_mysql(): 24 | """Return True if DB is MySQL.""" 25 | return _vendor == "mysql" 26 | 27 | 28 | def is_mariadb(): 29 | """Return True if DB is MariaDB.""" 30 | return _vendor == "mariadb" 31 | 32 | 33 | def is_sqlite(): 34 | """Return True if DB is SQLite.""" 35 | return _vendor == "sqlite" 36 | 37 | 38 | def is_oracle(): 39 | """Return True if DB is Oracle.""" 40 | return _vendor == "oracle" 41 | 42 | 43 | def is_mssql(): 44 | """Return True if DB is Microsoft SQL Server.""" 45 | return _vendor in ("microsoft", "mssql") 46 | 47 | 48 | def get_vendor(): 49 | """Return DB vendor.""" 50 | return _vendor 51 | -------------------------------------------------------------------------------- /treenode/utils/db/service.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | DB Vendor Utility Module 4 | 5 | The module contains utilities related to optimizing the application's operation 6 | with various types of Databases. 7 | 8 | Version: 3.0.0 9 | Author: Timur Kady 10 | Email: timurkady@yandex.com 11 | """ 12 | 13 | from __future__ import annotations 14 | from django.db import models, connection 15 | 16 | 17 | class SQLService: 18 | """SQL utility class bound to a specific model.""" 19 | 20 | def __init__(self, model): 21 | """Init.""" 22 | self.db_vendor = connection.vendor 23 | self.model = model 24 | self.table = model._meta.db_table 25 | 26 | def get_next_id(self): 27 | """Reliably get the next ID for this model on different DBMS.""" 28 | if self.db_vendor == 'postgresql': 29 | seq_name = f"{self.table}_id_seq" 30 | with connection.cursor() as cursor: 31 | cursor.execute(f"SELECT nextval('{seq_name}')") 32 | return cursor.fetchone()[0] 33 | 34 | elif self.db_vendor == 'oracle': 35 | seq_name = f"{self.table}_SEQ" 36 | with connection.cursor() as cursor: 37 | try: 38 | cursor.execute(f"SELECT {seq_name}.NEXTVAL FROM DUAL") 39 | return cursor.fetchone()[0] 40 | except Exception: 41 | cursor.execute( 42 | f"CREATE SEQUENCE {seq_name} START WITH 1 INCREMENT BY 1" 43 | ) 44 | cursor.execute(f"SELECT {seq_name}.NEXTVAL FROM DUAL") 45 | return cursor.fetchone()[0] 46 | 47 | elif self.db_vendor in ('sqlite', 'mysql'): 48 | with connection.cursor() as cursor: 49 | cursor.execute(f"SELECT MAX(id) FROM {self.table}") 50 | row = cursor.fetchone() 51 | return (row[0] or 0) + 1 52 | 53 | else: 54 | raise NotImplementedError( 55 | f"get_next_id() not supported for DB vendor '{self.db_vendor}'") 56 | 57 | def reassign_children(self, old_parent_id, new_parent_id): 58 | """Set new parent to children.""" 59 | sql = f""" 60 | UPDATE {self.table} 61 | SET parent_id = %s 62 | WHERE parent_id = %s 63 | """ 64 | with connection.cursor() as cursor: 65 | cursor.execute(sql, [new_parent_id, old_parent_id]) 66 | 67 | 68 | class ModelSQLService: 69 | """ 70 | Decorate SQLService. 71 | 72 | Descriptor to bind SQLService to Django models via 73 | `db = ModelSQLService()`. 74 | """ 75 | 76 | def __set_name__(self, owner, name): 77 | """Set name.""" 78 | self.model = owner 79 | 80 | def __get__(self, instance, owner): 81 | """Get SQLService.""" 82 | return SQLService(owner) 83 | 84 | # The End 85 | -------------------------------------------------------------------------------- /treenode/utils/db/sqlcompat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Database compatibility extension module. 4 | 5 | Adapts SQL code to the specific features of SQL syntax of various 6 | Database vendors. 7 | 8 | Instead of direct concatenation: 9 | old: p._path || '.' || LPAD(...) 10 | new: SQLCompat.concat("p._path", "'.'", SQLCompat.lpad(...)) 11 | 12 | Instead of TO_HEX(...) 13 | old: TO_HEX(...) 14 | new: SQLCompat.to_hex(...) 15 | 16 | Instead of LPAD(...) 17 | old: LPAD(...) 18 | new: SQLCompat.lpad(...) 19 | 20 | Version: 3.1.0 21 | Author: Timur Kady 22 | Email: timurkady@yandex.com 23 | """ 24 | 25 | from django.db import connection 26 | from .db_vendor import is_mysql, is_mariadb, is_sqlite, is_mssql 27 | from ...settings import TREENODE_PAD_CHAR 28 | 29 | 30 | class SQLCompat: 31 | """Vendor-Specific SQL Compatibility Layer.""" 32 | 33 | @staticmethod 34 | def concat(*args): 35 | """Adapt string concatenation to the vendor-specific syntax.""" 36 | args_joined = ", ".join(args) 37 | if is_mysql() or is_mariadb(): 38 | return f"CONCAT({args_joined})" 39 | elif is_mssql(): 40 | return " + ".join(args) 41 | else: 42 | return " || ".join(args) 43 | 44 | @staticmethod 45 | def to_hex(value): 46 | """Convert integer to uppercase hexadecimal string.""" 47 | if is_sqlite(): 48 | return f"UPPER(printf('%x', {value}))" 49 | elif is_mysql() or is_mariadb(): 50 | return f"UPPER(CONV({value}, 10, 16))" 51 | else: 52 | return f"UPPER(TO_HEX({value}))" 53 | 54 | @staticmethod 55 | def lpad(value, length, char=TREENODE_PAD_CHAR): 56 | """Pad string to the specified length.""" 57 | if is_sqlite(): 58 | return f"printf('%0{length}s', {value})" 59 | else: 60 | return f"LPAD({value}, {length}, {char})" 61 | 62 | @staticmethod 63 | def update_from(db_table, cte_header, base_sql, recursive_sql, update_fields): 64 | """ 65 | Generate final SQL for updating via recursive CTE. 66 | 67 | PostgreSQL uses UPDATE ... FROM. 68 | Other engines use vendor-specific strategies. 69 | """ 70 | qt = connection.ops.quote_name(db_table) 71 | def qf(f): return connection.ops.quote_name(f) 72 | 73 | cte_alias = { 74 | "priority": "new_priority", 75 | "_path": "new_path", 76 | "_depth": "new_depth", 77 | } 78 | 79 | if connection.vendor == "postgresql": 80 | set_clause = ", ".join( 81 | f"{qf(f)} = t.{cte_alias.get(f, f)}" for f in update_fields 82 | ) 83 | return f""" 84 | WITH RECURSIVE tree_cte {cte_header} AS ( 85 | {base_sql} 86 | UNION ALL 87 | {recursive_sql} 88 | ) 89 | UPDATE {qt} AS orig 90 | SET {set_clause} 91 | FROM tree_cte t 92 | WHERE orig.id = t.id; 93 | """ 94 | 95 | elif connection.vendor in {"microsoft", "mssql"}: 96 | set_clause = ", ".join( 97 | f"{qt}.{f} = t.{f}" for f in update_fields 98 | ) 99 | return f""" 100 | WITH tree_cte {cte_header} AS ( 101 | {base_sql} 102 | UNION ALL 103 | {recursive_sql} 104 | ) 105 | UPDATE orig 106 | SET {set_clause} 107 | FROM {qt} AS orig 108 | JOIN tree_cte t ON orig.id = t.id; 109 | """ 110 | 111 | elif connection.vendor == "oracle": 112 | set_clause = ", ".join( 113 | f"orig.{f} = t.{f}" for f in update_fields 114 | ) 115 | return f""" 116 | WITH tree_cte {cte_header} AS ( 117 | {base_sql} 118 | UNION ALL 119 | {recursive_sql} 120 | ) 121 | MERGE INTO {qt} orig 122 | USING tree_cte t 123 | ON (orig.id = t.id) 124 | WHEN MATCHED THEN UPDATE SET 125 | {set_clause}; 126 | """ 127 | 128 | else: 129 | set_clause = ", ".join( 130 | f"{qf(f)} = (SELECT t.{f} FROM tree_cte t WHERE t.id = {qt}.id)" 131 | for f in update_fields 132 | ) 133 | where_clause = f"id IN (SELECT id FROM tree_cte)" 134 | return f""" 135 | WITH RECURSIVE tree_cte {cte_header} AS ( 136 | {base_sql} 137 | UNION ALL 138 | {recursive_sql} 139 | ) 140 | UPDATE {qt} 141 | SET {set_clause} 142 | WHERE {where_clause}; 143 | """ 144 | 145 | # The End 146 | -------------------------------------------------------------------------------- /treenode/utils/db/sqlquery.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | SQL Queue Class. 4 | 5 | SQL query queue supporting Query and (sql, params). 6 | 7 | Version: 3.0.0 8 | Author: Timur Kady 9 | Email: timurkady@yandex.com 10 | """ 11 | 12 | 13 | from __future__ import annotations 14 | from django.db import connections, connection, DEFAULT_DB_ALIAS 15 | from typing import Union, Tuple, List 16 | from django.db.models.sql import Query 17 | 18 | 19 | class SQLQueue: 20 | """SQL Queue Class.""" 21 | 22 | def __init__(self, using: str = DEFAULT_DB_ALIAS): 23 | """Init Queue.""" 24 | self.using = using 25 | self._items: List[Tuple[str, list]] = [] 26 | 27 | def append(self, item: Union[Query, Tuple[str, list]]): 28 | """ 29 | Add a query to the queue. 30 | 31 | Supports: 32 | - Django Query: model.objects.filter(...).query 33 | - tuple (sql, params) 34 | """ 35 | if isinstance(item, tuple): 36 | sql, params = item 37 | if not isinstance(sql, str) or not isinstance(params, (list, tuple)): # noqa: D501 38 | raise TypeError("Ожидается (sql: str, params: list | tuple)") 39 | self._items.append((sql, list(params))) 40 | 41 | elif isinstance(item, Query): 42 | sql, params = item.as_sql(connection) 43 | self._items.append((sql, params)) 44 | 45 | else: 46 | raise TypeError("Expected either Query or (sql, params)") 47 | 48 | def flush(self): 49 | """ 50 | Execute all requests from the queue and clear it. 51 | 52 | flush() is called manually. 53 | """ 54 | if not self._items: 55 | return 56 | 57 | # print("sqlq: ", self._items) 58 | 59 | conn = connections[self.using] 60 | with conn.cursor() as cursor: 61 | for sql, params in self._items: 62 | try: 63 | cursor.execute(sql, params) 64 | except Exception as e: 65 | print(">>> SQLQueue error:", e) 66 | raise 67 | self._items.clear() 68 | 69 | 70 | # The End 71 | -------------------------------------------------------------------------------- /treenode/version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Version Module 4 | 5 | This module defines the current version of the TreeNode package. 6 | 7 | Version: 3.0.8 8 | Author: Timur Kady 9 | Email: timurkady@yandex.com 10 | """ 11 | 12 | __version__ = '3.0.8' 13 | -------------------------------------------------------------------------------- /treenode/views/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # from .crud import * 3 | from .autoapi import AutoTreeAPI 4 | 5 | __all__ = ['AutoTreeAPI'] 6 | -------------------------------------------------------------------------------- /treenode/views/autoapi.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Route generator for all models inherited from TreeNodeModel 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | 11 | from django.apps import apps 12 | from django.urls import path 13 | from django.conf import settings 14 | from django.contrib.auth.decorators import login_required 15 | 16 | from ..models import TreeNodeModel 17 | from .autocomplete import TreeNodeAutocompleteView 18 | from .children import TreeChildrenView 19 | from .search import TreeSearchView 20 | from .crud import TreeNodeBaseAPIView 21 | 22 | 23 | class AutoTreeAPI: 24 | """Auto-discover and expose TreeNode-based APIs.""" 25 | 26 | def __init__(self, base_view=TreeNodeBaseAPIView, base_url="api"): 27 | """Init auto-discover.""" 28 | self.base_view = base_view 29 | self.base_url = base_url 30 | 31 | def protect_view(self, view, model): 32 | """ 33 | Protect view. 34 | 35 | Protects view with login_required if needed, based on model attribute 36 | or global settings. 37 | """ 38 | if getattr(model, 'api_login_required', None) is True: 39 | return login_required(view) 40 | if getattr(settings, 'TREENODE_API_LOGIN_REQUIRED', False): 41 | return login_required(view) 42 | return view 43 | 44 | def discover(self): 45 | """Scan models and generate API urls.""" 46 | urls = [ 47 | # Admin and Widget end-points 48 | path("widget/autocomplete/", TreeNodeAutocompleteView.as_view(), name="tree_autocomplete"), # noqa: D501 49 | path("widget/children/", TreeChildrenView.as_view(), name="tree_children"), # noqa: D501 50 | path("widget/search/", TreeSearchView.as_view(), name="tree_search"), # noqa: D501 51 | ] 52 | for model in apps.get_models(): 53 | if issubclass(model, TreeNodeModel) and model is not TreeNodeModel: 54 | model_name = model._meta.model_name 55 | 56 | # Dynamically create an API view class for the model 57 | api_view_class = type( 58 | f"{model_name.capitalize()}APIView", 59 | (self.base_view,), 60 | {"model": model} 61 | ) 62 | 63 | # List of API actions and their corresponding URL patterns 64 | action_patterns = [ 65 | # List / Create 66 | ("", None, f"{model_name}-list"), 67 | # Retrieve / Update / Delete 68 | ("/", None, f"{model_name}-detail"), 69 | ("tree/", {'action': 'tree'}, f"{model_name}-tree"), 70 | # Direct children 71 | ("/children/", {'action': 'children'}, f"{model_name}-children"), # noqa: D501 72 | # All descendants 73 | ("/descendants/", {'action': 'descendants'}, f"{model_name}-descendants"), # noqa: D501 74 | # Ancestors + Self + Descendants 75 | ("/family/", {'action': 'family'}, f"{model_name}-family"), # noqa: D501 76 | ] 77 | 78 | # Create secured view instance once 79 | view = self.protect_view(api_view_class.as_view(), model) 80 | 81 | # Automatically build all paths for this model 82 | for url_suffix, extra_kwargs, route_name in action_patterns: 83 | urls.append( 84 | path( 85 | f"{self.base_url}/{model_name}/{url_suffix}", 86 | view, 87 | extra_kwargs or {}, 88 | name=route_name 89 | ) 90 | ) 91 | return urls 92 | -------------------------------------------------------------------------------- /treenode/views/autocomplete.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | 5 | Handles autocomplete suggestions for TreeNode models. 6 | 7 | Version: 3.0.0 8 | Author: Timur Kady 9 | Email: timurkady@yandex.com 10 | """ 11 | 12 | # autocomplete.py 13 | from django.http import JsonResponse 14 | from django.views import View 15 | from django.contrib.admin.views.decorators import staff_member_required 16 | from django.utils.decorators import method_decorator 17 | 18 | from .common import get_model_from_request 19 | 20 | 21 | @method_decorator(staff_member_required, name='dispatch') 22 | class TreeNodeAutocompleteView(View): 23 | """Widget Autocomplete View.""" 24 | 25 | def get(self, request, *args, **kwargs): 26 | """Get request.""" 27 | select_id = request.GET.get("select_id", "").strip() 28 | q = request.GET.get("q", "").strip() 29 | 30 | model = get_model_from_request(request) 31 | results = [] 32 | 33 | if q: 34 | field = getattr(model, "display_field", "id") 35 | queryset = model.objects.filter(**{f"{field}__icontains": q}) 36 | elif select_id: 37 | pk = int(select_id) 38 | queryset = model.objects.filter(pk=pk) 39 | else: 40 | queryset = model.objects.filter(parent__isnull=True) 41 | 42 | results = [ 43 | { 44 | "id": obj.pk, 45 | "text": str(obj), 46 | "level": obj.get_depth(), 47 | "is_leaf": obj.is_leaf(), 48 | } 49 | for obj in queryset[:20] 50 | ] 51 | 52 | return JsonResponse({"results": results}) 53 | -------------------------------------------------------------------------------- /treenode/views/children.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | Version: 3.0.0 5 | Author: Timur Kady 6 | Email: timurkady@yandex.com 7 | """ 8 | 9 | from django.http import JsonResponse 10 | from django.views import View 11 | from django.contrib.admin.views.decorators import staff_member_required 12 | from django.utils.decorators import method_decorator 13 | 14 | from .common import get_model_from_request 15 | 16 | 17 | @method_decorator(staff_member_required, name='dispatch') 18 | class TreeChildrenView(View): 19 | def get(self, request, *args, **kwargs): 20 | model = get_model_from_request(request) 21 | reference_id = request.GET.get("reference_id") 22 | if not reference_id: 23 | return JsonResponse({"results": []}) 24 | 25 | obj = model.objects.filter(pk=reference_id).first() 26 | if not obj or obj.is_leaf(): 27 | return JsonResponse({"results": []}) 28 | 29 | results = [ 30 | { 31 | "id": node.pk, 32 | "text": str(node), 33 | "level": node.get_depth(), 34 | "is_leaf": node.is_leaf(), 35 | } 36 | for node in obj.get_children() 37 | ] 38 | return JsonResponse({"results": results}) 39 | 40 | 41 | # The End 42 | -------------------------------------------------------------------------------- /treenode/views/common.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Created on Thu Apr 10 19:50:23 2025 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from django.apps import apps 11 | from django.http import Http404 12 | 13 | 14 | def get_model_from_request(request): 15 | """Get model from request.""" 16 | model_label = request.GET.get("model") 17 | if not model_label: 18 | raise Http404("Missing 'model' parameter.") 19 | try: 20 | app_label, model_name = model_label.lower().split(".") 21 | return apps.get_model(app_label, model_name) 22 | except Exception: 23 | raise Http404(f"Invalid model format: {model_label}") 24 | -------------------------------------------------------------------------------- /treenode/views/search.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Created on Thu Apr 10 19:53:56 2025 4 | 5 | Version: 3.0.0 6 | Author: Timur Kady 7 | Email: timurkady@yandex.com 8 | """ 9 | 10 | from django.http import JsonResponse 11 | from django.views import View 12 | from django.utils.decorators import method_decorator 13 | from django.contrib.admin.views.decorators import staff_member_required 14 | from django.apps import apps 15 | from django.db.models import Q 16 | 17 | 18 | @method_decorator(staff_member_required, name="dispatch") 19 | class TreeSearchView(View): 20 | """Search view for TreeNode models used in admin interface.""" 21 | 22 | def get(self, request, *args, **kwargs): 23 | app_label = request.GET.get("app") 24 | model_name = request.GET.get("model") 25 | query = request.GET.get("q", "").strip() 26 | 27 | if not (app_label and model_name and query): 28 | return JsonResponse({"results": []}) 29 | 30 | try: 31 | model = apps.get_model(app_label, model_name) 32 | except LookupError: 33 | return JsonResponse({"results": []}) 34 | 35 | # Получаем queryset и ищем по __str__, через Q с contains 36 | queryset = model.objects.all() 37 | queryset = queryset.filter( 38 | Q(name__icontains=query) | Q(pk__icontains=query))[:20] 39 | 40 | results = [ 41 | { 42 | "id": obj.pk, 43 | "text": str(obj), 44 | "is_leaf": obj.is_leaf(), 45 | } 46 | for obj in queryset 47 | ] 48 | return JsonResponse({"results": results}) 49 | -------------------------------------------------------------------------------- /treenode/widgets.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | TreeNode Widgets Module 4 | 5 | This module defines a custom form widget for handling hierarchical data 6 | within Django's admin interface. It replaces the standard 94 |
95 |
96 | 🔎︎ 97 | 98 | 99 |
100 |
    101 |
    102 | 103 | """.format( 104 | attrs=" ".join(f'{key}="{val}"' for key, val in attrs.items()), 105 | search_placeholder=_("Search node..."), 106 | selected_value=selected_value 107 | ) 108 | 109 | return mark_safe(html) 110 | 111 | 112 | # The End 113 | --------------------------------------------------------------------------------