├── .gitignore ├── LICENSE ├── README.rst ├── closure_tree ├── __init__.py ├── __version__.py ├── fields.py ├── migrations.py └── models.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | *.pyc 3 | *.swp 4 | build/ 5 | dist/ 6 | *.egg-info/ 7 | 8 | .vscode/ 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Curtis Maloney 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 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django closure tree model. 2 | ========================== 3 | 4 | 5 | Abstract base model for creating a Closure Tree using a recursive Postgres view. 6 | 7 | https://schinckel.net/2016/01/27/django-trees-via-closure-view/ 8 | 9 | Usage 10 | ===== 11 | 12 | Inherit from the Node model: 13 | 14 | .. code-block:: python 15 | 16 | from closure_tree.models import Node 17 | 18 | 19 | class MyNode(Node): 20 | name = models.CharField(max_length=30) 21 | 22 | 23 | Create migrations: 24 | 25 | .. code-block:: sh 26 | 27 | $ ./manage.py makemigrations 28 | 29 | 30 | Add the CreateTreeClosure migration step: 31 | 32 | .. code-block:: sh 33 | 34 | $ ./manage.py makemigrations --empty myapp 35 | 36 | 37 | .. code-block:: python 38 | 39 | from closure_tree.migrations import CreateTreeClosure 40 | 41 | class Migration(migrations.Migration): 42 | 43 | dependencies = [ 44 | ('dummy', '0001_initial'), 45 | ] 46 | 47 | operations = [ 48 | CreateTreeClosure('MyNode'), 49 | ] 50 | 51 | -------------------------------------------------------------------------------- /closure_tree/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/funkybob/django-closure-tree/23da006aa0fa8ff8d21203b2f59fb76cbb0d3814/closure_tree/__init__.py -------------------------------------------------------------------------------- /closure_tree/__version__.py: -------------------------------------------------------------------------------- 1 | version = (0, 2, 2) 2 | 3 | __version__ = '.'.join(map(str, version)) 4 | -------------------------------------------------------------------------------- /closure_tree/fields.py: -------------------------------------------------------------------------------- 1 | from django.contrib.postgres.fields import ArrayField 2 | from django.db import models 3 | from django.db.models.fields.related import resolve_relation 4 | from django.db.models.utils import make_model_tuple 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | 8 | class ClosureManyToManyField(models.ManyToManyField): 9 | ''' 10 | Pre-configured M2M that defines a 'through' model automatically for the 11 | closure table. 12 | ''' 13 | 14 | def contribute_to_class(self, cls, name, **kwargs): 15 | if not cls._meta.abstract: 16 | # Define through table 17 | meta = type('Meta', (), { 18 | 'db_table': '%s_closure' % cls._meta.db_table, 19 | 'app_label': cls._meta.app_label, 20 | 'db_tablespace': cls._meta.db_tablespace, 21 | 'unique_together': ('ancestor', 'descendant'), 22 | 'verbose_name': _('ancestor-descendant relationship'), 23 | 'verbose_name_plural': _('ancestor-descendant relationships'), 24 | 'apps': cls._meta.apps, 25 | 'managed': False, 26 | }) 27 | # Construct and set the new class. 28 | name_ = '%s_Closure' % cls._meta.model_name 29 | self.remote_field.through = type(name_, (models.Model,), { 30 | 'Meta': meta, 31 | '__module__': cls.__module__, 32 | 'path': ArrayField(base_field=models.IntegerField(), primary_key=True), 33 | 'ancestor': models.ForeignKey( 34 | cls, 35 | related_name='%s+' % name_, 36 | db_tablespace=self.db_tablespace, 37 | db_constraint=self.remote_field.db_constraint, 38 | on_delete=models.DO_NOTHING, 39 | ), 40 | 'descendant': models.ForeignKey( 41 | cls, 42 | related_name='%s+' % name_, 43 | db_tablespace=self.db_tablespace, 44 | db_constraint=self.remote_field.db_constraint, 45 | on_delete=models.DO_NOTHING, 46 | ), 47 | 'depth': models.IntegerField(), 48 | }) 49 | 50 | super().contribute_to_class(cls, name, **kwargs) 51 | -------------------------------------------------------------------------------- /closure_tree/migrations.py: -------------------------------------------------------------------------------- 1 | from django.db.migrations.operations.special import RunSQL 2 | 3 | 4 | class CreateTreeClosure(RunSQL): 5 | reversible = True 6 | 7 | def __init__(self, model_name): 8 | self.model_name = model_name.lower() 9 | super().__init__('') 10 | 11 | def database_forwards(self, app_label, schema_editor, from_state, to_state): 12 | model = from_state.apps.get_model(app_label, self.model_name) 13 | view_name = model._meta.db_table + '_closure' 14 | 15 | self.sql = ''' 16 | CREATE OR REPLACE RECURSIVE VIEW {view}(path, ancestor_id, descendant_id, depth) AS 17 | 18 | SELECT ARRAY[node_id], node_id, node_id, 0 19 | FROM {table} 20 | 21 | UNION ALL 22 | 23 | SELECT parent_id || path, parent_id, descendant_id, depth + 1 24 | FROM {table} 25 | INNER JOIN {view} ON (ancestor_id = node_id) 26 | WHERE parent_id IS NOT NULL; 27 | '''.format( 28 | table=schema_editor.connection.ops.quote_name(model._meta.db_table), 29 | view=schema_editor.connection.ops.quote_name(view_name), 30 | ) 31 | super().database_forwards(app_label, schema_editor, from_state, to_state) 32 | 33 | 34 | def database_backwards(self, app_label, schema_editor, from_state, to_state): 35 | model = from_state.apps.get_model(app_label, self.model_name) 36 | view_name = model._meta.db_table + '_closure' 37 | self.reverse_sql = '''DROP VIEW IF EXISTS {view};'''.format( 38 | view=schema_editor.connection.ops.quote_name(view_name) 39 | ) 40 | super().database_backwards(app_label, schema_editor, from_state, to_state) 41 | -------------------------------------------------------------------------------- /closure_tree/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from .fields import ClosureManyToManyField 4 | 5 | 6 | class Node(models.Model): 7 | node_id = models.AutoField(primary_key=True) 8 | parent = models.ForeignKey('self', related_name='children', null=True, blank=True, on_delete=models.CASCADE) 9 | 10 | descendants = ClosureManyToManyField('self', symmetrical=False, related_name='ancestors') 11 | 12 | class Meta: 13 | abstract = True 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # setup.py from https://github.com/kennethreitz/setup.py 5 | 6 | # Note: To use the 'upload' functionality of this file, you must: 7 | # $ pip install twine 8 | 9 | import io 10 | import os 11 | import sys 12 | from shutil import rmtree 13 | 14 | from setuptools import find_packages, setup, Command 15 | 16 | # Package meta-data. 17 | NAME = 'django-closure-tree' 18 | DESCRIPTION = 'A Closure based Tree model for Django.' 19 | URL = 'https://github.com/funkybob/closure_tree' 20 | EMAIL = 'curtis@tinbrain.net' 21 | AUTHOR = 'Curtis Maloney' 22 | 23 | # What packages are required for this module to be executed? 24 | REQUIRED = [ 25 | 'django', 26 | ] 27 | 28 | # The rest you shouldn't have to touch too much :) 29 | # ------------------------------------------------ 30 | # Except, perhaps the License and Trove Classifiers! 31 | # If you do change the License, remember to change the Trove Classifier for that! 32 | # MODULE = NAME.replace('-', '_') 33 | MODULE = 'closure_tree' 34 | 35 | here = os.path.abspath(os.path.dirname(__file__)) 36 | 37 | # Import the README and use it as the long-description. 38 | # Note: this will only work if 'README.rst' is present in your MANIFEST.in file! 39 | with io.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f: 40 | long_description = '\n' + f.read() 41 | 42 | # Load the package's __version__.py module as a dictionary. 43 | about = {} 44 | with open(os.path.join(here, MODULE, '__version__.py')) as f: 45 | exec(f.read(), about) 46 | 47 | 48 | class UploadCommand(Command): 49 | """Support setup.py upload.""" 50 | 51 | description = 'Build and publish the package.' 52 | user_options = [] 53 | 54 | @staticmethod 55 | def status(s): 56 | """Prints things in bold.""" 57 | print('\033[1m{0}\033[0m'.format(s)) 58 | 59 | def initialize_options(self): 60 | pass 61 | 62 | def finalize_options(self): 63 | pass 64 | 65 | def run(self): 66 | try: 67 | self.status('Removing previous builds…') 68 | rmtree(os.path.join(here, 'dist')) 69 | except OSError: 70 | pass 71 | 72 | self.status('Building Source and Wheel (universal) distribution…') 73 | os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable)) 74 | 75 | self.status('Uploading the package to PyPi via Twine…') 76 | os.system('twine upload dist/*') 77 | 78 | sys.exit() 79 | 80 | 81 | # Where the magic happens: 82 | setup( 83 | name=NAME, 84 | version=about['__version__'], 85 | description=DESCRIPTION, 86 | long_description=long_description, 87 | author=AUTHOR, 88 | author_email=EMAIL, 89 | url=URL, 90 | packages=find_packages(exclude=('tests',)), 91 | # If your package is a single module, use this instead of 'packages': 92 | # py_modules=['mypackage'], 93 | 94 | # entry_points={ 95 | # 'console_scripts': ['mycli=mymodule:cli'], 96 | # }, 97 | install_requires=REQUIRED, 98 | include_package_data=True, 99 | license='MIT', 100 | classifiers=[ 101 | # Trove classifiers 102 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 103 | 'License :: OSI Approved :: MIT License', 104 | 'Programming Language :: Python', 105 | 'Programming Language :: Python :: 3', 106 | 'Programming Language :: Python :: 3.3', 107 | 'Programming Language :: Python :: 3.4', 108 | 'Programming Language :: Python :: 3.5', 109 | 'Programming Language :: Python :: 3.6', 110 | 'Programming Language :: Python :: Implementation :: CPython', 111 | 'Programming Language :: Python :: Implementation :: PyPy' 112 | ], 113 | # $ setup.py publish support. 114 | cmdclass={ 115 | 'upload': UploadCommand, 116 | }, 117 | ) 118 | --------------------------------------------------------------------------------