├── .coveragerc ├── MANIFEST.in ├── stdeb.cfg ├── .gitignore ├── example_project ├── example_project │ ├── __init__.py │ ├── wsgi.py │ └── settings.py └── manage.py ├── closuretree ├── __init__.py ├── version.py ├── models.py └── tests.py ├── docs ├── modules.rst ├── closuretree.rst ├── index.rst ├── Makefile └── conf.py ├── NOTICE ├── test_settings.py ├── .travis.yml ├── setup.py ├── README.rst └── LICENSE /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = closuretree 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include NOTICE 3 | -------------------------------------------------------------------------------- /stdeb.cfg: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | XS-Python-Version: >=2.6 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | docs/_build 4 | deb_dist 5 | -------------------------------------------------------------------------------- /example_project/example_project/__init__.py: -------------------------------------------------------------------------------- 1 | '''example_project __init__''' 2 | -------------------------------------------------------------------------------- /closuretree/__init__.py: -------------------------------------------------------------------------------- 1 | '''An implementation of a closure tree, for tree based models.''' 2 | -------------------------------------------------------------------------------- /closuretree/version.py: -------------------------------------------------------------------------------- 1 | from gitversion import rewritable_git_version 2 | __VERSION__ = rewritable_git_version(__file__) 3 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | closuretree 8 | 9 | -------------------------------------------------------------------------------- /docs/closuretree.rst: -------------------------------------------------------------------------------- 1 | closuretree Package 2 | =================== 3 | 4 | :mod:`models` Module 5 | -------------------- 6 | 7 | .. automodule:: closuretree.models 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /example_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../')) 6 | 7 | if __name__ == "__main__": 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 9 | 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Ocado Innovation Limited 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Ocado Innovation Limited 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | DATABASES = { 16 | 'default': { 17 | 'ENGINE': 'django.db.backends.sqlite3', 18 | }, 19 | } 20 | INSTALLED_APPS = ['closuretree'] 21 | ROOT_URLCONF = 'django_autoconfig.autourlconf' 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.6 4 | - 2.7 5 | - 3.2 6 | - 3.3 7 | - pypy 8 | - pypy3 9 | env: 10 | - DJANGO=">=1.4,<1.5" 11 | - DJANGO=">=1.6,<1.7" 12 | - DJANGO=">=1.7,<1.8" 13 | install: 14 | - pip install gitversion 15 | - pip install "Django${DJANGO}" 16 | - pip install . 17 | - pip install coveralls 18 | script: 19 | - python setup.py test 20 | after_success: 21 | - coveralls 22 | matrix: 23 | exclude: 24 | - python: 2.6 25 | env: DJANGO=">=1.7,<1.8" 26 | - python: 3.2 27 | env: DJANGO=">=1.4,<1.5" 28 | - python: 3.3 29 | env: DJANGO=">=1.4,<1.5" 30 | - python: pypy3 31 | env: DJANGO=">=1.4,<1.5" 32 | deploy: 33 | provider: pypi 34 | user: ocadotechnology 35 | password: 36 | secure: kdfRG0+3xsH4DSlTtVi8WT8/vhTzlfC58c0gP/MhsrN7k7ffGs6fjxkUoKpIGPZXV+v9kI5elYkFs4wInOrtq5ZgPHKMTpaHxQLrDtCkurYY7mD0e9KP2xVjvPEDQxDrynn1/x75Doi0Oe9StJ5E2odUJ0HcOwqhlKXJuOZa19M= 37 | distributions: "bdist_wheel sdist" 38 | on: 39 | all_branches: true 40 | repo: ocadotechnology/django-closuretree 41 | tags: true 42 | condition: "$DJANGO = '>=1.7,<1.8'" 43 | -------------------------------------------------------------------------------- /example_project/example_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example_project project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 19 | 20 | # This application object is used by any WSGI server configured to use this 21 | # file. This includes Django's development server, if the WSGI_APPLICATION 22 | # setting points here. 23 | from django.core.wsgi import get_wsgi_application 24 | application = get_wsgi_application() 25 | 26 | # Apply WSGI middleware here. 27 | # from helloworld.wsgi import HelloWorldApplication 28 | # application = HelloWorldApplication(application) 29 | -------------------------------------------------------------------------------- /example_project/example_project/settings.py: -------------------------------------------------------------------------------- 1 | '''Django settings for example_project project.''' 2 | import os 3 | 4 | DEBUG = True 5 | TEMPLATE_DEBUG = DEBUG 6 | 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. 10 | 'NAME': os.path.join(os.path.abspath(os.path.dirname(__file__)),'dbfile'),# Or path to database file if using sqlite3. 11 | 'USER': '', # Not used with sqlite3. 12 | 'PASSWORD': '', # Not used with sqlite3. 13 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. 14 | 'PORT': '', # Set to empty string for default. Not used with sqlite3. 15 | } 16 | } 17 | 18 | TIME_ZONE = 'Europe/London' 19 | LANGUAGE_CODE = 'en-gb' 20 | STATIC_ROOT = os.path.join(os.path.dirname(__file__), 'static') 21 | STATIC_URL = '/static/' 22 | SECRET_KEY = 'j$w9t$1(e7k*=c!ks!z&w0s6af!xrku1%&6!c@_5wwicjg&c_c' 23 | 24 | ROOT_URLCONF = 'django_autoconfig.autourlconf' 25 | 26 | WSGI_APPLICATION = 'example_project.wsgi.application' 27 | 28 | INSTALLED_APPS = ( 29 | 'closuretree', 30 | ) 31 | 32 | try: 33 | # pylint: disable=E0611 34 | from example_project.local_settings import * 35 | except ImportError: 36 | pass 37 | 38 | from django_autoconfig import autoconfig 39 | autoconfig.configure_settings(globals()) 40 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Ocado Innovation Limited 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Setup file for django-closuretree.""" 16 | from setuptools import setup, find_packages 17 | 18 | from closuretree.version import __VERSION__ 19 | 20 | setup( 21 | name='django-closuretree', 22 | version=__VERSION__, 23 | packages=find_packages(), 24 | author='Mike Bryant', 25 | author_email='mike.bryant@ocado.com', 26 | description='Efficient tree-based datastructure for Django', 27 | long_description=open('README.rst').read(), 28 | url='https://github.com/ocadotechnology/django-closuretree/', 29 | install_requires=[ 30 | 'django >= 1.4', 31 | 'django-autoconfig', 32 | ], 33 | tests_require=['django-setuptest'], 34 | test_suite='setuptest.setuptest.SetupTestSuite', 35 | classifiers=[ 36 | 'Development Status :: 5 - Production/Stable', 37 | 'Framework :: Django', 38 | 'Intended Audience :: Developers', 39 | 'License :: OSI Approved :: Apache Software License', 40 | 'Programming Language :: Python', 41 | 'Programming Language :: Python :: 2', 42 | 'Programming Language :: Python :: 2.6', 43 | 'Programming Language :: Python :: 2.7', 44 | 'Programming Language :: Python :: 3', 45 | 'Programming Language :: Python :: 3.2', 46 | 'Programming Language :: Python :: 3.3', 47 | 'Topic :: Software Development :: Libraries :: Python Modules', 48 | ], 49 | ) 50 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ****************** 2 | django-closuretree 3 | ****************** 4 | 5 | 6 | .. image:: https://travis-ci.org/ocadotechnology/django-closuretree.svg 7 | :target: https://travis-ci.org/ocadotechnology/django-closuretree 8 | :alt: Build Status 9 | .. image:: https://landscape.io/github/ocadotechnology/django-closuretree/master/landscape.svg?style=flat 10 | :target: https://landscape.io/github/ocadotechnology/django-closuretree/master 11 | :alt: Code Health Badge 12 | .. image:: https://readthedocs.org/projects/django-closuretree/badge/?version=latest 13 | :target: http://django-closuretree.readthedocs.org/en/latest/ 14 | :alt: Documentation Status 15 | .. image:: https://coveralls.io/repos/ocadotechnology/django-closuretree/badge.svg 16 | :target: https://coveralls.io/r/ocadotechnology/django-closuretree 17 | :alt: Test Coverage 18 | .. image:: https://pypip.in/v/django-closuretree/badge.svg?style=flat 19 | :target: https://pypi.python.org/pypi/django-closuretree/ 20 | :alt: Version Badge 21 | .. image:: https://pypip.in/license/django-closuretree/badge.svg?style=flat 22 | :target: https://pypi.python.org/pypi/django-closuretree/ 23 | :alt: License Badge 24 | 25 | 26 | ``django-closuretree`` is an implementation of the `closure tree `_ technique for `Django `_ applications designed to provide efficient querying of `tree-based structures `_ in a relational database. Its goal is to reduce the number of queries required when querying the children or parents of a given object. 27 | 28 | Given the following model: 29 | 30 | .. code-block:: python 31 | 32 | class Node(models.Model): 33 | name = models.CharField(max_length=24) 34 | parent = models.ForeignKey('self', related_name='children') 35 | 36 | The children of each model can be queried with: 37 | 38 | .. code-block:: python 39 | 40 | Node.objects.get(name='A').children.all() 41 | 42 | However, for recursive lookups, this results in a large number of queries. Instead, ``django-closuretree`` allows you to extract them all in one go: 43 | 44 | .. code-block:: python 45 | 46 | from closuretree.models import ClosureModel 47 | 48 | class Node(ClosureModel): 49 | name = models.CharField(max_length=24) 50 | parent = models.ForeignKey('self', related_name='children') 51 | 52 | Node.objects.get(name='A').get_descendants() 53 | 54 | A single query will obtain all the descendants. 55 | 56 | =========== 57 | Quick Start 58 | =========== 59 | 60 | * Install ``django-closuretree`` with ``pip install django-closuretree``. 61 | * Inherit your models from ``closuretree.models.ClosureModel`` instead of ``django.db.models.Model``. 62 | 63 | That's it. You can now use ``get_descendants()`` and ``get_ancestors()`` on a model instance. 64 | 65 | If you're adding this to an existing application that already has data in the database, you'll need to run the ``rebuildtable()`` method of each model before the closure tree will be populated with the existing data: 66 | 67 | .. code-block:: python 68 | 69 | Node.rebuildtable() 70 | 71 | ============ 72 | Contributing 73 | ============ 74 | 75 | To contribute, fork the repo, do your work, and issue a pull request. We ask that contributors adhere to `PEP8 `_ standards, and include full tests for all their code. 76 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Django Closuretree documentation master file, created by 2 | sphinx-quickstart on Tue Jan 29 18:07:10 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to django-closuretree's documentation! 7 | ============================================== 8 | 9 | **django-closuretree** is an implementation of a closure tree for tree-based 10 | Django models. It aims to reduce the number of database hits required when 11 | traversing complex tree-based relationships between models in your Django app. 12 | 13 | Requirements 14 | ============ 15 | 16 | * Django 1.4+ 17 | * Sphinx (for documentation) 18 | 19 | Basic Usage 20 | =========== 21 | 22 | Inherit your models from ``closuretree.models.ClosureModel`` instead of ``django.db.models.Model``: 23 | 24 | .. code-block:: python 25 | 26 | from django.db import models 27 | from closuretree.models import ClosureModel 28 | 29 | class MyModel(ClosureModel): 30 | parent = models.ForeignKey('self', related_name='children') 31 | name = models.CharField(max_length=32) 32 | 33 | def __unicode__(self): 34 | return '%s: %s" % (self.id, self.name) 35 | 36 | **django-closuretree** will automatically use the field named ``parent`` as the 37 | relationship. This can be manually overriden: 38 | 39 | .. code-block:: python 40 | 41 | from django.db import models 42 | from closuretree.models import ClosureModel 43 | 44 | class MyModel(ClosureModel): 45 | parent_rel = models.ForeignKey('self', related_name='children') 46 | name = models.CharField(max_length=32) 47 | 48 | class ClosureMeta(object): 49 | parent_attr = 'parent_rel' 50 | 51 | def __unicode__(self): 52 | return '%s: %s' % (self.id, self.name) 53 | 54 | Perhaps the most useful methods provided by ``closuretree.models.ClosureModel`` are 55 | the following: 56 | 57 | .. code-block:: python 58 | 59 | >> my_model = MyModel.objects.get(pk=10) 60 | >> my_model.get_ancestors() 61 | [, , ] 62 | >> my_model.get_descendants() 63 | [, ] 64 | >> my_model.get_descendants(depth=1) 65 | [] 66 | >> my_model.get_root() 67 | 68 | >> my_model.is_ancestor_of(MyModel.objects.get(name='Alice')) 69 | True 70 | >> my_model.is_descendant_of(MyModel.objects.get(name='Bar')) 71 | True 72 | 73 | Read the :doc:`closuretree` model documentation for more methods. 74 | 75 | Adding to existing models 76 | ========================= 77 | 78 | If you add **django-closuretree** to existing models, you'll need to build the closure table for the pre-existing data: 79 | 80 | .. code-block:: python 81 | 82 | MyModel.rebuildtable() 83 | 84 | Indirect relations 85 | ================== 86 | 87 | If your model is linked to itself via an indirect relationship (for example, ModelA -> ModelB -> ModelC -> ModelA), then you'll need to define a parent property that traverses this relationship, and set a sentinel attribute as the foriegn key to ModelB: 88 | 89 | .. code-block:: python 90 | 91 | class ModelA(ClosureModel): 92 | model_b = models.ForeignKey(ModelB) 93 | 94 | @property 95 | def parent(self): 96 | return self.model_b.model_c.model_a 97 | 98 | class ClosureMeta: 99 | sentinel_attr = 'model_b' 100 | 101 | Closuretree will watch the sentinel attribute for changes, and use the value of the parent property when rebuilding the tree. 102 | 103 | API Documentation 104 | ================= 105 | 106 | .. toctree:: 107 | :maxdepth: 2 108 | 109 | modules 110 | 111 | Indices and tables 112 | ================== 113 | 114 | * :ref:`genindex` 115 | * :ref:`modindex` 116 | * :ref:`search` 117 | 118 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoClosuretree.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoClosuretree.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoClosuretree" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoClosuretree" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Django Closuretree documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jan 29 18:07:10 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | # pylint: disable=W0622 15 | 16 | import sys, os 17 | from closuretree.version import __VERSION__ 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | #sys.path.insert(0, os.path.abspath('.')) 23 | 24 | sys.path.insert(0, os.path.abspath('../')) 25 | sys.path.insert(0, os.path.abspath('../example_project')) 26 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example_project.settings') 27 | 28 | # -- General configuration ----------------------------------------------------- 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | #needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be extensions 34 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 35 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.coverage', 'sphinx.ext.viewcode'] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix of source filenames. 41 | source_suffix = '.rst' 42 | 43 | # The encoding of source files. 44 | #source_encoding = 'utf-8-sig' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = u'Django Closuretree' 51 | copyright = u'2015, Ocado Innovation Limited' 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The full version, including alpha/beta/rc tags. 58 | release = __VERSION__ 59 | # The short X.Y version. 60 | version = '.'.join(release.split('.')[0:2]) 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | #language = None 65 | 66 | # There are two options for replacing |today|: either, you set today to some 67 | # non-false value, then it is used: 68 | #today = '' 69 | # Else, today_fmt is used as the format for a strftime call. 70 | #today_fmt = '%B %d, %Y' 71 | 72 | # List of patterns, relative to source directory, that match files and 73 | # directories to ignore when looking for source files. 74 | exclude_patterns = ['_build'] 75 | 76 | # The reST default role (used for this markup: `text`) to use for all documents. 77 | #default_role = None 78 | 79 | # If true, '()' will be appended to :func: etc. cross-reference text. 80 | #add_function_parentheses = True 81 | 82 | # If true, the current module name will be prepended to all description 83 | # unit titles (such as .. function::). 84 | #add_module_names = True 85 | 86 | # If true, sectionauthor and moduleauthor directives will be shown in the 87 | # output. They are ignored by default. 88 | #show_authors = False 89 | 90 | # The name of the Pygments (syntax highlighting) style to use. 91 | pygments_style = 'sphinx' 92 | 93 | # A list of ignored prefixes for module index sorting. 94 | #modindex_common_prefix = [] 95 | 96 | 97 | # -- Options for HTML output --------------------------------------------------- 98 | 99 | # The theme to use for HTML and HTML Help pages. See the documentation for 100 | # a list of builtin themes. 101 | html_theme = 'default' 102 | 103 | # Theme options are theme-specific and customize the look and feel of a theme 104 | # further. For a list of options available for each theme, see the 105 | # documentation. 106 | #html_theme_options = {} 107 | 108 | # Add any paths that contain custom themes here, relative to this directory. 109 | #html_theme_path = [] 110 | 111 | # The name for this set of Sphinx documents. If None, it defaults to 112 | # " v documentation". 113 | #html_title = None 114 | 115 | # A shorter title for the navigation bar. Default is the same as html_title. 116 | #html_short_title = None 117 | 118 | # The name of an image file (relative to this directory) to place at the top 119 | # of the sidebar. 120 | #html_logo = None 121 | 122 | # The name of an image file (within the static path) to use as favicon of the 123 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 124 | # pixels large. 125 | #html_favicon = None 126 | 127 | # Add any paths that contain custom static files (such as style sheets) here, 128 | # relative to this directory. They are copied after the builtin static files, 129 | # so a file named "default.css" will overwrite the builtin "default.css". 130 | html_static_path = ['_static'] 131 | 132 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 133 | # using the given strftime format. 134 | #html_last_updated_fmt = '%b %d, %Y' 135 | 136 | # If true, SmartyPants will be used to convert quotes and dashes to 137 | # typographically correct entities. 138 | #html_use_smartypants = True 139 | 140 | # Custom sidebar templates, maps document names to template names. 141 | #html_sidebars = {} 142 | 143 | # Additional templates that should be rendered to pages, maps page names to 144 | # template names. 145 | #html_additional_pages = {} 146 | 147 | # If false, no module index is generated. 148 | #html_domain_indices = True 149 | 150 | # If false, no index is generated. 151 | #html_use_index = True 152 | 153 | # If true, the index is split into individual pages for each letter. 154 | #html_split_index = False 155 | 156 | # If true, links to the reST sources are added to the pages. 157 | #html_show_sourcelink = True 158 | 159 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 160 | #html_show_sphinx = True 161 | 162 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 163 | #html_show_copyright = True 164 | 165 | # If true, an OpenSearch description file will be output, and all pages will 166 | # contain a tag referring to it. The value of this option must be the 167 | # base URL from which the finished HTML is served. 168 | #html_use_opensearch = '' 169 | 170 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 171 | #html_file_suffix = None 172 | 173 | # Output file base name for HTML help builder. 174 | htmlhelp_basename = 'DjangoClosuretreedoc' 175 | 176 | 177 | # -- Options for LaTeX output -------------------------------------------------- 178 | 179 | latex_elements = { 180 | # The paper size ('letterpaper' or 'a4paper'). 181 | #'papersize': 'letterpaper', 182 | 183 | # The font size ('10pt', '11pt' or '12pt'). 184 | #'pointsize': '10pt', 185 | 186 | # Additional stuff for the LaTeX preamble. 187 | #'preamble': '', 188 | } 189 | 190 | # Grouping the document tree into LaTeX files. List of tuples 191 | # (source start file, target name, title, author, documentclass [howto/manual]). 192 | latex_documents = [ 193 | ('index', 'DjangoClosuretree.tex', u'Django Closuretree Documentation', 194 | u'Mike Bryant', 'manual'), 195 | ] 196 | 197 | # The name of an image file (relative to this directory) to place at the top of 198 | # the title page. 199 | #latex_logo = None 200 | 201 | # For "manual" documents, if this is true, then toplevel headings are parts, 202 | # not chapters. 203 | #latex_use_parts = False 204 | 205 | # If true, show page references after internal links. 206 | #latex_show_pagerefs = False 207 | 208 | # If true, show URL addresses after external links. 209 | #latex_show_urls = False 210 | 211 | # Documents to append as an appendix to all manuals. 212 | #latex_appendices = [] 213 | 214 | # If false, no module index is generated. 215 | #latex_domain_indices = True 216 | 217 | 218 | # -- Options for manual page output -------------------------------------------- 219 | 220 | # One entry per manual page. List of tuples 221 | # (source start file, name, description, authors, manual section). 222 | man_pages = [ 223 | ('index', 'djangoclosuretree', u'Django Closuretree Documentation', 224 | [u'Mike Bryant'], 1) 225 | ] 226 | 227 | # If true, show URL addresses after external links. 228 | #man_show_urls = False 229 | 230 | 231 | # -- Options for Texinfo output ------------------------------------------------ 232 | 233 | # Grouping the document tree into Texinfo files. List of tuples 234 | # (source start file, target name, title, author, 235 | # dir menu entry, description, category) 236 | texinfo_documents = [ 237 | ('index', 'DjangoClosuretree', u'Django Closuretree Documentation', 238 | u'Mike Bryant', 'DjangoClosuretree', 'A closure tree implementation for parent child relationships.', 239 | 'Miscellaneous'), 240 | ] 241 | 242 | # Documents to append as an appendix to all manuals. 243 | #texinfo_appendices = [] 244 | 245 | # If false, no module index is generated. 246 | #texinfo_domain_indices = True 247 | 248 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 249 | #texinfo_show_urls = 'footnote' 250 | 251 | 252 | # Example configuration for intersphinx: refer to the Python standard library. 253 | intersphinx_mapping = {'http://docs.python.org/': None} 254 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /closuretree/models.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Ocado Innovation Limited 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Models for closuretree application.""" 16 | 17 | # We like magic. 18 | # pylint: disable=W0142 19 | 20 | # We have lots of dynamically generated things, hard for pylint to solve. 21 | # pylint: disable=E1101 22 | 23 | # It may not be our class, but we made the attribute on it 24 | # pylint: disable=W0212 25 | 26 | # Public methods are useful! 27 | # pylint: disable=R0904 28 | 29 | from django.db import models 30 | from django.db.models.base import ModelBase 31 | from django.utils.six import with_metaclass 32 | import sys 33 | 34 | def _closure_model_unicode(self): 35 | """__unicode__ implementation for the dynamically created 36 | Closure model. 37 | """ 38 | return "Closure from %s to %s" % (self.parent, self.child) 39 | 40 | def create_closure_model(cls): 41 | """Creates a Closure model in the same module as the model.""" 42 | meta_vals = { 43 | 'unique_together': (("parent", "child"),) 44 | } 45 | if getattr(cls._meta, 'db_table', None): 46 | meta_vals['db_table'] = '%sclosure' % getattr(cls._meta, 'db_table') 47 | model = type('%sClosure' % cls.__name__, (models.Model,), { 48 | 'parent': models.ForeignKey( 49 | cls.__name__, 50 | related_name=cls.closure_parentref() 51 | ), 52 | 'child': models.ForeignKey( 53 | cls.__name__, 54 | related_name=cls.closure_childref() 55 | ), 56 | 'depth': models.IntegerField(), 57 | '__module__': cls.__module__, 58 | '__unicode__': _closure_model_unicode, 59 | 'Meta': type('Meta', (object,), meta_vals), 60 | }) 61 | setattr(cls, "_closure_model", model) 62 | return model 63 | 64 | class ClosureModelBase(ModelBase): 65 | """Metaclass for Models inheriting from ClosureModel, 66 | to ensure the Closure model is created. 67 | """ 68 | #This is a metaclass. MAGIC! 69 | def __init__(cls, name, bases, dct): 70 | """Create the closure model in addition 71 | to doing all the normal django stuff. 72 | """ 73 | super(ClosureModelBase, cls).__init__(name, bases, dct) 74 | if not cls._meta.get_parent_list() and cls.__module__ != __name__: 75 | setattr( 76 | sys.modules[cls.__module__], 77 | '%sClosure' % cls.__name__, 78 | create_closure_model(cls) 79 | ) 80 | 81 | class ClosureModel(with_metaclass(ClosureModelBase, models.Model)): 82 | """Provides methods to assist in a tree based structure.""" 83 | # pylint: disable=W5101 84 | 85 | class Meta: 86 | """We make this an abstract class, it needs to be inherited from.""" 87 | # pylint: disable=W0232 88 | # pylint: disable=R0903 89 | abstract = True 90 | 91 | def __setattr__(self, name, value): 92 | if ( 93 | name.startswith(self._closure_sentinel_attr) and 94 | hasattr(self, name) and 95 | not self._closure_change_check() 96 | ): 97 | # Already set once, and not already stored the old 98 | # value, need to take a copy before it changes 99 | self._closure_change_init() 100 | super(ClosureModel, self).__setattr__(name, value) 101 | 102 | @classmethod 103 | def _toplevel(cls): 104 | """Find the top level of the chain we're in. 105 | 106 | For example, if we have: 107 | C inheriting from B inheriting from A inheriting from ClosureModel 108 | C._toplevel() will return A. 109 | """ 110 | superclasses = ( 111 | list(set(ClosureModel.__subclasses__()) & 112 | set(cls._meta.get_parent_list())) 113 | ) 114 | return next(iter(superclasses)) if superclasses else cls 115 | 116 | @classmethod 117 | def rebuildtable(cls): 118 | """Regenerate the entire closuretree.""" 119 | cls._closure_model.objects.all().delete() 120 | cls._closure_model.objects.bulk_create([cls._closure_model( 121 | parent_id=x['pk'], 122 | child_id=x['pk'], 123 | depth=0 124 | ) for x in cls.objects.values("pk")]) 125 | for node in cls.objects.all(): 126 | node._closure_createlink() 127 | 128 | @classmethod 129 | def closure_parentref(cls): 130 | """How to refer to parents in the closure tree""" 131 | return "%sclosure_children" % cls._toplevel().__name__.lower() 132 | 133 | # Backwards compatibility: 134 | _closure_parentref = closure_parentref 135 | 136 | @classmethod 137 | def closure_childref(cls): 138 | """How to refer to children in the closure tree""" 139 | return "%sclosure_parents" % cls._toplevel().__name__.lower() 140 | 141 | # Backwards compatibility: 142 | _closure_childref = closure_childref 143 | 144 | @property 145 | def _closure_sentinel_attr(self): 146 | """The attribute we need to watch to tell if the 147 | parent/child relationships have changed 148 | """ 149 | meta = getattr(self, 'ClosureMeta', None) 150 | return getattr(meta, 'sentinel_attr', self._closure_parent_attr) 151 | 152 | @property 153 | def _closure_parent_attr(self): 154 | '''The attribute or property that holds the parent object.''' 155 | meta = getattr(self, 'ClosureMeta', None) 156 | return getattr(meta, 'parent_attr', 'parent') 157 | 158 | @property 159 | def _closure_parent_pk(self): 160 | """What our parent pk is in the closure tree.""" 161 | if hasattr(self, "%s_id" % self._closure_parent_attr): 162 | return getattr(self, "%s_id" % self._closure_parent_attr) 163 | else: 164 | parent = getattr(self, self._closure_parent_attr) 165 | return parent.id if parent else None 166 | 167 | def _closure_deletelink(self, oldparentpk): 168 | """Remove incorrect links from the closure tree.""" 169 | self._closure_model.objects.filter( 170 | **{ 171 | "parent__%s__child" % self._closure_parentref(): oldparentpk, 172 | "child__%s__parent" % self._closure_childref(): self.pk 173 | } 174 | ).delete() 175 | 176 | def _closure_createlink(self): 177 | """Create a link in the closure tree.""" 178 | linkparents = self._closure_model.objects.filter( 179 | child__id=self._closure_parent_pk 180 | ).values("parent", "depth") 181 | linkchildren = self._closure_model.objects.filter( 182 | parent__id=self.pk 183 | ).values("child", "depth") 184 | newlinks = [self._closure_model( 185 | parent_id=p['parent'], 186 | child_id=c['child'], 187 | depth=p['depth']+c['depth']+1 188 | ) for p in linkparents for c in linkchildren] 189 | self._closure_model.objects.bulk_create(newlinks) 190 | 191 | def get_ancestors(self, include_self=False, depth=None): 192 | """Return all the ancestors of this object.""" 193 | if self.is_root_node(): 194 | if not include_self: 195 | return self._toplevel().objects.none() 196 | else: 197 | # Filter on pk for efficiency. 198 | return self._toplevel().objects.filter(pk=self.pk) 199 | 200 | params = {"%s__child" % self._closure_parentref():self.pk} 201 | if depth is not None: 202 | params["%s__depth__lte" % self._closure_parentref()] = depth 203 | ancestors = self._toplevel().objects.filter(**params) 204 | if not include_self: 205 | ancestors = ancestors.exclude(pk=self.pk) 206 | return ancestors.order_by("%s__depth" % self._closure_parentref()) 207 | 208 | def get_descendants(self, include_self=False, depth=None): 209 | """Return all the descendants of this object.""" 210 | params = {"%s__parent" % self._closure_childref():self.pk} 211 | if depth is not None: 212 | params["%s__depth__lte" % self._closure_childref()] = depth 213 | descendants = self._toplevel().objects.filter(**params) 214 | if not include_self: 215 | descendants = descendants.exclude(pk=self.pk) 216 | return descendants.order_by("%s__depth" % self._closure_childref()) 217 | 218 | def prepopulate(self, queryset): 219 | """Perpopulate a descendants query's children efficiently. 220 | Call like: blah.prepopulate(blah.get_descendants().select_related(stuff)) 221 | """ 222 | objs = list(queryset) 223 | hashobjs = dict([(x.pk, x) for x in objs] + [(self.pk, self)]) 224 | for descendant in hashobjs.values(): 225 | descendant._cached_children = [] 226 | for descendant in objs: 227 | assert descendant._closure_parent_pk in hashobjs 228 | parent = hashobjs[descendant._closure_parent_pk] 229 | parent._cached_children.append(descendant) 230 | 231 | def get_children(self): 232 | """Return all the children of this object.""" 233 | if hasattr(self, '_cached_children'): 234 | children = self._toplevel().objects.filter( 235 | pk__in=[n.pk for n in self._cached_children] 236 | ) 237 | children._result_cache = self._cached_children 238 | return children 239 | else: 240 | return self.get_descendants(include_self=False, depth=1) 241 | 242 | def get_root(self): 243 | """Return the furthest ancestor of this node.""" 244 | if self.is_root_node(): 245 | return self 246 | 247 | return self.get_ancestors().order_by( 248 | "-%s__depth" % self._closure_parentref() 249 | )[0] 250 | 251 | def is_child_node(self): 252 | """Is this node a child, i.e. has a parent?""" 253 | return not self.is_root_node() 254 | 255 | def is_root_node(self): 256 | """Is this node a root, i.e. has no parent?""" 257 | return self._closure_parent_pk is None 258 | 259 | def is_descendant_of(self, other, include_self=False): 260 | """Is this node a descendant of `other`?""" 261 | if other.pk == self.pk: 262 | return include_self 263 | 264 | return self._closure_model.objects.filter( 265 | parent=other, 266 | child=self 267 | ).exclude(pk=self.pk).exists() 268 | 269 | def is_ancestor_of(self, other, include_self=False): 270 | """Is this node an ancestor of `other`?""" 271 | return other.is_descendant_of(self, include_self=include_self) 272 | 273 | def _closure_change_init(self): 274 | """Part of the change detection. Setting up""" 275 | # More magic. We're setting this inside setattr... 276 | # pylint: disable=W0201 277 | self._closure_old_parent_pk = self._closure_parent_pk 278 | 279 | def _closure_change_check(self): 280 | """Part of the change detection. Have we changed since we began?""" 281 | return hasattr(self,"_closure_old_parent_pk") 282 | 283 | def _closure_change_oldparent(self): 284 | """Part of the change detection. What we used to be""" 285 | return self._closure_old_parent_pk 286 | 287 | def save(self, *args, **kwargs): 288 | create = not self.id 289 | val = super(ClosureModel, self).save(*args, **kwargs) 290 | if create: 291 | closure_instance = self._closure_model( 292 | parent=self, 293 | child=self, 294 | depth=0 295 | ) 296 | closure_instance.save() 297 | if self._closure_change_check(): 298 | #Changed parents. 299 | if self._closure_change_oldparent(): 300 | self._closure_deletelink(self._closure_change_oldparent()) 301 | self._closure_createlink() 302 | delattr(self, "_closure_old_parent_pk") 303 | elif create: 304 | # We still need to create links when we're first made 305 | self._closure_createlink() 306 | 307 | return val 308 | 309 | def delete(self, *args, **kwargs): 310 | self._closure_deletelink(self._closure_parent_pk) 311 | super(ClosureModel, self).delete(*args, **kwargs) 312 | -------------------------------------------------------------------------------- /closuretree/tests.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Ocado Innovation Limited 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for closuretree""" 16 | 17 | # This is kinda tricky... 18 | # pylint: disable=C0103 19 | # pylint: disable=E1101 20 | # pylint: disable=E0602 21 | # pylint: disable=R0903 22 | # pylint: disable=R0904 23 | # pylint: disable= 24 | # pylint: disable= 25 | 26 | from django.test import TestCase 27 | from django.db import models 28 | from closuretree.models import ClosureModel 29 | 30 | class TC(ClosureModel): 31 | """A test model.""" 32 | parent2 = models.ForeignKey( 33 | "self", 34 | related_name="children", 35 | null=True, 36 | blank=True 37 | ) 38 | name = models.CharField(max_length=32) 39 | blah = models.ForeignKey("Blah", related_name="tcs", null=True, blank=True) 40 | 41 | class ClosureMeta(object): 42 | """Closure options.""" 43 | parent_attr = "parent2" 44 | 45 | def __unicode__(self): 46 | return "%s: %s" % (self.id, self.name) 47 | 48 | class Blah(models.Model): 49 | """A test model for foreign keys""" 50 | thing = models.CharField(max_length=32) 51 | 52 | class TCSUB(TC): 53 | """Testing closure subclasses.""" 54 | extrafield = models.IntegerField() 55 | 56 | class TCSUB2(TCSUB): 57 | """Testing closure subclasses.""" 58 | ef = models.IntegerField() 59 | 60 | class BaseTestCase(TestCase): 61 | """Providing details for testing.""" 62 | 63 | def setUp(self): 64 | self.a = TC.objects.create(name="a") 65 | self.b = TC.objects.create(name="b") 66 | self.c = TC.objects.create(name="c") 67 | self.d = TC.objects.create(name="d") 68 | 69 | # We cheat here, we don't care about the __unicode__ method, 70 | # It's only useful when we're working out why the tests fail. 71 | self.a.__unicode__() 72 | 73 | def test_unicode(self): 74 | """Test unicoding of the closures works.""" 75 | # No, it's a method for unittest! 76 | # pylint: disable=R0201 77 | 78 | for obj in TCClosure.objects.all(): 79 | obj.__unicode__() 80 | 81 | def test_adding(self): 82 | """ 83 | Tests that adding a new parent relationship creates closures 84 | """ 85 | self.failUnlessEqual(TCClosure.objects.count(), 4) 86 | self.b.parent2 = self.a 87 | self.b.save() 88 | self.failUnlessEqual(TCClosure.objects.count(), 5) 89 | self.c.parent2 = self.b 90 | self.c.save() 91 | # Test double save 92 | self.c.save() 93 | self.d.parent2 = self.c 94 | self.d.save() 95 | self.failUnlessEqual(TCClosure.objects.count(), 10) 96 | 97 | def test_deletion(self): 98 | """ 99 | Tests that deleting a relationship removes the closure entries. 100 | """ 101 | self.failUnlessEqual(TCClosure.objects.count(), 4) 102 | self.b.parent2 = self.a 103 | self.b.save() 104 | self.failUnlessEqual(TCClosure.objects.count(), 5) 105 | self.b.parent2 = None 106 | self.b.save() 107 | self.failUnlessEqual(TCClosure.objects.count(), 4) 108 | self.b.parent2 = self.a 109 | self.b.save() 110 | self.c.parent2 = self.b 111 | self.c.save() 112 | self.failUnlessEqual(TCClosure.objects.count(), 7) 113 | self.b.delete() 114 | self.failUnlessEqual(TCClosure.objects.count(), 2) 115 | 116 | class AncestorTestCase(TestCase): 117 | """Testing things to do with ancestors.""" 118 | 119 | def setUp(self): 120 | self.a = TC.objects.create(name="a") 121 | self.b = TC.objects.create(name="b") 122 | self.c = TC.objects.create(name="c") 123 | self.b.parent2 = self.a 124 | self.b.save() 125 | self.c.parent2 = self.b 126 | self.c.save() 127 | 128 | def test_ancestors(self): 129 | """Testing the ancestors method.""" 130 | self.failUnlessEqual(list(self.a.get_ancestors()), []) 131 | self.failUnlessEqual(list(self.b.get_ancestors()), [self.a]) 132 | self.failUnlessEqual( 133 | list(self.a.get_ancestors(include_self=True)), 134 | [self.a] 135 | ) 136 | self.failUnlessEqual( 137 | list(self.c.get_ancestors(include_self=True)), 138 | [self.c, self.b, self.a] 139 | ) 140 | self.failUnlessEqual( 141 | list(self.c.get_ancestors(include_self=True, depth=1)), 142 | [self.c, self.b] 143 | ) 144 | 145 | def test_descendants(self): 146 | """Testing the descendants method.""" 147 | self.failUnlessEqual(list(self.c.get_descendants()), []) 148 | self.failUnlessEqual(list(self.b.get_descendants()), [self.c]) 149 | self.failUnlessEqual( 150 | list(self.a.get_descendants(include_self=True)), 151 | [self.a, self.b, self.c] 152 | ) 153 | self.failUnlessEqual( 154 | list(self.c.get_descendants(include_self=True)), 155 | [self.c] 156 | ) 157 | 158 | def test_children(self): 159 | """Testing the children method.""" 160 | self.failUnlessEqual(list(self.c.get_children()), []) 161 | self.failUnlessEqual(list(self.b.get_children()), [self.c]) 162 | 163 | class RebuildTestCase(TestCase): 164 | """Test rebuilding the tree""" 165 | 166 | def setUp(self): 167 | self.a = TC.objects.create(name="a") 168 | self.b = TC.objects.create(name="b") 169 | self.b.parent2 = self.a 170 | self.b.save() 171 | self.c = TC.objects.create(name="c") 172 | self.c.parent2 = self.b 173 | self.c.save() 174 | self.d = TC.objects.create(name="d") 175 | self.d.parent2 = self.a 176 | self.d.save() 177 | 178 | def test_rebuild_from_full(self): 179 | """Test a rebuild when the tree is correct.""" 180 | 181 | self.failUnlessEqual(TCClosure.objects.count(), 8) 182 | TC.rebuildtable() 183 | self.failUnlessEqual(TCClosure.objects.count(), 8) 184 | 185 | def test_rebuild_from_empty(self): 186 | """Test a rebuild when the tree is empty.""" 187 | 188 | TCClosure.objects.all().delete() 189 | TC.rebuildtable() 190 | self.failUnlessEqual(TCClosure.objects.count(), 8) 191 | 192 | def test_rebuild_from_partial(self): 193 | """Test a rebuild when the tree is partially empty.""" 194 | 195 | TCClosure.objects.get(parent__name='a', child__name='a').delete() 196 | TCClosure.objects.get(parent__name='a', child__name='c').delete() 197 | self.failUnlessEqual(TCClosure.objects.count(), 6) 198 | TC.rebuildtable() 199 | self.failUnlessEqual(TCClosure.objects.count(), 8) 200 | 201 | class InitialClosureTestCase(TestCase): 202 | """Tests for when things are created with a parent.""" 203 | 204 | def test_creating_with_parent(self): 205 | """Make sure closures are created when making objects.""" 206 | a = TC.objects.create(name="a") 207 | self.failUnlessEqual(TCClosure.objects.count(), 1) 208 | b = TC.objects.create(name="b", parent2=a) 209 | self.failUnlessEqual(TCClosure.objects.count(), 3) 210 | TC.objects.create(name="c", parent2=b) 211 | self.failUnlessEqual(TCClosure.objects.count(), 6) 212 | 213 | class IsTestCase(TestCase): 214 | """Test some useful methods.""" 215 | 216 | def setUp(self): 217 | self.a = TC.objects.create(name="a") 218 | self.b = TC.objects.create(name="b", parent2=self.a) 219 | self.c = TC.objects.create(name="c", parent2=self.b) 220 | self.d = TC.objects.create(name="d", parent2=self.c) 221 | self.e = TC.objects.create(name="e", parent2=self.b) 222 | self.f = TC.objects.create(name="f", parent2=self.e) 223 | 224 | def test_ancestor_of(self): 225 | """Test is_ancestor_of method.""" 226 | self.assertEqual(self.a.is_ancestor_of(self.c), True) 227 | self.assertEqual(self.a.is_ancestor_of(self.d), True) 228 | self.assertEqual(self.a.is_ancestor_of(self.e), True) 229 | self.assertEqual(self.d.is_ancestor_of(self.b), False) 230 | self.assertEqual( 231 | self.d.is_ancestor_of(self.b, include_self=True), 232 | False 233 | ) 234 | self.assertEqual(self.f.is_ancestor_of(self.f), False) 235 | self.assertEqual( 236 | self.f.is_ancestor_of(self.f, include_self=False), 237 | False 238 | ) 239 | self.assertEqual(self.f.is_ancestor_of(self.f, include_self=True), True) 240 | 241 | def test_descendant_of(self): 242 | """Test id_descendant_of method.""" 243 | self.assertEqual(self.a.is_descendant_of(self.a), False) 244 | self.assertEqual(self.f.is_descendant_of(self.f), False) 245 | self.assertEqual( 246 | self.a.is_descendant_of(self.a, include_self=False), False 247 | ) 248 | self.assertEqual( 249 | self.a.is_descendant_of(self.a, include_self=True), True 250 | ) 251 | self.assertEqual(self.a.is_descendant_of(self.c), False) 252 | self.assertEqual(self.c.is_descendant_of(self.a), True) 253 | 254 | def test_get_root(self): 255 | """Test get_root method""" 256 | self.assertEqual(self.a.get_root(), self.a) 257 | self.assertEqual(self.b.get_root(), self.a) 258 | self.assertEqual(self.f.get_root(), self.a) 259 | 260 | def test_child_node(self): 261 | """Test is_child_node method""" 262 | self.assertEqual(self.a.is_child_node(), False) 263 | self.assertEqual(self.b.is_child_node(), True) 264 | self.assertEqual(self.f.is_child_node(), True) 265 | 266 | class PrepopulateTestCase(TestCase): 267 | """Test prepopulating.""" 268 | 269 | def setUp(self): 270 | self.a = TC.objects.create(name="a") 271 | self.b = TC.objects.create(name="b", parent2=self.a) 272 | self.c = TC.objects.create(name="c", parent2=self.b) 273 | self.d = TC.objects.create(name="d", parent2=self.c) 274 | self.e = TC.objects.create(name="e", parent2=self.b) 275 | self.f = TC.objects.create(name="f", parent2=self.e) 276 | 277 | def test_prepopulate(self): 278 | """Test prepopulating""" 279 | with self.assertNumQueries(6): 280 | children = [] 281 | for node in self.a.get_descendants(): 282 | children.extend(list(node.get_children())) 283 | self.assertEqual(len(children), 4) 284 | with self.assertNumQueries(1): 285 | children = [] 286 | queryset = self.a.get_descendants() 287 | self.a.prepopulate(queryset) 288 | for node in queryset: 289 | children.extend(list(node.get_children())) 290 | self.assertEqual(len(children), 4) 291 | 292 | def test_prepopulate_not_root(self): 293 | """Test prepopulating when we're not the root""" 294 | with self.assertNumQueries(5): 295 | children = [] 296 | for node in self.b.get_descendants(): 297 | children.extend(list(node.get_children())) 298 | self.assertEqual(len(children), 2) 299 | with self.assertNumQueries(1): 300 | children = [] 301 | queryset = self.b.get_descendants() 302 | self.b.prepopulate(queryset) 303 | for node in queryset: 304 | children.extend(list(node.get_children())) 305 | self.assertEqual(len(children), 2) 306 | 307 | class SentinelModel(ClosureModel): 308 | """A model using a sentinel attribute.""" 309 | location = models.ForeignKey( 310 | "IntermediateModel", 311 | null=True, 312 | blank=True 313 | ) 314 | 315 | @property 316 | def parent(self): 317 | """Return the object's parent.""" 318 | if self.location: 319 | return self.location.real_parent 320 | 321 | class ClosureMeta(object): 322 | """Closure options.""" 323 | sentinel_attr = "location" 324 | parent_attr = "parent" 325 | 326 | class IntermediateModel(models.Model): 327 | """The intermediate model between the sentinel model 328 | and its parent (itself). 329 | """ 330 | real_parent = models.ForeignKey( 331 | 'SentinelModel', 332 | null=True, 333 | blank=True, 334 | ) 335 | 336 | class SentinelAttributeTestCase(TestCase): 337 | """Test functionality of the sentinel attribute.""" 338 | 339 | def setUp(self): 340 | self.a = SentinelModel.objects.create() 341 | self.b = SentinelModel.objects.create() 342 | self.c = SentinelModel.objects.create() 343 | self.d = SentinelModel.objects.create() 344 | self.l1 = IntermediateModel(real_parent=self.a) 345 | self.l2 = IntermediateModel(real_parent=self.b) 346 | self.l3 = IntermediateModel(real_parent=self.c) 347 | 348 | def test_closure_creation(self): 349 | '''Test creation of closures in the sentinel case''' 350 | 351 | self.failUnlessEqual(SentinelModelClosure.objects.count(), 4) 352 | 353 | self.b.location = self.l1 354 | self.b.save() 355 | self.failUnlessEqual(self.b.parent, self.a) 356 | self.c.location = self.l2 357 | self.c.save() 358 | 359 | self.failUnlessEqual(SentinelModelClosure.objects.count(), 7) 360 | 361 | class TCNoMeta(ClosureModel): 362 | """A test model without a ClosureMeta.""" 363 | parent = models.ForeignKey( 364 | "self", 365 | related_name="children", 366 | null=True, 367 | blank=True 368 | ) 369 | name = models.CharField(max_length=32) 370 | 371 | class NoMetaTestCase(TestCase): 372 | '''Testing models without a ClosureMeta.''' 373 | 374 | def test_basic(self): 375 | """ 376 | Basic test that you don't need the ClosureMeta class. 377 | """ 378 | a = TCNoMeta.objects.create(name='a') 379 | b = TCNoMeta.objects.create(name='b', parent=a) 380 | c = TCNoMeta.objects.create(name='c', parent=b) 381 | self.failUnlessEqual(a.get_descendants().count(), 2) 382 | self.failUnlessEqual(c.get_ancestors().count(), 2) 383 | --------------------------------------------------------------------------------