Initial commit

This commit is contained in:
2024-08-27 20:33:44 +02:00
commit 1f1832267d
14794 changed files with 1599592 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
"""
See PEP 386 (https://www.python.org/dev/peps/pep-0386/)
Release logic:
1. Remove ".devX" from __version__ (below)
2. git add treebeard/__init__.py
3. git commit -m 'Bump to <version>'
4. git tag <version>
5. git push
6. ensure that all tests pass on Github Actions
7. git push --tags
8. pip install --upgrade pip wheel twine
9. python setup.py clean --all
9. python setup.py sdist bdist_wheel
10. twine upload dist/*
11. bump the version, append ".dev0" to __version__
12. git add treebeard/__init__.py
13. git commit -m 'Start with <version>'
14. git push
"""
__version__ = '4.7.1'

View File

@@ -0,0 +1,127 @@
"""Django admin support for treebeard"""
import sys
from django.conf import settings
from django.contrib import admin, messages
from django.http import HttpResponse, HttpResponseBadRequest
from django.urls import path
from django.utils.translation import gettext_lazy as _
from django.utils.encoding import force_str
from treebeard.exceptions import (InvalidPosition, MissingNodeOrderBy,
InvalidMoveToDescendant, PathOverflow)
from treebeard.al_tree import AL_Node
class TreeAdmin(admin.ModelAdmin):
"""Django Admin class for treebeard."""
change_list_template = 'admin/tree_change_list.html'
def get_queryset(self, request):
if issubclass(self.model, AL_Node):
# AL Trees return a list instead of a QuerySet for .get_tree()
# So we're returning the regular .get_queryset cause we will use
# the old admin
return super().get_queryset(request)
else:
return self.model.get_tree()
def changelist_view(self, request, extra_context=None):
if issubclass(self.model, AL_Node):
# For AL trees, use the old admin display
self.change_list_template = 'admin/tree_list.html'
if extra_context is None:
extra_context = {}
request_context = any(
map(
lambda tmpl:
tmpl.get('BACKEND', None) == 'django.template.backends.django.DjangoTemplates' and
tmpl.get('APP_DIRS', False) and
'django.template.context_processors.request' in tmpl.get('OPTIONS', {}).get('context_processors', []),
settings.TEMPLATES
)
)
lacks_request = ('request' not in extra_context and not request_context)
if lacks_request:
extra_context['request'] = request
return super().changelist_view(request, extra_context)
def get_urls(self):
"""
Adds a url to move nodes to this admin
"""
urls = super().get_urls()
from django.views.i18n import JavaScriptCatalog
jsi18n_url = path('jsi18n/',
JavaScriptCatalog.as_view(packages=['treebeard']),
name='javascript-catalog'
)
new_urls = [
path('move/', self.admin_site.admin_view(self.move_node), ),
jsi18n_url,
]
return new_urls + urls
def get_node(self, node_id):
return self.model.objects.get(pk=node_id)
def try_to_move_node(self, as_child, node, pos, request, target):
try:
node.move(target, pos=pos)
# Call the save method on the (reloaded) node in order to trigger
# possible signal handlers etc.
node = self.get_node(node.pk)
node.save()
except (MissingNodeOrderBy, PathOverflow, InvalidMoveToDescendant,
InvalidPosition):
e = sys.exc_info()[1]
# An error was raised while trying to move the node, then set an
# error message and return 400, this will cause a reload on the
# client to show the message
messages.error(request,
_('Exception raised while moving node: %s') % _(
force_str(e)))
return HttpResponseBadRequest('Exception raised during move')
if as_child:
msg = _('Moved node "%(node)s" as child of "%(other)s"')
else:
msg = _('Moved node "%(node)s" as sibling of "%(other)s"')
messages.info(request, msg % {'node': node, 'other': target})
return HttpResponse('OK')
def move_node(self, request):
try:
node_id = request.POST['node_id']
target_id = request.POST['sibling_id']
as_child = bool(int(request.POST.get('as_child', 0)))
except (KeyError, ValueError):
# Some parameters were missing return a BadRequest
return HttpResponseBadRequest('Malformed POST params')
node = self.get_node(node_id)
target = self.get_node(target_id)
is_sorted = True if node.node_order_by else False
pos = {
(True, True): 'sorted-child',
(True, False): 'last-child',
(False, True): 'sorted-sibling',
(False, False): 'left',
}[as_child, is_sorted]
return self.try_to_move_node(as_child, node, pos, request, target)
def admin_factory(form_class):
"""Dynamically build a TreeAdmin subclass for the given form class.
:param form_class:
:return: A TreeAdmin subclass.
"""
return type(
form_class.__name__ + 'Admin',
(TreeAdmin,),
dict(form=form_class))

View File

@@ -0,0 +1,405 @@
"""Adjacency List"""
from django.core import serializers
from django.db import models
from django.utils.translation import gettext_noop as _
from treebeard.exceptions import InvalidMoveToDescendant, NodeAlreadySaved
from treebeard.models import Node
def get_result_class(cls):
"""
For the given model class, determine what class we should use for the
nodes returned by its tree methods (such as get_children).
Usually this will be trivially the same as the initial model class,
but there are special cases when model inheritance is in use:
* If the model extends another via multi-table inheritance, we need to
use whichever ancestor originally implemented the tree behaviour (i.e.
the one which defines the 'parent' field). We can't use the
subclass, because it's not guaranteed that the other nodes reachable
from the current one will be instances of the same subclass.
* If the model is a proxy model, the returned nodes should also use
the proxy class.
"""
base_class = cls._meta.get_field('parent').model
if cls._meta.proxy_for_model == base_class:
return cls
else:
return base_class
class AL_NodeManager(models.Manager):
"""Custom manager for nodes in an Adjacency List tree."""
def get_queryset(self):
"""Sets the custom queryset as the default."""
if self.model.node_order_by:
order_by = ['parent'] + list(self.model.node_order_by)
else:
order_by = ['parent', 'sib_order']
return super().get_queryset().order_by(*order_by)
class AL_Node(Node):
"""Abstract model to create your own Adjacency List Trees."""
objects = AL_NodeManager()
node_order_by = None
@classmethod
def add_root(cls, **kwargs):
"""Adds a root node to the tree."""
if len(kwargs) == 1 and 'instance' in kwargs:
# adding the passed (unsaved) instance to the tree
newobj = kwargs['instance']
if not newobj._state.adding:
raise NodeAlreadySaved("Attempted to add a tree node that is "\
"already in the database")
else:
newobj = cls(**kwargs)
newobj._cached_depth = 1
if not cls.node_order_by:
try:
max = get_result_class(cls).objects.filter(
parent__isnull=True).order_by(
'sib_order').reverse()[0].sib_order
except IndexError:
max = 0
newobj.sib_order = max + 1
newobj.save()
return newobj
@classmethod
def get_root_nodes(cls):
""":returns: A queryset containing the root nodes in the tree."""
return get_result_class(cls).objects.filter(parent__isnull=True)
def get_depth(self, update=False):
"""
:returns: the depth (level) of the node
Caches the result in the object itself to help in loops.
:param update: Updates the cached value.
"""
if self.parent_id is None:
return 1
try:
if update:
del self._cached_depth
else:
return self._cached_depth
except AttributeError:
pass
depth = 0
node = self
while node:
node = node.parent
depth += 1
self._cached_depth = depth
return depth
def get_children(self):
""":returns: A queryset of all the node's children"""
return get_result_class(self.__class__).objects.filter(parent=self)
def get_parent(self, update=False):
""":returns: the parent node of the current node object."""
if self._meta.proxy_for_model:
# the current node is a proxy model; the returned parent
# should be the same proxy model, so we need to explicitly
# fetch it as an instance of that model rather than simply
# following the 'parent' relation
if self.parent_id is None:
return None
else:
return self.__class__.objects.get(pk=self.parent_id)
else:
return self.parent
def get_ancestors(self):
"""
:returns: A *list* containing the current node object's ancestors,
starting by the root node and descending to the parent.
"""
ancestors = []
if self._meta.proxy_for_model:
# the current node is a proxy model; our result set
# should use the same proxy model, so we need to
# explicitly fetch instances of that model
# when following the 'parent' relation
cls = self.__class__
node = self
while node.parent_id:
node = cls.objects.get(pk=node.parent_id)
ancestors.insert(0, node)
else:
node = self.parent
while node:
ancestors.insert(0, node)
node = node.parent
return ancestors
def get_root(self):
""":returns: the root node for the current node object."""
ancestors = self.get_ancestors()
if ancestors:
return ancestors[0]
return self
def is_descendant_of(self, node):
"""
:returns: ``True`` if the node if a descendant of another node given
as an argument, else, returns ``False``
"""
return self.pk in [obj.pk for obj in node.get_descendants()]
@classmethod
def dump_bulk(cls, parent=None, keep_ids=True):
"""Dumps a tree branch to a python data structure."""
serializable_cls = cls._get_serializable_model()
if (
parent and serializable_cls != cls and
parent.__class__ != serializable_cls
):
parent = serializable_cls.objects.get(pk=parent.pk)
# a list of nodes: not really a queryset, but it works
objs = serializable_cls.get_tree(parent)
ret, lnk = [], {}
pk_field = cls._meta.pk.attname
for node, pyobj in zip(objs, serializers.serialize('python', objs)):
depth = node.get_depth()
# django's serializer stores the attributes in 'fields'
fields = pyobj['fields']
del fields['parent']
# non-sorted trees have this
if 'sib_order' in fields:
del fields['sib_order']
if pk_field in fields:
del fields[pk_field]
newobj = {'data': fields}
if keep_ids:
newobj[pk_field] = pyobj['pk']
if (not parent and depth == 1) or\
(parent and depth == parent.get_depth()):
ret.append(newobj)
else:
parentobj = lnk[node.parent_id]
if 'children' not in parentobj:
parentobj['children'] = []
parentobj['children'].append(newobj)
lnk[node.pk] = newobj
return ret
def add_child(self, **kwargs):
"""Adds a child to the node."""
cls = get_result_class(self.__class__)
if len(kwargs) == 1 and 'instance' in kwargs:
# adding the passed (unsaved) instance to the tree
newobj = kwargs['instance']
if not newobj._state.adding:
raise NodeAlreadySaved("Attempted to add a tree node that is "\
"already in the database")
else:
newobj = cls(**kwargs)
try:
newobj._cached_depth = self._cached_depth + 1
except AttributeError:
pass
if not cls.node_order_by:
try:
max = cls.objects.filter(parent=self).reverse(
)[0].sib_order
except IndexError:
max = 0
newobj.sib_order = max + 1
newobj.parent = self
newobj.save()
return newobj
@classmethod
def _get_tree_recursively(cls, results, parent, depth):
if parent:
nodes = parent.get_children()
else:
nodes = cls.get_root_nodes()
for node in nodes:
node._cached_depth = depth
results.append(node)
cls._get_tree_recursively(results, node, depth + 1)
@classmethod
def get_tree(cls, parent=None):
"""
:returns: A list of nodes ordered as DFS, including the parent. If
no parent is given, the entire tree is returned.
"""
if parent:
depth = parent.get_depth() + 1
results = [parent]
else:
depth = 1
results = []
cls._get_tree_recursively(results, parent, depth)
return results
def get_descendants(self):
"""
:returns: A *list* of all the node's descendants, doesn't
include the node itself
"""
return self.__class__.get_tree(parent=self)[1:]
def get_descendant_count(self):
""":returns: the number of descendants of a nodee"""
return len(self.get_descendants())
def get_siblings(self):
"""
:returns: A queryset of all the node's siblings, including the node
itself.
"""
if self.parent:
return get_result_class(self.__class__).objects.filter(
parent=self.parent)
return self.__class__.get_root_nodes()
def add_sibling(self, pos=None, **kwargs):
"""Adds a new node as a sibling to the current node object."""
pos = self._prepare_pos_var_for_add_sibling(pos)
if len(kwargs) == 1 and 'instance' in kwargs:
# adding the passed (unsaved) instance to the tree
newobj = kwargs['instance']
if not newobj._state.adding:
raise NodeAlreadySaved("Attempted to add a tree node that is "\
"already in the database")
else:
# creating a new object
newobj = get_result_class(self.__class__)(**kwargs)
if not self.node_order_by:
newobj.sib_order = self.__class__._get_new_sibling_order(pos,
self)
newobj.parent_id = self.parent_id
newobj.save()
return newobj
@classmethod
def _is_target_pos_the_last_sibling(cls, pos, target):
return pos == 'last-sibling' or (
pos == 'right' and target == target.get_last_sibling())
@classmethod
def _make_hole_in_db(cls, min, target_node):
qset = get_result_class(cls).objects.filter(sib_order__gte=min)
if target_node.is_root():
qset = qset.filter(parent__isnull=True)
else:
qset = qset.filter(parent=target_node.parent)
qset.update(sib_order=models.F('sib_order') + 1)
@classmethod
def _make_hole_and_get_sibling_order(cls, pos, target_node):
siblings = target_node.get_siblings()
siblings = {
'left': siblings.filter(sib_order__gte=target_node.sib_order),
'right': siblings.filter(sib_order__gt=target_node.sib_order),
'first-sibling': siblings
}[pos]
sib_order = {
'left': target_node.sib_order,
'right': target_node.sib_order + 1,
'first-sibling': 1
}[pos]
try:
min = siblings.order_by('sib_order')[0].sib_order
except IndexError:
min = 0
if min:
cls._make_hole_in_db(min, target_node)
return sib_order
@classmethod
def _get_new_sibling_order(cls, pos, target_node):
if cls._is_target_pos_the_last_sibling(pos, target_node):
sib_order = target_node.get_last_sibling().sib_order + 1
else:
sib_order = cls._make_hole_and_get_sibling_order(pos, target_node)
return sib_order
def move(self, target, pos=None):
"""
Moves the current node and all it's descendants to a new position
relative to another node.
"""
pos = self._prepare_pos_var_for_move(pos)
sib_order = None
parent = None
if pos in ('first-child', 'last-child', 'sorted-child'):
# moving to a child
if not target.is_leaf():
target = target.get_last_child()
pos = {'first-child': 'first-sibling',
'last-child': 'last-sibling',
'sorted-child': 'sorted-sibling'}[pos]
else:
parent = target
if pos == 'sorted-child':
pos = 'sorted-sibling'
else:
pos = 'first-sibling'
sib_order = 1
if target.is_descendant_of(self):
raise InvalidMoveToDescendant(
_("Can't move node to a descendant."))
if self == target and (
(pos == 'left') or
(pos in ('right', 'last-sibling') and
target == target.get_last_sibling()) or
(pos == 'first-sibling' and
target == target.get_first_sibling())):
# special cases, not actually moving the node so no need to UPDATE
return
if pos == 'sorted-sibling':
if parent:
self.parent = parent
else:
self.parent = target.parent
else:
if sib_order:
self.sib_order = sib_order
else:
self.sib_order = self.__class__._get_new_sibling_order(pos,
target)
if parent:
self.parent = parent
else:
self.parent = target.parent
self.save()
class Meta:
"""Abstract model."""
abstract = True

View File

@@ -0,0 +1,31 @@
"""Treebeard exceptions"""
class InvalidPosition(Exception):
"""Raised when passing an invalid pos value"""
class InvalidMoveToDescendant(Exception):
"""Raised when attempting to move a node to one of it's descendants."""
class NodeAlreadySaved(Exception):
"""
Raised when attempting to add a node which is already saved to the
database.
"""
class MissingNodeOrderBy(Exception):
"""
Raised when an operation needs a missing
:attr:`~treebeard.MP_Node.node_order_by` attribute
"""
class PathOverflow(Exception):
"""
Raised when trying to add or move a node to a position where no more nodes
can be added (see :attr:`~treebeard.MP_Node.path` and
:attr:`~treebeard.MP_Node.alphabet` for more info)
"""

View File

@@ -0,0 +1,226 @@
"""Forms for treebeard."""
from django import forms
from django.db.models.query import QuerySet
from django.forms.models import ErrorList
from django.forms.models import modelform_factory as django_modelform_factory
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from treebeard.al_tree import AL_Node
from treebeard.mp_tree import MP_Node
from treebeard.ns_tree import NS_Node
class MoveNodeForm(forms.ModelForm):
"""
Form to handle moving a node in a tree.
Handles sorted/unsorted trees.
It adds two fields to the form:
- Relative to: The target node where the current node will
be moved to.
- Position: The position relative to the target node that
will be used to move the node. These can be:
- For sorted trees: ``Child of`` and ``Sibling of``
- For unsorted trees: ``First child of``, ``Before`` and
``After``
.. warning::
Subclassing :py:class:`MoveNodeForm` directly is
discouraged, since special care is needed to handle
excluded fields, and these change depending on the
tree type.
It is recommended that the :py:func:`movenodeform_factory`
function is used instead.
"""
__position_choices_sorted = (
('sorted-child', _('Child of')),
('sorted-sibling', _('Sibling of')),
)
__position_choices_unsorted = (
('first-child', _('First child of')),
('left', _('Before')),
('right', _('After')),
)
_position = forms.ChoiceField(required=True, label=_("Position"))
_ref_node_id = forms.ChoiceField(required=False, label=_("Relative to"))
def _get_position_ref_node(self, instance):
if self.is_sorted:
position = 'sorted-child'
node_parent = instance.get_parent()
if node_parent:
ref_node_id = node_parent.pk
else:
ref_node_id = ''
else:
prev_sibling = instance.get_prev_sibling()
if prev_sibling:
position = 'right'
ref_node_id = prev_sibling.pk
else:
position = 'first-child'
if instance.is_root():
ref_node_id = ''
else:
ref_node_id = instance.get_parent().pk
return {'_ref_node_id': ref_node_id,
'_position': position}
def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None,
initial=None, error_class=ErrorList, label_suffix=':',
empty_permitted=False, instance=None, **kwargs):
opts = self._meta
if opts.model is None:
raise ValueError('ModelForm has no model class specified')
# update the '_position' field choices
self.is_sorted = getattr(opts.model, 'node_order_by', False)
if self.is_sorted:
choices_sort_mode = self.__class__.__position_choices_sorted
else:
choices_sort_mode = self.__class__.__position_choices_unsorted
self.declared_fields['_position'].choices = choices_sort_mode
# update the '_ref_node_id' choices
choices = self.mk_dropdown_tree(opts.model, for_node=instance)
self.declared_fields['_ref_node_id'].choices = choices
# use the formfield `to_python` method to coerse the field for custom ids
pkFormField = opts.model._meta.pk.formfield()
self.declared_fields['_ref_node_id'].coerce = pkFormField.to_python if pkFormField else int
# put initial data for these fields into a map, update the map with
# initial data, and pass this new map to the parent constructor as
# initial data
if instance is None:
initial_ = {}
else:
initial_ = self._get_position_ref_node(instance)
if initial is not None:
initial_.update(initial)
super().__init__(
data=data, files=files, auto_id=auto_id, prefix=prefix,
initial=initial_, error_class=error_class,
label_suffix=label_suffix, empty_permitted=empty_permitted,
instance=instance, **kwargs)
def _clean_cleaned_data(self):
""" delete auxilary fields not belonging to node model """
reference_node_id = None
if '_ref_node_id' in self.cleaned_data:
if self.cleaned_data['_ref_node_id'] != '0':
reference_node_id = self.cleaned_data['_ref_node_id']
if reference_node_id.isdigit():
reference_node_id = int(reference_node_id)
del self.cleaned_data['_ref_node_id']
position_type = self.cleaned_data['_position']
del self.cleaned_data['_position']
return position_type, reference_node_id
def save(self, commit=True):
position_type, reference_node_id = self._clean_cleaned_data()
if self.instance._state.adding:
if reference_node_id:
reference_node = self._meta.model.objects.get(
pk=reference_node_id)
self.instance = reference_node.add_child(instance=self.instance)
self.instance.move(reference_node, pos=position_type)
else:
self.instance = self._meta.model.add_root(instance=self.instance)
else:
self.instance.save()
if reference_node_id:
reference_node = self._meta.model.objects.get(
pk=reference_node_id)
self.instance.move(reference_node, pos=position_type)
else:
if self.is_sorted:
pos = 'sorted-sibling'
else:
pos = 'first-sibling'
self.instance.move(self._meta.model.get_first_root_node(), pos)
# Reload the instance
self.instance.refresh_from_db()
super().save(commit=commit)
return self.instance
@staticmethod
def is_loop_safe(for_node, possible_parent):
if for_node is not None:
return not (
possible_parent == for_node
) or (possible_parent.is_descendant_of(for_node))
return True
@staticmethod
def mk_indent(level):
return '&nbsp;&nbsp;&nbsp;&nbsp;' * (level - 1)
@classmethod
def add_subtree(cls, for_node, node, options):
""" Recursively build options tree. """
if cls.is_loop_safe(for_node, node):
for item, _ in node.get_annotated_list(node):
options.append((item.pk, mark_safe(cls.mk_indent(item.get_depth()) + escape(item))))
@classmethod
def mk_dropdown_tree(cls, model, for_node=None):
""" Creates a tree-like list of choices """
options = [(None, _('-- root --'))]
for node in model.get_root_nodes():
cls.add_subtree(for_node, node, options)
return options
def movenodeform_factory(model, form=MoveNodeForm, fields=None, exclude=None,
formfield_callback=None, widgets=None):
"""Dynamically build a MoveNodeForm subclass with the proper Meta.
:param Node model:
The subclass of :py:class:`Node` that will be handled
by the form.
:param form:
The form class that will be used as a base. By
default, :py:class:`MoveNodeForm` will be used.
:return: A :py:class:`MoveNodeForm` subclass
"""
_exclude = _get_exclude_for_model(model, exclude)
return django_modelform_factory(
model, form, fields, _exclude, formfield_callback, widgets)
def _get_exclude_for_model(model, exclude):
if exclude:
_exclude = tuple(exclude)
else:
_exclude = ()
if issubclass(model, AL_Node):
_exclude += ('sib_order', 'parent')
elif issubclass(model, MP_Node):
_exclude += ('depth', 'numchild', 'path')
elif issubclass(model, NS_Node):
_exclude += ('depth', 'lft', 'rgt', 'tree_id')
return _exclude

View File

@@ -0,0 +1,86 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-15 20:56+0000\n"
"PO-Revision-Date: 2018-06-15 23:09+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 1.5.7\n"
#: treebeard/admin.py:106
#, python-format
msgid "Exception raised while moving node: %s"
msgstr "Ausnahmefehler in folgendem Element: %s"
#: treebeard/admin.py:110
#, python-format
msgid "Moved node \"%(node)s\" as child of \"%(other)s\""
msgstr "Element \"%(node)s\" positioniert unterhalb von \"%(other)s\""
#: treebeard/admin.py:112
#, python-format
msgid "Moved node \"%(node)s\" as sibling of \"%(other)s\""
msgstr "Element \"%(node)s\" positioniert gleichauf mit \"%(other)s\""
#: treebeard/al_tree.py:373 treebeard/mp_tree.py:474 treebeard/ns_tree.py:363
msgid "Can't move node to a descendant."
msgstr "Kann Element nicht in ein eigenes Unter-Element verschieben"
#: treebeard/forms.py:46
msgid "Child of"
msgstr "als Unterkategorie von"
#: treebeard/forms.py:47
msgid "Sibling of"
msgstr "auf gleicher Ebene wie"
#: treebeard/forms.py:51
msgid "First child of"
msgstr "Als erste Unterkategorie von"
#: treebeard/forms.py:52
msgid "Before"
msgstr "Vor"
#: treebeard/forms.py:53
msgid "After"
msgstr "Nach"
#: treebeard/forms.py:56
msgid "Position"
msgstr "Position"
#: treebeard/forms.py:60
msgid "Relative to"
msgstr "relativ zu"
#: treebeard/forms.py:189
msgid "-- root --"
msgstr "-- Basiskategorie --"
#: treebeard/mp_tree.py:382
msgid ""
"The new node is too deep in the tree, try increasing the path.max_length "
"property and UPDATE your database"
msgstr ""
"Das neue Element ist zu tief positioniert. Versuche path.max_length zu "
"erhöhen und aktualisiere die Datenbank"
#: treebeard/mp_tree.py:1114
#, python-format
msgid "Path Overflow from: '%s'"
msgstr "Pfad Überlauf von: '%s'"
#: treebeard/templatetags/admin_tree.py:249
msgid "Return to ordered tree"
msgstr "Zurück zur geordneten Baumansicht"

View File

@@ -0,0 +1,30 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-15 20:56+0000\n"
"PO-Revision-Date: 2018-06-15 23:10+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 1.5.7\n"
#: treebeard/static/treebeard/treebeard-admin.js:158
msgid "Abort"
msgstr "Abbruch"
#: treebeard/static/treebeard/treebeard-admin.js:180
msgid "As Sibling"
msgstr "Als Geschwister-Element"
#: treebeard/static/treebeard/treebeard-admin.js:198
msgid "As child"
msgstr "Als Kind-Element"

View File

@@ -0,0 +1,78 @@
msgid ""
msgstr ""
"Project-Id-Version: Django-treebeard\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2011-07-18 17:36+0200\n"
"PO-Revision-Date: 2010-05-03 23:40-0500\n"
"Last-Translator: Gustavo Picon <tabo@tabo.pe>\n"
"Language-Team: Spanish\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: admin.py:113
#, python-format
msgid "Moved node \"%(node)s\" as child of \"%(other)s\""
msgstr ""
#: admin.py:119
#, python-format
msgid "Moved node \"%(node)s\" as sibling of \"%(other)s\""
msgstr ""
#: admin.py:129
#, python-format
msgid "Exception raised while moving node: %s"
msgstr ""
#: al_tree.py:319 mp_tree.py:641 ns_tree.py:308
msgid "Can't move node to a descendant."
msgstr ""
#: forms.py:17
msgid "Child of"
msgstr "Hijo de"
#: forms.py:18
msgid "Sibling of"
msgstr "Hermano de"
#: forms.py:22
msgid "First child of"
msgstr "Primer hijo de"
#: forms.py:23
msgid "Before"
msgstr "Antes"
#: forms.py:24
msgid "After"
msgstr "Después"
#: forms.py:27
msgid "Position"
msgstr "Posición"
#: forms.py:31
msgid "Relative to"
msgstr "Relativo a"
#: forms.py:81
msgid "-- root --"
msgstr "-- raíz --"
#: mp_tree.py:521
msgid ""
"The new node is too deep in the tree, try increasing the path.max_length "
"property and UPDATE your database"
msgstr ""
#: mp_tree.py:702
#, python-format
msgid "Path Overflow from: '%s'"
msgstr ""
#: templatetags/admin_tree.py:148
msgid "Return to ordered tree"
msgstr ""

View File

@@ -0,0 +1,24 @@
msgid ""
msgstr ""
"Project-Id-Version: Django-treebeard\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2011-07-18 14:12+0200\n"
"PO-Revision-Date: 2011-07-18 14:12+0200\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: static/treebeard/treebeard-admin.js:157
msgid "Abort"
msgstr ""
#: static/treebeard/treebeard-admin.js:172
msgid "As Sibling"
msgstr ""
#: static/treebeard/treebeard-admin.js:190
msgid "As child"
msgstr ""

View File

@@ -0,0 +1,86 @@
# treebeard translation in french.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the treebeard package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-15 20:56+0000\n"
"PO-Revision-Date: 2018-06-15 23:09+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 1.5.7\n"
#: treebeard/admin.py:106
#, python-format
msgid "Exception raised while moving node: %s"
msgstr "Une expetion est survenue pendant le placement de l'élément: %s"
#: treebeard/admin.py:110
#, python-format
msgid "Moved node \"%(node)s\" as child of \"%(other)s\""
msgstr "Élément \"%(node)s\" déplacé en temps qu'enfant de \"%(other)s\""
#: treebeard/admin.py:112
#, python-format
msgid "Moved node \"%(node)s\" as sibling of \"%(other)s\""
msgstr "Élément \"%(node)s\" déplacé au même niveau que \"%(other)s\""
#: treebeard/al_tree.py:373 treebeard/mp_tree.py:474 treebeard/ns_tree.py:363
msgid "Can't move node to a descendant."
msgstr "L'élément ne peux être déplacé vers un de ces décendant."
#: treebeard/forms.py:46
msgid "Child of"
msgstr "Enfant de"
#: treebeard/forms.py:47
msgid "Sibling of"
msgstr "Au même niveau que"
#: treebeard/forms.py:51
msgid "First child of"
msgstr "Premier enfant de"
#: treebeard/forms.py:52
msgid "Before"
msgstr "Avant"
#: treebeard/forms.py:53
msgid "After"
msgstr "Après"
#: treebeard/forms.py:56
msgid "Position"
msgstr "Position"
#: treebeard/forms.py:60
msgid "Relative to"
msgstr "Relative à"
#: treebeard/forms.py:189
msgid "-- root --"
msgstr "-- racine --"
#: treebeard/mp_tree.py:382
msgid ""
"The new node is too deep in the tree, try increasing the path.max_length "
"property and UPDATE your database"
msgstr ""
"L'élément est trop profond dans l'arbre, essayez d'augmenter la propriété path.max_length "
"et de mêtre à jour vore base de donnée"
#: treebeard/mp_tree.py:1114
#, python-format
msgid "Path Overflow from: '%s'"
msgstr "Chemin trop long de: '%s'"
#: treebeard/templatetags/admin_tree.py:249
msgid "Return to ordered tree"
msgstr "Retour à l'arbre trié"

View File

@@ -0,0 +1,30 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-15 20:56+0000\n"
"PO-Revision-Date: 2018-06-15 23:10+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 1.5.7\n"
#: treebeard/static/treebeard/treebeard-admin.js:158
msgid "Abort"
msgstr "Interrompre"
#: treebeard/static/treebeard/treebeard-admin.js:180
msgid "As Sibling"
msgstr "Au même niveau"
#: treebeard/static/treebeard/treebeard-admin.js:198
msgid "As child"
msgstr "En tant qu'enfant"

View File

@@ -0,0 +1,86 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-15 20:56+0000\n"
"PO-Revision-Date: 2018-06-15 23:09+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 1.5.7\n"
#: treebeard/admin.py:106
#, python-format
msgid "Exception raised while moving node: %s"
msgstr "Hiba lépett fel a csomópont mozgatása közben: %s"
#: treebeard/admin.py:110
#, python-format
msgid "Moved node \"%(node)s\" as child of \"%(other)s\""
msgstr "A(z) \"%(node)s\" csomópont a(z) \"%(other)s\" csomópont alá került."
#: treebeard/admin.py:112
#, python-format
msgid "Moved node \"%(node)s\" as sibling of \"%(other)s\""
msgstr "A(z) \"%(node)s\" csomópont a(z) \"%(other)s\" testvére lett."
#: treebeard/al_tree.py:373 treebeard/mp_tree.py:474 treebeard/ns_tree.py:363
msgid "Can't move node to a descendant."
msgstr "Nem lehet egy csomópontot egy leszármazottja alá mozgatni."
#: treebeard/forms.py:46
msgid "Child of"
msgstr "Gyermeke ennek"
#: treebeard/forms.py:47
msgid "Sibling of"
msgstr "Testvére ennek"
#: treebeard/forms.py:51
msgid "First child of"
msgstr "Első gyermeke ennek"
#: treebeard/forms.py:52
msgid "Before"
msgstr "Előtte"
#: treebeard/forms.py:53
msgid "After"
msgstr "Utána"
#: treebeard/forms.py:56
msgid "Position"
msgstr "Pozíció"
#: treebeard/forms.py:60
msgid "Relative to"
msgstr "Relatív ehhez"
#: treebeard/forms.py:189
msgid "-- root --"
msgstr "-- Gyökérkategória --"
#: treebeard/mp_tree.py:382
msgid ""
"The new node is too deep in the tree, try increasing the path.max_length "
"property and UPDATE your database"
msgstr ""
"Az új csomópont túl mélyen van a fában, próbáljuk meg megnövelni a "
"path.max_length értékét és frissítsük az adatbázist."
#: treebeard/mp_tree.py:1114
#, python-format
msgid "Path Overflow from: '%s'"
msgstr "Útvonal túlfolyás innen: '%s'"
#: treebeard/templatetags/admin_tree.py:249
msgid "Return to ordered tree"
msgstr "Vissza a rendezett fához"

View File

@@ -0,0 +1,30 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-15 20:56+0000\n"
"PO-Revision-Date: 2018-06-15 23:10+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 1.5.7\n"
#: treebeard/static/treebeard/treebeard-admin.js:158
msgid "Abort"
msgstr "Megszakít"
#: treebeard/static/treebeard/treebeard-admin.js:180
msgid "As Sibling"
msgstr "Testvérként"
#: treebeard/static/treebeard/treebeard-admin.js:198
msgid "As child"
msgstr "Gyermekként"

View File

@@ -0,0 +1,80 @@
msgid ""
msgstr ""
"Project-Id-Version: Django-treebeard\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2011-07-18 17:36+0200\n"
"PO-Revision-Date: 2011-07-18 14:11+0200\n"
"Last-Translator: Jaap Roes <jaap@eight.nl>\n"
"Language-Team: Dutch\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: admin.py:113
#, python-format
msgid "Moved node \"%(node)s\" as child of \"%(other)s\""
msgstr "\"%(node)s\" is nu onderdeel van \"%(other)s\""
#: admin.py:119
#, python-format
msgid "Moved node \"%(node)s\" as sibling of \"%(other)s\""
msgstr "\"%(node)s\" staat nu voor \"%(other)s\""
#: admin.py:129
#, python-format
msgid "Exception raised while moving node: %s"
msgstr "Fatale fout tijdens het verplaatsen: %s"
#: al_tree.py:319 mp_tree.py:641 ns_tree.py:308
msgid "Can't move node to a descendant."
msgstr "Kan node niet naar eigen subnode verplaatsen"
#: forms.py:17
msgid "Child of"
msgstr "Onderdeel"
#: forms.py:18
msgid "Sibling of"
msgstr "Naast"
#: forms.py:22
msgid "First child of"
msgstr "1e onderdeel"
#: forms.py:23
msgid "Before"
msgstr "Voor"
#: forms.py:24
msgid "After"
msgstr "Na"
#: forms.py:27
msgid "Position"
msgstr "Positie"
#: forms.py:31
msgid "Relative to"
msgstr "Ten opzichte van"
#: forms.py:81
msgid "-- root --"
msgstr "-- hoofdniveau --"
#: mp_tree.py:521
msgid ""
"The new node is too deep in the tree, try increasing the path.max_length "
"property and UPDATE your database"
msgstr ""
"De nieuwe node bevindt zich te diep in de boom. Verhoog de path.max_lenght "
"waarde en UPDATE de database."
#: mp_tree.py:702
#, python-format
msgid "Path Overflow from: '%s'"
msgstr "Path overflow van: '%s'"
#: templatetags/admin_tree.py:148
msgid "Return to ordered tree"
msgstr "Als gesorteerde boom"

View File

@@ -0,0 +1,24 @@
msgid ""
msgstr ""
"Project-Id-Version: Django-treebeard\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2011-07-18 14:12+0200\n"
"PO-Revision-Date: 2011-07-18 14:12+0200\n"
"Last-Translator: Jaap Roes <jaap@eight.nl>\n"
"Language-Team: Dutch\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: static/treebeard/treebeard-admin.js:157
msgid "Abort"
msgstr "Annuleren"
#: static/treebeard/treebeard-admin.js:172
msgid "As Sibling"
msgstr "Als naastliggend onderdeel"
#: static/treebeard/treebeard-admin.js:190
msgid "As child"
msgstr "Als subonderdeel"

View File

@@ -0,0 +1,44 @@
msgid ""
msgstr ""
"Project-Id-Version: Django-treebeard\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2010-05-03 23:53-0500\n"
"PO-Revision-Date: 2010-05-03 23:40-0500\n"
"Last-Translator: Bartosz Turkot <bartosz.turkot@blueservices.pl>\n"
"Language-Team: Polish\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: forms.py:16
msgid "Child of"
msgstr "Dziecko kategorii"
#: forms.py:17
msgid "Sibling of"
msgstr "Sąsiad kategorii"
#: forms.py:21
msgid "First child of"
msgstr "Pierwsze dziecko kategorii"
#: forms.py:22
msgid "Before"
msgstr "Przed"
#: forms.py:23
msgid "After"
msgstr "Za"
#: forms.py:26
msgid "Position"
msgstr "Pozycja"
#: forms.py:30
msgid "Relative to"
msgstr "Względem"
#: forms.py:80
msgid "-- root --"
msgstr "-- kategoria główna --"

View File

@@ -0,0 +1,79 @@
msgid ""
msgstr ""
"Project-Id-Version: Django-treebeard\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2011-07-18 17:36+0200\n"
"PO-Revision-Date: 2009-04-10 18:37+0400\n"
"Last-Translator: chembervint <chembervint@gmail.com>\n"
"Language-Team: Russian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%"
"10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
#: admin.py:113
#, python-format
msgid "Moved node \"%(node)s\" as child of \"%(other)s\""
msgstr ""
#: admin.py:119
#, python-format
msgid "Moved node \"%(node)s\" as sibling of \"%(other)s\""
msgstr ""
#: admin.py:129
#, python-format
msgid "Exception raised while moving node: %s"
msgstr ""
#: al_tree.py:319 mp_tree.py:641 ns_tree.py:308
msgid "Can't move node to a descendant."
msgstr ""
#: forms.py:17
msgid "Child of"
msgstr "Вложенный"
#: forms.py:18
msgid "Sibling of"
msgstr "Соседний к"
#: forms.py:22
msgid "First child of"
msgstr "Первый вложенный"
#: forms.py:23
msgid "Before"
msgstr "До"
#: forms.py:24
msgid "After"
msgstr "После"
#: forms.py:27
msgid "Position"
msgstr "Позиция"
#: forms.py:31
msgid "Relative to"
msgstr "Относительно"
#: forms.py:81
msgid "-- root --"
msgstr "-- корень --"
#: mp_tree.py:521
msgid ""
"The new node is too deep in the tree, try increasing the path.max_length "
"property and UPDATE your database"
msgstr ""
#: mp_tree.py:702
#, python-format
msgid "Path Overflow from: '%s'"
msgstr ""
#: templatetags/admin_tree.py:148
msgid "Return to ordered tree"
msgstr ""

View File

@@ -0,0 +1,25 @@
msgid ""
msgstr ""
"Project-Id-Version: Django-treebeard\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2011-07-18 14:12+0200\n"
"PO-Revision-Date: 2011-07-18 14:12+0200\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%"
"10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
#: static/treebeard/treebeard-admin.js:157
msgid "Abort"
msgstr ""
#: static/treebeard/treebeard-admin.js:172
msgid "As Sibling"
msgstr ""
#: static/treebeard/treebeard-admin.js:190
msgid "As child"
msgstr ""

View File

@@ -0,0 +1,657 @@
"""Models and base API"""
import operator
from functools import reduce
from django.db.models import Q
from django.db import models, router, connections
from treebeard.exceptions import InvalidPosition, MissingNodeOrderBy
class Node(models.Model):
"""Node class"""
_db_connection = None
@classmethod
def add_root(cls, **kwargs): # pragma: no cover
"""
Adds a root node to the tree. The new root node will be the new
rightmost root node. If you want to insert a root node at a specific
position, use :meth:`add_sibling` in an already existing root node
instead.
:param `**kwargs`: object creation data that will be passed to the
inherited Node model
:param instance: Instead of passing object creation data, you can
pass an already-constructed (but not yet saved) model instance to
be inserted into the tree.
:returns: the created node object. It will be save()d by this method.
:raise NodeAlreadySaved: when the passed ``instance`` already exists
in the database
"""
raise NotImplementedError
@classmethod
def get_foreign_keys(cls):
"""Get foreign keys and models they refer to, so we can pre-process
the data for load_bulk
"""
foreign_keys = {}
for field in cls._meta.fields:
if (
field.get_internal_type() == 'ForeignKey' and
field.name != 'parent'
):
foreign_keys[field.name] = field.remote_field.model
return foreign_keys
@classmethod
def _process_foreign_keys(cls, foreign_keys, node_data):
"""For each foreign key try to load the actual object so load_bulk
doesn't fail trying to load an int where django expects a
model instance
"""
for key in foreign_keys.keys():
if key in node_data:
node_data[key] = foreign_keys[key].objects.get(
pk=node_data[key])
@classmethod
def load_bulk(cls, bulk_data, parent=None, keep_ids=False):
"""
Loads a list/dictionary structure to the tree.
:param bulk_data:
The data that will be loaded, the structure is a list of
dictionaries with 2 keys:
- ``data``: will store arguments that will be passed for object
creation, and
- ``children``: a list of dictionaries, each one has it's own
``data`` and ``children`` keys (a recursive structure)
:param parent:
The node that will receive the structure as children, if not
specified the first level of the structure will be loaded as root
nodes
:param keep_ids:
If enabled, loads the nodes with the same primary keys that are
given in the structure. Will error if there are nodes without
primary key info or if the primary keys are already used.
:returns: A list of the added node ids.
"""
# tree, iterative preorder
added = []
# stack of nodes to analyze
stack = [(parent, node) for node in bulk_data[::-1]]
foreign_keys = cls.get_foreign_keys()
pk_field = cls._meta.pk.attname
while stack:
parent, node_struct = stack.pop()
# shallow copy of the data structure so it doesn't persist...
node_data = node_struct['data'].copy()
cls._process_foreign_keys(foreign_keys, node_data)
if keep_ids:
node_data[pk_field] = node_struct[pk_field]
if parent:
node_obj = parent.add_child(**node_data)
else:
node_obj = cls.add_root(**node_data)
added.append(node_obj.pk)
if 'children' in node_struct:
# extending the stack with the current node as the parent of
# the new nodes
stack.extend([
(node_obj, node)
for node in node_struct['children'][::-1]
])
return added
@classmethod
def dump_bulk(cls, parent=None, keep_ids=True): # pragma: no cover
"""
Dumps a tree branch to a python data structure.
:param parent:
The node whose descendants will be dumped. The node itself will be
included in the dump. If not given, the entire tree will be dumped.
:param keep_ids:
Stores the pk value (primary key) of every node. Enabled by
default.
:returns: A python data structure, described with detail in
:meth:`load_bulk`
"""
raise NotImplementedError
@classmethod
def get_root_nodes(cls): # pragma: no cover
""":returns: A queryset containing the root nodes in the tree."""
raise NotImplementedError
@classmethod
def get_first_root_node(cls):
"""
:returns:
The first root node in the tree or ``None`` if it is empty.
"""
try:
return cls.get_root_nodes()[0]
except IndexError:
return None
@classmethod
def get_last_root_node(cls):
"""
:returns:
The last root node in the tree or ``None`` if it is empty.
"""
try:
return cls.get_root_nodes().reverse()[0]
except IndexError:
return None
@classmethod
def find_problems(cls): # pragma: no cover
"""Checks for problems in the tree structure."""
raise NotImplementedError
@classmethod
def fix_tree(cls): # pragma: no cover
"""
Solves problems that can appear when transactions are not used and
a piece of code breaks, leaving the tree in an inconsistent state.
"""
raise NotImplementedError
@classmethod
def get_tree(cls, parent=None):
"""
:returns:
A list of nodes ordered as DFS, including the parent. If
no parent is given, the entire tree is returned.
"""
raise NotImplementedError
@classmethod
def get_descendants_group_count(cls, parent=None):
"""
Helper for a very common case: get a group of siblings and the number
of *descendants* (not only children) in every sibling.
:param parent:
The parent of the siblings to return. If no parent is given, the
root nodes will be returned.
:returns:
A `list` (**NOT** a Queryset) of node objects with an extra
attribute: `descendants_count`.
"""
if parent is None:
qset = cls.get_root_nodes()
else:
qset = parent.get_children()
nodes = list(qset)
for node in nodes:
node.descendants_count = node.get_descendant_count()
return nodes
def get_depth(self): # pragma: no cover
""":returns: the depth (level) of the node"""
raise NotImplementedError
def get_siblings(self): # pragma: no cover
"""
:returns:
A queryset of all the node's siblings, including the node
itself.
"""
raise NotImplementedError
def get_children(self): # pragma: no cover
""":returns: A queryset of all the node's children"""
raise NotImplementedError
def get_children_count(self):
""":returns: The number of the node's children"""
return self.get_children().count()
def get_descendants(self):
"""
:returns:
A queryset of all the node's descendants, doesn't
include the node itself (some subclasses may return a list).
"""
raise NotImplementedError
def get_descendant_count(self):
""":returns: the number of descendants of a node."""
return self.get_descendants().count()
def get_first_child(self):
"""
:returns:
The leftmost node's child, or None if it has no children.
"""
try:
return self.get_children()[0]
except IndexError:
return None
def get_last_child(self):
"""
:returns:
The rightmost node's child, or None if it has no children.
"""
try:
return self.get_children().reverse()[0]
except IndexError:
return None
def get_first_sibling(self):
"""
:returns:
The leftmost node's sibling, can return the node itself if
it was the leftmost sibling.
"""
return self.get_siblings()[0]
def get_last_sibling(self):
"""
:returns:
The rightmost node's sibling, can return the node itself if
it was the rightmost sibling.
"""
return self.get_siblings().reverse()[0]
def get_prev_sibling(self):
"""
:returns:
The previous node's sibling, or None if it was the leftmost
sibling.
"""
siblings = self.get_siblings()
ids = [obj.pk for obj in siblings]
if self.pk in ids:
idx = ids.index(self.pk)
if idx > 0:
return siblings[idx - 1]
def get_next_sibling(self):
"""
:returns:
The next node's sibling, or None if it was the rightmost
sibling.
"""
siblings = self.get_siblings()
ids = [obj.pk for obj in siblings]
if self.pk in ids:
idx = ids.index(self.pk)
if idx < len(siblings) - 1:
return siblings[idx + 1]
def is_sibling_of(self, node):
"""
:returns: ``True`` if the node is a sibling of another node given as an
argument, else, returns ``False``
:param node:
The node that will be checked as a sibling
"""
return self.get_siblings().filter(pk=node.pk).exists()
def is_child_of(self, node):
"""
:returns: ``True`` if the node is a child of another node given as an
argument, else, returns ``False``
:param node:
The node that will be checked as a parent
"""
return node.get_children().filter(pk=self.pk).exists()
def is_descendant_of(self, node): # pragma: no cover
"""
:returns: ``True`` if the node is a descendant of another node given
as an argument, else, returns ``False``
:param node:
The node that will be checked as an ancestor
"""
raise NotImplementedError
def add_child(self, **kwargs): # pragma: no cover
"""
Adds a child to the node. The new node will be the new rightmost
child. If you want to insert a node at a specific position,
use the :meth:`add_sibling` method of an already existing
child node instead.
:param `**kwargs`:
Object creation data that will be passed to the inherited Node
model
:param instance: Instead of passing object creation data, you can
pass an already-constructed (but not yet saved) model instance to
be inserted into the tree.
:returns: The created node object. It will be save()d by this method.
:raise NodeAlreadySaved: when the passed ``instance`` already exists
in the database
"""
raise NotImplementedError
def add_sibling(self, pos=None, **kwargs): # pragma: no cover
"""
Adds a new node as a sibling to the current node object.
:param pos:
The position, relative to the current node object, where the
new node will be inserted, can be one of:
- ``first-sibling``: the new node will be the new leftmost sibling
- ``left``: the new node will take the node's place, which will be
moved to the right 1 position
- ``right``: the new node will be inserted at the right of the node
- ``last-sibling``: the new node will be the new rightmost sibling
- ``sorted-sibling``: the new node will be at the right position
according to the value of node_order_by
:param `**kwargs`:
Object creation data that will be passed to the inherited
Node model
:param instance: Instead of passing object creation data, you can
pass an already-constructed (but not yet saved) model instance to
be inserted into the tree.
:returns:
The created node object. It will be saved by this method.
:raise InvalidPosition: when passing an invalid ``pos`` parm
:raise InvalidPosition: when :attr:`node_order_by` is enabled and the
``pos`` parm wasn't ``sorted-sibling``
:raise MissingNodeOrderBy: when passing ``sorted-sibling`` as ``pos``
and the :attr:`node_order_by` attribute is missing
:raise NodeAlreadySaved: when the passed ``instance`` already exists
in the database
"""
raise NotImplementedError
def get_root(self): # pragma: no cover
""":returns: the root node for the current node object."""
raise NotImplementedError
def is_root(self):
""":returns: True if the node is a root node (else, returns False)"""
return self.get_root().pk == self.pk
def is_leaf(self):
""":returns: True if the node is a leaf node (else, returns False)"""
return not self.get_children().exists()
def get_ancestors(self): # pragma: no cover
"""
:returns:
A queryset containing the current node object's ancestors,
starting by the root node and descending to the parent.
(some subclasses may return a list)
"""
raise NotImplementedError
def get_parent(self, update=False): # pragma: no cover
"""
:returns: the parent node of the current node object.
Caches the result in the object itself to help in loops.
:param update: Updates the cached value.
"""
raise NotImplementedError
def move(self, target, pos=None): # pragma: no cover
"""
Moves the current node and all it's descendants to a new position
relative to another node.
:param target:
The node that will be used as a relative child/sibling when moving
:param pos:
The position, relative to the target node, where the
current node object will be moved to, can be one of:
- ``first-child``: the node will be the new leftmost child of the
``target`` node
- ``last-child``: the node will be the new rightmost child of the
``target`` node
- ``sorted-child``: the new node will be moved as a child of the
``target`` node according to the value of :attr:`node_order_by`
- ``first-sibling``: the node will be the new leftmost sibling of
the ``target`` node
- ``left``: the node will take the ``target`` node's place, which
will be moved to the right 1 position
- ``right``: the node will be moved to the right of the ``target``
node
- ``last-sibling``: the node will be the new rightmost sibling of
the ``target`` node
- ``sorted-sibling``: the new node will be moved as a sibling of
the ``target`` node according to the value of
:attr:`node_order_by`
.. note::
If no ``pos`` is given the library will use ``last-sibling``,
or ``sorted-sibling`` if :attr:`node_order_by` is enabled.
:returns: None
:raise InvalidPosition: when passing an invalid ``pos`` parm
:raise InvalidPosition: when :attr:`node_order_by` is enabled and the
``pos`` parm wasn't ``sorted-sibling`` or ``sorted-child``
:raise InvalidMoveToDescendant: when trying to move a node to one of
it's own descendants
:raise PathOverflow: when the library can't make room for the
node's new position
:raise MissingNodeOrderBy: when passing ``sorted-sibling`` or
``sorted-child`` as ``pos`` and the :attr:`node_order_by`
attribute is missing
"""
raise NotImplementedError
def delete(self, *args, **kwargs):
"""Removes a node and all it's descendants."""
return self.__class__.objects.filter(pk=self.pk).delete(*args, **kwargs)
delete.alters_data = True
delete.queryset_only = True
def _prepare_pos_var(self, pos, method_name, valid_pos, valid_sorted_pos):
if pos is None:
if self.node_order_by:
pos = 'sorted-sibling'
else:
pos = 'last-sibling'
if pos not in valid_pos:
raise InvalidPosition('Invalid relative position: %s' % (pos, ))
if self.node_order_by and pos not in valid_sorted_pos:
raise InvalidPosition(
'Must use %s in %s when node_order_by is enabled' % (
' or '.join(valid_sorted_pos), method_name))
if pos in valid_sorted_pos and not self.node_order_by:
raise MissingNodeOrderBy('Missing node_order_by attribute.')
return pos
_valid_pos_for_add_sibling = ('first-sibling', 'left', 'right',
'last-sibling', 'sorted-sibling')
_valid_pos_for_sorted_add_sibling = ('sorted-sibling',)
def _prepare_pos_var_for_add_sibling(self, pos):
return self._prepare_pos_var(
pos,
'add_sibling',
self._valid_pos_for_add_sibling,
self._valid_pos_for_sorted_add_sibling)
_valid_pos_for_move = _valid_pos_for_add_sibling + (
'first-child', 'last-child', 'sorted-child')
_valid_pos_for_sorted_move = _valid_pos_for_sorted_add_sibling + (
'sorted-child',)
def _prepare_pos_var_for_move(self, pos):
return self._prepare_pos_var(
pos,
'move',
self._valid_pos_for_move,
self._valid_pos_for_sorted_move)
def get_sorted_pos_queryset(self, siblings, newobj):
"""
:returns:
A queryset of the nodes that must be moved to the right.
Called only for Node models with :attr:`node_order_by`
This function is based on _insertion_target_filters from django-mptt
(BSD licensed) by Jonathan Buchanan:
https://github.com/django-mptt/django-mptt/blob/0.3.0/mptt/signals.py
"""
fields, filters = [], []
for field in self.node_order_by:
value = getattr(newobj, field)
filters.append(
Q(
*[Q(**{f: v}) for f, v in fields] +
[Q(**{'%s__gt' % field: value})]
)
)
fields.append((field, value))
return siblings.filter(reduce(operator.or_, filters))
@classmethod
def get_annotated_list_qs(cls, qs):
"""
Gets an annotated list from a queryset.
"""
result, info = [], {}
start_depth, prev_depth = (None, None)
for node in qs:
depth = node.get_depth()
if start_depth is None:
start_depth = depth
open = (depth and (prev_depth is None or depth > prev_depth))
if prev_depth is not None and depth < prev_depth:
info['close'] = list(range(0, prev_depth - depth))
info = {'open': open, 'close': [], 'level': depth - start_depth}
result.append((node, info,))
prev_depth = depth
if start_depth and start_depth > 0:
info['close'] = list(range(0, prev_depth - start_depth + 1))
return result
@classmethod
def get_annotated_list(cls, parent=None, max_depth=None):
"""
Gets an annotated list from a tree branch.
:param parent:
The node whose descendants will be annotated. The node itself
will be included in the list. If not given, the entire tree
will be annotated.
:param max_depth:
Optionally limit to specified depth
"""
result, info = [], {}
start_depth, prev_depth = (None, None)
qs = cls.get_tree(parent)
if max_depth:
qs = qs.filter(depth__lte=max_depth)
return cls.get_annotated_list_qs(qs)
@classmethod
def _get_serializable_model(cls):
"""
Returns a model with a valid _meta.local_fields (serializable).
Basically, this means the original model, not a proxied model.
(this is a workaround for a bug in django)
"""
current_class = cls
while current_class._meta.proxy:
current_class = current_class._meta.proxy_for_model
return current_class
@classmethod
def _get_database_connection(cls, action):
return {
'read': connections[router.db_for_read(cls)],
'write': connections[router.db_for_write(cls)]
}[action]
@classmethod
def get_database_vendor(cls, action):
"""
returns the supported database vendor used by a treebeard model when
performing read (select) or write (update, insert, delete) operations.
:param action:
`read` or `write`
:returns: postgresql, mysql or sqlite
"""
return cls._get_database_connection(action).vendor
@classmethod
def _get_database_cursor(cls, action):
return cls._get_database_connection(action).cursor()
class Meta:
"""Abstract model."""
abstract = True

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,692 @@
"""Nested Sets"""
import operator
from functools import reduce
from django.core import serializers
from django.db import connection, models
from django.db.models import Q
from django.utils.translation import gettext_noop as _
from treebeard.exceptions import InvalidMoveToDescendant, NodeAlreadySaved
from treebeard.models import Node
def get_result_class(cls):
"""
For the given model class, determine what class we should use for the
nodes returned by its tree methods (such as get_children).
Usually this will be trivially the same as the initial model class,
but there are special cases when model inheritance is in use:
* If the model extends another via multi-table inheritance, we need to
use whichever ancestor originally implemented the tree behaviour (i.e.
the one which defines the 'lft'/'rgt' fields). We can't use the
subclass, because it's not guaranteed that the other nodes reachable
from the current one will be instances of the same subclass.
* If the model is a proxy model, the returned nodes should also use
the proxy class.
"""
base_class = cls._meta.get_field('lft').model
if cls._meta.proxy_for_model == base_class:
return cls
else:
return base_class
def merge_deleted_counters(c1, c2):
"""
Merge return values from Django's Queryset.delete() method.
"""
object_counts = {
key: c1[1].get(key, 0) + c2[1].get(key, 0)
for key in set(c1[1]) | set(c2[1])
}
return (c1[0] + c2[0], object_counts)
class NS_NodeQuerySet(models.query.QuerySet):
"""
Custom queryset for the tree node manager.
Needed only for the customized delete method.
"""
def delete(self, *args, removed_ranges=None, deleted_counter=None, **kwargs):
"""
Custom delete method, will remove all descendant nodes to ensure a
consistent tree (no orphans)
:returns: tuple of the number of objects deleted and a dictionary
with the number of deletions per object type
"""
model = get_result_class(self.model)
if deleted_counter is None:
deleted_counter = (0, {})
if removed_ranges is not None:
# we already know the children, let's call the default django
# delete method and let it handle the removal of the user's
# foreign keys...
result = super().delete(*args, **kwargs)
deleted_counter = merge_deleted_counters(deleted_counter, result)
cursor = model._get_database_cursor('write')
# Now closing the gap (Celko's trees book, page 62)
# We do this for every gap that was left in the tree when the nodes
# were removed. If many nodes were removed, we're going to update
# the same nodes over and over again. This would be probably
# cheaper precalculating the gapsize per intervals, or just do a
# complete reordering of the tree (uses COUNT)...
for tree_id, drop_lft, drop_rgt in sorted(removed_ranges,
reverse=True):
sql, params = model._get_close_gap_sql(drop_lft, drop_rgt,
tree_id)
cursor.execute(sql, params)
else:
# we'll have to manually run through all the nodes that are going
# to be deleted and remove nodes from the list if an ancestor is
# already getting removed, since that would be redundant
removed = {}
for node in self.order_by('tree_id', 'lft'):
found = False
for rid, rnode in removed.items():
if node.is_descendant_of(rnode):
found = True
break
if not found:
removed[node.pk] = node
# ok, got the minimal list of nodes to remove...
# we must also remove their descendants
toremove = []
ranges = []
for id, node in removed.items():
toremove.append(Q(lft__range=(node.lft, node.rgt)) &
Q(tree_id=node.tree_id))
ranges.append((node.tree_id, node.lft, node.rgt))
if toremove:
deleted_counter = model.objects.filter(
reduce(operator.or_,
toremove)
).delete(removed_ranges=ranges, deleted_counter=deleted_counter)
return deleted_counter
delete.alters_data = True
delete.queryset_only = True
class NS_NodeManager(models.Manager):
"""Custom manager for nodes in a Nested Sets tree."""
def get_queryset(self):
"""Sets the custom queryset as the default."""
return NS_NodeQuerySet(self.model).order_by('tree_id', 'lft')
class NS_Node(Node):
"""Abstract model to create your own Nested Sets Trees."""
node_order_by = []
lft = models.PositiveIntegerField(db_index=True)
rgt = models.PositiveIntegerField(db_index=True)
tree_id = models.PositiveIntegerField(db_index=True)
depth = models.PositiveIntegerField(db_index=True)
objects = NS_NodeManager()
@classmethod
def add_root(cls, **kwargs):
"""Adds a root node to the tree."""
# do we have a root node already?
last_root = cls.get_last_root_node()
if last_root and last_root.node_order_by:
# there are root nodes and node_order_by has been set
# delegate sorted insertion to add_sibling
return last_root.add_sibling('sorted-sibling', **kwargs)
if last_root:
# adding the new root node as the last one
newtree_id = last_root.tree_id + 1
else:
# adding the first root node
newtree_id = 1
if len(kwargs) == 1 and 'instance' in kwargs:
# adding the passed (unsaved) instance to the tree
newobj = kwargs['instance']
if not newobj._state.adding:
raise NodeAlreadySaved("Attempted to add a tree node that is "\
"already in the database")
else:
# creating the new object
newobj = get_result_class(cls)(**kwargs)
newobj.depth = 1
newobj.tree_id = newtree_id
newobj.lft = 1
newobj.rgt = 2
# saving the instance before returning it
newobj.save()
return newobj
@classmethod
def _move_right(cls, tree_id, rgt, lftmove=False, incdec=2):
if lftmove:
lftop = '>='
else:
lftop = '>'
sql = 'UPDATE %(table)s '\
' SET lft = CASE WHEN lft %(lftop)s %(parent_rgt)d '\
' THEN lft %(incdec)+d '\
' ELSE lft END, '\
' rgt = CASE WHEN rgt >= %(parent_rgt)d '\
' THEN rgt %(incdec)+d '\
' ELSE rgt END '\
' WHERE rgt >= %(parent_rgt)d AND '\
' tree_id = %(tree_id)s' % {
'table': connection.ops.quote_name(
get_result_class(cls)._meta.db_table),
'parent_rgt': rgt,
'tree_id': tree_id,
'lftop': lftop,
'incdec': incdec}
return sql, []
@classmethod
def _move_tree_right(cls, tree_id):
sql = 'UPDATE %(table)s '\
' SET tree_id = tree_id+1 '\
' WHERE tree_id >= %(tree_id)d' % {
'table': connection.ops.quote_name(
get_result_class(cls)._meta.db_table),
'tree_id': tree_id}
return sql, []
def add_child(self, **kwargs):
"""Adds a child to the node."""
if not self.is_leaf():
# there are child nodes, delegate insertion to add_sibling
if self.node_order_by:
pos = 'sorted-sibling'
else:
pos = 'last-sibling'
last_child = self.get_last_child()
last_child._cached_parent_obj = self
return last_child.add_sibling(pos, **kwargs)
# we're adding the first child of this node
sql, params = self.__class__._move_right(self.tree_id,
self.rgt, False, 2)
if len(kwargs) == 1 and 'instance' in kwargs:
# adding the passed (unsaved) instance to the tree
newobj = kwargs['instance']
if not newobj._state.adding:
raise NodeAlreadySaved("Attempted to add a tree node that is "\
"already in the database")
else:
# creating a new object
newobj = get_result_class(self.__class__)(**kwargs)
newobj.tree_id = self.tree_id
newobj.depth = self.depth + 1
newobj.lft = self.lft + 1
newobj.rgt = self.lft + 2
# this is just to update the cache
self.rgt += 2
newobj._cached_parent_obj = self
cursor = self._get_database_cursor('write')
cursor.execute(sql, params)
# saving the instance before returning it
newobj.save()
return newobj
def add_sibling(self, pos=None, **kwargs):
"""Adds a new node as a sibling to the current node object."""
pos = self._prepare_pos_var_for_add_sibling(pos)
if len(kwargs) == 1 and 'instance' in kwargs:
# adding the passed (unsaved) instance to the tree
newobj = kwargs['instance']
if not newobj._state.adding:
raise NodeAlreadySaved("Attempted to add a tree node that is "\
"already in the database")
else:
# creating a new object
newobj = get_result_class(self.__class__)(**kwargs)
newobj.depth = self.depth
sql = None
target = self
if target.is_root():
newobj.lft = 1
newobj.rgt = 2
if pos == 'sorted-sibling':
siblings = list(target.get_sorted_pos_queryset(
target.get_siblings(), newobj))
if siblings:
pos = 'left'
target = siblings[0]
else:
pos = 'last-sibling'
last_root = target.__class__.get_last_root_node()
if (
(pos == 'last-sibling') or
(pos == 'right' and target == last_root)
):
newobj.tree_id = last_root.tree_id + 1
else:
newpos = {'first-sibling': 1,
'left': target.tree_id,
'right': target.tree_id + 1}[pos]
sql, params = target.__class__._move_tree_right(newpos)
newobj.tree_id = newpos
else:
newobj.tree_id = target.tree_id
if pos == 'sorted-sibling':
siblings = list(target.get_sorted_pos_queryset(
target.get_siblings(), newobj))
if siblings:
pos = 'left'
target = siblings[0]
else:
pos = 'last-sibling'
if pos in ('left', 'right', 'first-sibling'):
siblings = list(target.get_siblings())
if pos == 'right':
if target == siblings[-1]:
pos = 'last-sibling'
else:
pos = 'left'
found = False
for node in siblings:
if found:
target = node
break
elif node == target:
found = True
if pos == 'left':
if target == siblings[0]:
pos = 'first-sibling'
if pos == 'first-sibling':
target = siblings[0]
move_right = self.__class__._move_right
if pos == 'last-sibling':
newpos = target.get_parent().rgt
sql, params = move_right(target.tree_id, newpos, False, 2)
elif pos == 'first-sibling':
newpos = target.lft
sql, params = move_right(target.tree_id, newpos - 1, False, 2)
elif pos == 'left':
newpos = target.lft
sql, params = move_right(target.tree_id, newpos, True, 2)
newobj.lft = newpos
newobj.rgt = newpos + 1
# saving the instance before returning it
if sql:
cursor = self._get_database_cursor('write')
cursor.execute(sql, params)
newobj.save()
return newobj
def move(self, target, pos=None):
"""
Moves the current node and all it's descendants to a new position
relative to another node.
"""
pos = self._prepare_pos_var_for_move(pos)
cls = get_result_class(self.__class__)
parent = None
if pos in ('first-child', 'last-child', 'sorted-child'):
# moving to a child
if target.is_leaf():
parent = target
pos = 'last-child'
else:
target = target.get_last_child()
pos = {'first-child': 'first-sibling',
'last-child': 'last-sibling',
'sorted-child': 'sorted-sibling'}[pos]
if target.is_descendant_of(self):
raise InvalidMoveToDescendant(
_("Can't move node to a descendant."))
if self == target and (
(pos == 'left') or
(pos in ('right', 'last-sibling') and
target == target.get_last_sibling()) or
(pos == 'first-sibling' and
target == target.get_first_sibling())):
# special cases, not actually moving the node so no need to UPDATE
return
if pos == 'sorted-sibling':
siblings = list(target.get_sorted_pos_queryset(
target.get_siblings(), self))
if siblings:
pos = 'left'
target = siblings[0]
else:
pos = 'last-sibling'
if pos in ('left', 'right', 'first-sibling'):
siblings = list(target.get_siblings())
if pos == 'right':
if target == siblings[-1]:
pos = 'last-sibling'
else:
pos = 'left'
found = False
for node in siblings:
if found:
target = node
break
elif node == target:
found = True
if pos == 'left':
if target == siblings[0]:
pos = 'first-sibling'
if pos == 'first-sibling':
target = siblings[0]
# ok let's move this
cursor = self._get_database_cursor('write')
move_right = cls._move_right
gap = self.rgt - self.lft + 1
sql = None
target_tree = target.tree_id
# first make a hole
if pos == 'last-child':
newpos = parent.rgt
sql, params = move_right(target.tree_id, newpos, False, gap)
elif target.is_root():
newpos = 1
if pos == 'last-sibling':
target_tree = target.get_siblings().reverse()[0].tree_id + 1
elif pos == 'first-sibling':
target_tree = 1
sql, params = cls._move_tree_right(1)
elif pos == 'left':
sql, params = cls._move_tree_right(target.tree_id)
else:
if pos == 'last-sibling':
newpos = target.get_parent().rgt
sql, params = move_right(target.tree_id, newpos, False, gap)
elif pos == 'first-sibling':
newpos = target.lft
sql, params = move_right(target.tree_id,
newpos - 1, False, gap)
elif pos == 'left':
newpos = target.lft
sql, params = move_right(target.tree_id, newpos, True, gap)
if sql:
cursor.execute(sql, params)
# we reload 'self' because lft/rgt may have changed
fromobj = cls.objects.get(pk=self.pk)
depthdiff = target.depth - fromobj.depth
if parent:
depthdiff += 1
# move the tree to the hole
sql = "UPDATE %(table)s "\
" SET tree_id = %(target_tree)d, "\
" lft = lft + %(jump)d , "\
" rgt = rgt + %(jump)d , "\
" depth = depth + %(depthdiff)d "\
" WHERE tree_id = %(from_tree)d AND "\
" lft BETWEEN %(fromlft)d AND %(fromrgt)d" % {
'table': connection.ops.quote_name(cls._meta.db_table),
'from_tree': fromobj.tree_id,
'target_tree': target_tree,
'jump': newpos - fromobj.lft,
'depthdiff': depthdiff,
'fromlft': fromobj.lft,
'fromrgt': fromobj.rgt}
cursor.execute(sql, [])
# close the gap
sql, params = cls._get_close_gap_sql(fromobj.lft,
fromobj.rgt, fromobj.tree_id)
cursor.execute(sql, params)
@classmethod
def _get_close_gap_sql(cls, drop_lft, drop_rgt, tree_id):
sql = 'UPDATE %(table)s '\
' SET lft = CASE '\
' WHEN lft > %(drop_lft)d '\
' THEN lft - %(gapsize)d '\
' ELSE lft END, '\
' rgt = CASE '\
' WHEN rgt > %(drop_lft)d '\
' THEN rgt - %(gapsize)d '\
' ELSE rgt END '\
' WHERE (lft > %(drop_lft)d '\
' OR rgt > %(drop_lft)d) AND '\
' tree_id=%(tree_id)d' % {
'table': connection.ops.quote_name(
get_result_class(cls)._meta.db_table),
'gapsize': drop_rgt - drop_lft + 1,
'drop_lft': drop_lft,
'tree_id': tree_id}
return sql, []
@classmethod
def load_bulk(cls, bulk_data, parent=None, keep_ids=False):
"""Loads a list/dictionary structure to the tree."""
cls = get_result_class(cls)
# tree, iterative preorder
added = []
if parent:
parent_id = parent.pk
else:
parent_id = None
# stack of nodes to analyze
stack = [(parent_id, node) for node in bulk_data[::-1]]
foreign_keys = cls.get_foreign_keys()
pk_field = cls._meta.pk.attname
while stack:
parent_id, node_struct = stack.pop()
# shallow copy of the data structure so it doesn't persist...
node_data = node_struct['data'].copy()
cls._process_foreign_keys(foreign_keys, node_data)
if keep_ids:
node_data[pk_field] = node_struct[pk_field]
if parent_id:
parent = cls.objects.get(pk=parent_id)
node_obj = parent.add_child(**node_data)
else:
node_obj = cls.add_root(**node_data)
added.append(node_obj.pk)
if 'children' in node_struct:
# extending the stack with the current node as the parent of
# the new nodes
stack.extend([
(node_obj.pk, node)
for node in node_struct['children'][::-1]
])
return added
def get_children(self):
""":returns: A queryset of all the node's children"""
return self.get_descendants().filter(depth=self.depth + 1)
def get_depth(self):
""":returns: the depth (level) of the node"""
return self.depth
def is_leaf(self):
""":returns: True if the node is a leaf node (else, returns False)"""
return self.rgt - self.lft == 1
def get_root(self):
""":returns: the root node for the current node object."""
if self.lft == 1:
return self
return get_result_class(self.__class__).objects.get(
tree_id=self.tree_id, lft=1)
def is_root(self):
""":returns: True if the node is a root node (else, returns False)"""
return self.lft == 1
def get_siblings(self):
"""
:returns: A queryset of all the node's siblings, including the node
itself.
"""
if self.lft == 1:
return self.get_root_nodes()
return self.get_parent(True).get_children()
@classmethod
def dump_bulk(cls, parent=None, keep_ids=True):
"""Dumps a tree branch to a python data structure."""
qset = cls._get_serializable_model().get_tree(parent)
ret, lnk = [], {}
pk_field = cls._meta.pk.attname
for pyobj in qset:
serobj = serializers.serialize('python', [pyobj])[0]
# django's serializer stores the attributes in 'fields'
fields = serobj['fields']
depth = fields['depth']
# this will be useless in load_bulk
del fields['lft']
del fields['rgt']
del fields['depth']
del fields['tree_id']
if pk_field in fields:
# this happens immediately after a load_bulk
del fields[pk_field]
newobj = {'data': fields}
if keep_ids:
newobj[pk_field] = serobj['pk']
if (not parent and depth == 1) or\
(parent and depth == parent.depth):
ret.append(newobj)
else:
parentobj = pyobj.get_parent()
parentser = lnk[parentobj.pk]
if 'children' not in parentser:
parentser['children'] = []
parentser['children'].append(newobj)
lnk[pyobj.pk] = newobj
return ret
@classmethod
def get_tree(cls, parent=None):
"""
:returns:
A *queryset* of nodes ordered as DFS, including the parent.
If no parent is given, all trees are returned.
"""
cls = get_result_class(cls)
if parent is None:
# return the entire tree
return cls.objects.all()
if parent.is_leaf():
return cls.objects.filter(pk=parent.pk)
return cls.objects.filter(
tree_id=parent.tree_id,
lft__range=(parent.lft, parent.rgt - 1))
def get_descendants(self):
"""
:returns: A queryset of all the node's descendants as DFS, doesn't
include the node itself
"""
if self.is_leaf():
return get_result_class(self.__class__).objects.none()
return self.__class__.get_tree(self).exclude(pk=self.pk)
def get_descendant_count(self):
""":returns: the number of descendants of a node."""
return (self.rgt - self.lft - 1) / 2
def get_ancestors(self):
"""
:returns: A queryset containing the current node object's ancestors,
starting by the root node and descending to the parent.
"""
if self.is_root():
return get_result_class(self.__class__).objects.none()
return get_result_class(self.__class__).objects.filter(
tree_id=self.tree_id,
lft__lt=self.lft,
rgt__gt=self.rgt)
def is_descendant_of(self, node):
"""
:returns: ``True`` if the node if a descendant of another node given
as an argument, else, returns ``False``
"""
return (
self.tree_id == node.tree_id and
self.lft > node.lft and
self.rgt < node.rgt
)
def get_parent(self, update=False):
"""
:returns: the parent node of the current node object.
Caches the result in the object itself to help in loops.
"""
if self.is_root():
return
try:
if update:
del self._cached_parent_obj
else:
return self._cached_parent_obj
except AttributeError:
pass
# parent = our most direct ancestor
self._cached_parent_obj = self.get_ancestors().reverse()[0]
return self._cached_parent_obj
@classmethod
def get_root_nodes(cls):
""":returns: A queryset containing the root nodes in the tree."""
return get_result_class(cls).objects.filter(lft=1)
class Meta:
"""Abstract model."""
abstract = True

View File

@@ -0,0 +1,115 @@
"""Convert strings to numbers and numbers to strings.
Gustavo Picon
https://tabo.pe/projects/numconv/
"""
__version__ = '2.1.1'
# from april fool's rfc 1924
BASE85 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' \
'!#$%&()*+-;<=>?@^_`{|}~'
# rfc4648 alphabets
BASE16 = BASE85[:16]
BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
BASE32HEX = BASE85[:32]
BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
BASE64URL = BASE64[:62] + '-_'
# http://en.wikipedia.org/wiki/Base_62 useful for url shorteners
BASE62 = BASE85[:62]
class NumConv(object):
"""Class to create converter objects.
:param radix: The base that will be used in the conversions.
The default value is 10 for decimal conversions.
:param alphabet: A string that will be used as a encoding alphabet.
The length of the alphabet can be longer than the radix. In this
case the alphabet will be internally truncated.
The default value is :data:`numconv.BASE85`
:raise TypeError: when *radix* isn't an integer
:raise ValueError: when *radix* is invalid
:raise ValueError: when *alphabet* has duplicated characters
"""
def __init__(self, radix=10, alphabet=BASE85):
"""basic validation and cached_map storage"""
if int(radix) != radix:
raise TypeError('radix must be an integer')
if not 2 <= radix <= len(alphabet):
raise ValueError('radix must be >= 2 and <= %d' % (
len(alphabet), ))
self.radix = radix
self.alphabet = alphabet
self.cached_map = dict(zip(self.alphabet, range(len(self.alphabet))))
if len(self.cached_map) != len(self.alphabet):
raise ValueError("duplicate characters found in '%s'" % (
self.alphabet, ))
def int2str(self, num):
"""Converts an integer into a string.
:param num: A numeric value to be converted to another base as a
string.
:rtype: string
:raise TypeError: when *num* isn't an integer
:raise ValueError: when *num* isn't positive
"""
if int(num) != num:
raise TypeError('number must be an integer')
if num < 0:
raise ValueError('number must be positive')
radix, alphabet = self.radix, self.alphabet
if radix in (8, 10, 16) and \
alphabet[:radix].lower() == BASE85[:radix].lower():
return ({8: '%o', 10: '%d', 16: '%x'}[radix] % num).upper()
ret = ''
while True:
ret = alphabet[num % radix] + ret
if num < radix:
break
num //= radix
return ret
def str2int(self, num):
"""Converts a string into an integer.
If possible, the built-in python conversion will be used for speed
purposes.
:param num: A string that will be converted to an integer.
:rtype: integer
:raise ValueError: when *num* is invalid
"""
radix, alphabet = self.radix, self.alphabet
if radix <= 36 and alphabet[:radix].lower() == BASE85[:radix].lower():
return int(num, radix)
ret = 0
lalphabet = alphabet[:radix]
for char in num:
if char not in lalphabet:
raise ValueError("invalid literal for radix2int() with radix "
"%d: '%s'" % (radix, num))
ret = ret * radix + self.cached_map[char]
return ret
def int2str(num, radix=10, alphabet=BASE85):
"""helper function for quick base conversions from integers to strings"""
return NumConv(radix, alphabet).int2str(num)
def str2int(num, radix=10, alphabet=BASE85):
"""helper function for quick base conversions from strings to integers"""
return NumConv(radix, alphabet).str2int(num)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 B

View File

@@ -0,0 +1,83 @@
/* Treebeard Admin */
#roots {
margin: 0;
padding: 0;
}
#roots li {
list-style: none;
padding: 5px !important;
line-height: 13px;
border-bottom: 1px solid #EEE;
}
#roots li a {
font-weight: bold;
font-size: 12px;
}
#roots li input {
margin: 0 5px;
}
.oder-grabber {
width: 1.5em;
text-align: center;
}
.drag-handler span {
width: 16px;
background: transparent url(expand-collapse.png) no-repeat left -48px;
height: 16px;
margin: 0 5px;
display: inline-block;
}
.drag-handler span.active {
background: transparent url(expand-collapse.png) no-repeat left -32px;
cursor: move;
}
.spacer {
width: 10px;
margin: 0 10px;
}
.collapse {
width: 16px;
height: 16px;
display: inline-block;
text-indent: -999px;
}
.collapsed {
background: transparent url(expand-collapse.png) no-repeat left -16px;
}
.expanded {
background: transparent url(expand-collapse.png) no-repeat left 0;
}
#drag_line {
border-top: 5px solid #A0A;
background: #A0A;
display: block;
position: absolute;
}
#drag_line span {
position: relative;
display: block;
width: 100px;
background: #FFD;
color: #000;
left: 100px;
text-align: center;
border: 1px solid #000;
vertical-align: center;
}
/*tr:target { I'm handling the highlight with js to have more control
background-color: #FF0;
}*/

View File

@@ -0,0 +1,314 @@
(function ($) {
// Ok, let's do eeet
ACTIVE_NODE_BG_COLOR = '#B7D7E8';
RECENTLY_MOVED_COLOR = '#FFFF00';
RECENTLY_MOVED_FADEOUT = '#FFFFFF';
ABORT_COLOR = '#EECCCC';
DRAG_LINE_COLOR = '#AA00AA';
MOVE_NODE_ENDPOINT = 'move/';
RECENTLY_FADE_DURATION = 2000;
CSRF_TOKEN = document.currentScript.dataset.csrftoken;
// Add jQuery util for disabling selection
// Originally taken from jquery-ui (where it is deprecated)
// https://api.jqueryui.com/disableSelection/
$.fn.extend( {
disableSelection: ( function() {
var eventType = "onselectstart" in document.createElement( "div" ) ? "selectstart" : "mousedown";
return function() {
return this.on( eventType + ".ui-disableSelection", function( event ) {
event.preventDefault();
} );
};
} )(),
enableSelection: function() {
return this.off( ".ui-disableSelection" );
}
} );
// This is the basic Node class, which handles UI tree operations for each 'row'
var Node = function (elem) {
var $elem = $(elem);
var node_id = $elem.attr('node');
var parent_id = $elem.attr('parent');
var level = parseInt($elem.attr('level'));
var children_num = parseInt($elem.attr('children-num'));
return {
elem: elem,
$elem: $elem,
node_id: node_id,
parent_id: parent_id,
level: level,
has_children: function () {
return children_num > 0;
},
node_name: function () {
// Returns the text of the node
return $elem.find('th a:not(.collapse)').text();
},
is_collapsed: function () {
return $elem.find('a.collapse').hasClass('collapsed');
},
children: function () {
return $('tr[parent=' + node_id + ']');
},
collapse: function () {
// For each children, hide it's children and so on...
$.each(this.children(),function () {
var node = new Node(this);
node.collapse();
}).hide();
// Swicth class to set the property expand/collapse icon
$elem.find('a.collapse').removeClass('expanded').addClass('collapsed');
},
parent_node: function () {
// Returns a Node object of the parent
return new Node($('tr[node=' + parent_id + ']', $elem.parent())[0]);
},
expand: function () {
// Display each kid (will display in collapsed state)
this.children().show();
// Swicth class to set the property expand/collapse icon
$elem.find('a.collapse').removeClass('collapsed').addClass('expanded');
},
toggle: function () {
if (this.is_collapsed()) {
this.expand();
} else {
this.collapse();
}
},
clone: function () {
return $elem.clone();
}
}
};
$(document).ready(function () {
$(document).ajaxSend(function (event, xhr, settings) {
if (!(/^http:.*/.test(settings.url) || /^https:.*/.test(settings.url))) {
// Only send the token to relative URLs i.e. locally.
xhr.setRequestHeader("X-CSRFToken", CSRF_TOKEN);
}
});
// Don't activate drag or collapse if GET filters are set on the page
if ($('#has-filters').val() === "1") {
return;
}
$body = $('body');
// Activate all rows for drag & drop
// then bind mouse down event
$('td.drag-handler span').addClass('active').bind('mousedown', function (evt) {
$ghost = $('<div id="ghost"></div>');
$drag_line = $('<div id="drag_line"><span></span></div>');
$ghost.appendTo($body);
$drag_line.appendTo($body);
var stop_drag = function () {
$ghost.remove();
$drag_line.remove();
$body.enableSelection().unbind('mousemove').unbind('mouseup');
node.elem.removeAttribute('style');
};
// Create a clone create the illusion that we're moving the node
var node = new Node($(this).closest('tr')[0]);
cloned_node = node.clone();
node.$elem.css({
'background': ACTIVE_NODE_BG_COLOR
});
$targetRow = null;
as_child = false;
// Now make the new clone move with the mouse
$body.disableSelection().bind('mousemove',function (evt2) {
$ghost.html(cloned_node).css({ // from FeinCMS :P
'opacity': .8,
'position': 'absolute',
'top': evt2.pageY,
'left': evt2.pageX - 30,
'width': 600
});
// Iterate through all rows and see where am I moving so I can place
// the drag line accordingly
rowHeight = node.$elem.height();
$('tr', node.$elem.parent()).each(function (index, element) {
$row = $(element);
rtop = $row.offset().top;
// The tooltip will display whether I'm dropping the element as
// child or sibling
$tooltip = $drag_line.find('span');
$tooltip.css({
'left': node.$elem.width() - $tooltip.width(),
'height': rowHeight,
});
node_top = node.$elem.offset().top;
// Check if you are dragging over the same node
if (evt2.pageY >= node_top && evt2.pageY <= node_top + rowHeight) {
$targetRow = null;
$tooltip.text(gettext('Abort'));
$drag_line.css({
'top': node_top,
'height': rowHeight,
'borderWidth': 0,
'opacity': 0.8,
'backgroundColor': ABORT_COLOR
});
} else
// Check if mouse is over this row
if (evt2.pageY >= rtop && evt2.pageY <= rtop + rowHeight / 2) {
// The mouse is positioned on the top half of a $row
$targetRow = $row;
as_child = false;
$drag_line.css({
'left': node.$elem.offset().left,
'width': node.$elem.width(),
'top': rtop,
'borderWidth': '5px',
'height': 0,
'opacity': 1
});
$tooltip.text(gettext('As Sibling'));
} else if (evt2.pageY >= rtop + rowHeight / 2 && evt2.pageY <= rtop + rowHeight) {
// The mouse is positioned on the bottom half of a row
$targetRow = $row;
target_node = new Node($targetRow[0]);
if (target_node.is_collapsed()) {
target_node.expand();
}
as_child = true;
$drag_line.css({
'top': rtop,
'left': node.$elem.offset().left,
'height': rowHeight,
'opacity': 0.4,
'width': node.$elem.width(),
'borderWidth': 0,
'backgroundColor': DRAG_LINE_COLOR
});
$tooltip.text(gettext('As child'));
}
});
}).bind('mouseup',function () {
if ($targetRow !== null) {
target_node = new Node($targetRow[0]);
if (target_node.node_id !== node.node_id) {
/*alert('Insert node ' + node.node_name() + ' as child of: '
+ target_node.parent_node().node_name() + '\n and sibling of: '
+ target_node.node_name());*/
// Call $.ajax so we can handle the error
// On Drop, make an XHR call to perform the node move
$.ajax({
url: MOVE_NODE_ENDPOINT,
type: 'POST',
data: {
node_id: node.node_id,
parent_id: target_node.parent_id,
sibling_id: target_node.node_id,
as_child: as_child ? 1 : 0
},
complete: function (req, status) {
// http://stackoverflow.com/questions/1439895/add-a-hash-with-javascript-to-url-without-scrolling-page/1439910#1439910
node.$elem.remove();
window.location.hash = 'node-' + node.node_id;
window.location.reload();
},
error: function (req, status, error) {
// On error (!200) also reload to display
// the message
node.$elem.remove();
window.location.hash = 'node-' + node.node_id;
window.location.reload();
}
});
}
}
stop_drag();
}).bind('keyup', function (kbevt) {
// Cancel drag on escape
if (kbevt.keyCode === 27) {
stop_drag();
}
});
});
$('a.collapse').click(function () {
var node = new Node($(this).closest('tr')[0]); // send the DOM node, not jQ
node.toggle();
return false;
});
var hash = window.location.hash;
// This is a hack, the actual element's id ends in '-id' but the url's hash
// doesn't, I'm doing this to avoid scrolling the page... is that a good thing?
if (hash) {
$(hash + '-id').animate({
backgroundColor: RECENTLY_MOVED_COLOR
}, RECENTLY_FADE_DURATION, function () {
$(this).animate({
backgroundColor: RECENTLY_MOVED_FADEOUT
}, RECENTLY_FADE_DURATION, function () {
this.removeAttribute('style');
});
});
}
});
})(django.jQuery);
// http://stackoverflow.com/questions/190560/jquery-animate-backgroundcolor/2302005#2302005
(function (d) {
d.each(["backgroundColor", "borderBottomColor", "borderLeftColor", "borderRightColor", "borderTopColor", "color", "outlineColor"], function (f, e) {
d.fx.step[e] = function (g) {
if (!g.colorInit) {
g.start = c(g.elem, e);
g.end = b(g.end);
g.colorInit = true
}
g.elem.style[e] = "rgb(" + [Math.max(Math.min(parseInt((g.pos * (g.end[0] - g.start[0])) + g.start[0]), 255), 0), Math.max(Math.min(parseInt((g.pos * (g.end[1] - g.start[1])) + g.start[1]), 255), 0), Math.max(Math.min(parseInt((g.pos * (g.end[2] - g.start[2])) + g.start[2]), 255), 0)].join(",") + ")"
}
});
function b(f) {
var e;
if (f && f.constructor == Array && f.length == 3) {
return f
}
if (e = /rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(f)) {
return[parseInt(e[1]), parseInt(e[2]), parseInt(e[3])]
}
if (e = /rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(f)) {
return[parseFloat(e[1]) * 2.55, parseFloat(e[2]) * 2.55, parseFloat(e[3]) * 2.55]
}
if (e = /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(f)) {
return[parseInt(e[1], 16), parseInt(e[2], 16), parseInt(e[3], 16)]
}
if (e = /#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(f)) {
return[parseInt(e[1] + e[1], 16), parseInt(e[2] + e[2], 16), parseInt(e[3] + e[3], 16)]
}
if (e = /rgba\(0, 0, 0, 0\)/.exec(f)) {
return a.transparent
}
return a[d.trim(f).toLowerCase()]
}
function c(g, e) {
var f;
do {
f = d.css(g, e);
if (f != "" && f != "transparent" || d.nodeName(g, "body")) {
break
}
e = "backgroundColor"
} while (g = g.parentNode);
return b(f)
}
var a = {aqua: [0, 255, 255], azure: [240, 255, 255], beige: [245, 245, 220], black: [0, 0, 0], blue: [0, 0, 255], brown: [165, 42, 42], cyan: [0, 255, 255], darkblue: [0, 0, 139], darkcyan: [0, 139, 139], darkgrey: [169, 169, 169], darkgreen: [0, 100, 0], darkkhaki: [189, 183, 107], darkmagenta: [139, 0, 139], darkolivegreen: [85, 107, 47], darkorange: [255, 140, 0], darkorchid: [153, 50, 204], darkred: [139, 0, 0], darksalmon: [233, 150, 122], darkviolet: [148, 0, 211], fuchsia: [255, 0, 255], gold: [255, 215, 0], green: [0, 128, 0], indigo: [75, 0, 130], khaki: [240, 230, 140], lightblue: [173, 216, 230], lightcyan: [224, 255, 255], lightgreen: [144, 238, 144], lightgrey: [211, 211, 211], lightpink: [255, 182, 193], lightyellow: [255, 255, 224], lime: [0, 255, 0], magenta: [255, 0, 255], maroon: [128, 0, 0], navy: [0, 0, 128], olive: [128, 128, 0], orange: [255, 165, 0], pink: [255, 192, 203], purple: [128, 0, 128], violet: [128, 0, 128], red: [255, 0, 0], silver: [192, 192, 192], white: [255, 255, 255], yellow: [255, 255, 0], transparent: [255, 255, 255]}
})(django.jQuery);

View File

@@ -0,0 +1,24 @@
{# Used for MP and NS trees #}
{% extends "admin/change_list.html" %}
{% load admin_list admin_tree static %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static 'treebeard/treebeard-admin.css' %}" />
{% endblock %}
{% block extrahead %}
{{ block.super }}
<script src="{% url 'admin:jsi18n' %}"></script>
<script data-csrftoken="{{ csrf_token }}" src="{% static 'treebeard/treebeard-admin.js' %}"></script>
{% endblock %}
{% block result_list %}
{% if action_form and actions_on_top and cl.full_result_count %}
{% admin_actions %}
{% endif %}
{% result_tree cl request %}
{% if action_form and actions_on_bottom and cl.full_result_count %}
{% admin_actions %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% if result_hidden_fields %}
<div class="hiddenfields"> {# DIV for HTML validation #}
{% for item in result_hidden_fields %}{{ item }}{% endfor %}
</div>
{% endif %}
{% if results %}
<div class="results">
<table cellspacing="0" id="result_list">
<thead>
<tr>
{% for header in result_headers %}
<th{{ header.class_attrib }}>
{% if header.sortable %}<a href="{{ header.url }}"
{% if header.tooltip %}title="{{ header.tooltip }}"{% endif %}>{% endif %}
{{ header.text|capfirst }}
{% if header.sortable %}</a>{% endif %}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for node_id, parent_id, node_level, children_num, result in results %}
<tr id="node-{{ node_id }}-id" class="{% cycle 'row1' 'row2' %}"
level="{{ node_level }}" children-num="{{ children_num }}"
parent="{{ parent_id }}" node="{{ node_id }}">
{% for item in result %}
{% if forloop.counter == 1 %}
{% for spacer in item.depth %}<span class="grab">&nbsp;
</span>{% endfor %}
{% endif %}
{{ item }}
{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
<input type="hidden" id="has-filters" value="{{ filtered|yesno:"1,0" }}"/>
</div>
{% endif %}

View File

@@ -0,0 +1,21 @@
{# Used for AL trees #}
{% extends "admin/change_list.html" %}
{% load admin_list admin_tree_list i18n %}
{% block extrastyle %}
{{ block.super }}
{% endblock %}
{% block extrahead %}
{{ block.super }}
{% endblock %}
{% block result_list %}
{% if action_form and actions_on_top and cl.full_result_count %}
{% admin_actions %}
{% endif %}
{% result_tree cl request %}
{% if action_form and actions_on_bottom and cl.full_result_count %}
{% admin_actions %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% if results %}
<ul>
{% for result in results %}
<li class="{% cycle 'row1' 'row2' %}">{{ result }}</li>
{% endfor %}
</ul>
{% endif %}

View File

@@ -0,0 +1,11 @@
from django.template import Variable, VariableDoesNotExist
action_form_var = Variable('action_form')
def needs_checkboxes(context):
try:
return action_form_var.resolve(context) is not None
except VariableDoesNotExist:
return False

View File

@@ -0,0 +1,215 @@
"""
Templatetags for django-treebeard to add drag and drop capabilities to the
nodes change list - @jjdelc
"""
import datetime
from django.db import models
from django.contrib.admin.templatetags.admin_list import (
result_headers, result_hidden_fields)
from django.contrib.admin.utils import (
lookup_field, display_for_field, display_for_value)
from django.core.exceptions import ObjectDoesNotExist
from django.template import Library
from django.utils.encoding import force_str
from django.utils.html import conditional_escape, format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from treebeard.templatetags import needs_checkboxes
register = Library()
def get_result_and_row_class(cl, field_name, result):
empty_value_display = cl.model_admin.get_empty_value_display()
row_classes = ['field-%s' % field_name]
try:
f, attr, value = lookup_field(field_name, result, cl.model_admin)
except ObjectDoesNotExist:
result_repr = empty_value_display
else:
empty_value_display = getattr(attr, 'empty_value_display', empty_value_display)
if f is None:
if field_name == 'action_checkbox':
row_classes = ['action-checkbox']
allow_tags = getattr(attr, 'allow_tags', False)
boolean = getattr(attr, 'boolean', False)
result_repr = display_for_value(value, empty_value_display, boolean)
# Strip HTML tags in the resulting text, except if the
# function has an "allow_tags" attribute set to True.
# WARNING: this will be deprecated in Django 2.0
if allow_tags:
result_repr = mark_safe(result_repr)
if isinstance(value, (datetime.date, datetime.time)):
row_classes.append('nowrap')
else:
if isinstance(getattr(f, 'remote_field'), models.ManyToOneRel):
field_val = getattr(result, f.name)
if field_val is None:
result_repr = empty_value_display
else:
result_repr = field_val
else:
result_repr = display_for_field(value, f, empty_value_display)
if isinstance(f, (models.DateField, models.TimeField,
models.ForeignKey)):
row_classes.append('nowrap')
if force_str(result_repr) == '':
result_repr = mark_safe('&nbsp;')
row_class = mark_safe(' class="%s"' % ' '.join(row_classes))
return result_repr, row_class
def get_spacer(first, result):
if first:
spacer = '<span class="spacer">&nbsp;</span>' * (
result.get_depth() - 1)
else:
spacer = ''
return spacer
def get_collapse(result):
if result.get_children_count():
collapse = ('<a href="#" title="" class="collapse expanded">'
'-</a>')
else:
collapse = '<span class="collapse">&nbsp;</span>'
return collapse
def get_drag_handler(first):
drag_handler = ''
if first:
drag_handler = ('<td class="drag-handler">'
'<span>&nbsp;</span></td>')
return drag_handler
def items_for_result(cl, result, form):
"""
Generates the actual list of data.
@jjdelc:
This has been shamelessly copied from original
django.contrib.admin.templatetags.admin_list.items_for_result
in order to alter the dispay for the first element
"""
first = True
pk = cl.lookup_opts.pk.attname
for field_name in cl.list_display:
result_repr, row_class = get_result_and_row_class(cl, field_name,
result)
# If list_display_links not defined, add the link tag to the
# first field
if (first and not cl.list_display_links) or \
field_name in cl.list_display_links:
table_tag = {True: 'th', False: 'td'}[first]
# This spacer indents the nodes based on their depth
spacer = get_spacer(first, result)
# This shows a collapse or expand link for nodes with childs
collapse = get_collapse(result)
# Add a <td/> before the first col to show the drag handler
drag_handler = get_drag_handler(first)
first = False
url = cl.url_for_result(result)
# Convert the pk to something that can be used in Javascript.
# Problem cases are long ints (23L) and non-ASCII strings.
if cl.to_field:
attr = str(cl.to_field)
else:
attr = pk
value = result.serializable_value(attr)
result_id = "'%s'" % force_str(value)
onclickstr = (
' onclick="opener.dismissRelatedLookupPopup(window, %s);'
' return false;"')
yield mark_safe(
'%s<%s%s>%s %s <a href="%s"%s>%s</a></%s>' % (
drag_handler, table_tag, row_class, spacer, collapse, url,
(cl.is_popup and onclickstr % result_id or ''),
conditional_escape(result_repr), table_tag))
else:
# By default the fields come from ModelAdmin.list_editable, but if
# we pull the fields out of the form instead of list_editable
# custom admins can provide fields on a per request basis
if (
form and
field_name in form.fields and
not (
field_name == cl.model._meta.pk.name and
form[cl.model._meta.pk.name].is_hidden
)
):
bf = form[field_name]
result_repr = mark_safe(force_str(bf.errors) + force_str(bf))
yield format_html('<td{0}>{1}</td>', row_class, result_repr)
if form and not form[cl.model._meta.pk.name].is_hidden:
yield format_html('<td>{0}</td>', force_str(form[cl.model._meta.pk.name]))
def get_parent_id(node):
"""Return the node's parent id or 0 if node is a root node."""
if node.is_root():
return 0
return node.get_parent().pk
def results(cl):
if cl.formset:
for res, form in zip(cl.result_list, cl.formset.forms):
yield (res.pk, get_parent_id(res), res.get_depth(),
res.get_children_count(),
list(items_for_result(cl, res, form)))
else:
for res in cl.result_list:
yield (res.pk, get_parent_id(res), res.get_depth(),
res.get_children_count(),
list(items_for_result(cl, res, None)))
def check_empty_dict(GET_dict):
"""
Returns True if the GET query string contains on values, but it can contain
empty keys.
This is better than doing not bool(request.GET) as an empty key will return
True
"""
empty = True
for k, v in GET_dict.items():
# Don't disable on p(age) or 'all' GET param
if v and k != 'p' and k != 'all':
empty = False
return empty
@register.inclusion_tag(
'admin/tree_change_list_results.html', takes_context=True)
def result_tree(context, cl, request):
"""
Added 'filtered' param, so the template's js knows whether the results have
been affected by a GET param or not. Only when the results are not filtered
you can drag and sort the tree
"""
# Here I'm adding an extra col on pos 2 for the drag handlers
headers = list(result_headers(cl))
headers.insert(1 if needs_checkboxes(context) else 0, {
'text': '+',
'sortable': True,
'url': request.path,
'tooltip': _('Return to ordered tree'),
'class_attrib': mark_safe(' class="oder-grabber"')
})
return {
'filtered': not check_empty_dict(request.GET),
'result_hidden_fields': list(result_hidden_fields(cl)),
'result_headers': headers,
'results': list(results(cl)),
}

View File

@@ -0,0 +1,47 @@
from django.template import Library
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.contrib.admin.options import TO_FIELD_VAR
from treebeard.templatetags import needs_checkboxes
register = Library()
CHECKBOX_TMPL = ('<input type="checkbox" class="action-select" value="{}" '
'name="_selected_action" />')
def _line(context, node, request):
pk_field = node._meta.model._meta.pk.attname
if TO_FIELD_VAR in request.GET and request.GET[TO_FIELD_VAR] == pk_field:
raw_id_fields = format_html("""
onclick="opener.dismissRelatedLookupPopup(window, '{}'); return false;"
""", node.pk)
else:
raw_id_fields = ''
output = ''
if needs_checkboxes(context):
output += format_html(CHECKBOX_TMPL, node.pk)
return output + format_html(
'<a href="{}/" {}>{}</a>',
node.pk, mark_safe(raw_id_fields), str(node))
def _subtree(context, node, request):
tree = ''
for subnode in node.get_children():
tree += format_html(
'<li>{}</li>',
mark_safe(_subtree(context, subnode, request)))
if tree:
tree = format_html('<ul>{}</ul>', mark_safe(tree))
return _line(context, node, request) + tree
@register.simple_tag(takes_context=True)
def result_tree(context, cl, request):
tree = ''
for root_node in cl.model.get_root_nodes():
tree += format_html(
'<li>{}</li>', mark_safe(_subtree(context, root_node, request)))
return format_html("<ul>{}</ul>", mark_safe(tree))

View File

@@ -0,0 +1,18 @@
import itertools
from django.contrib import admin
from treebeard.admin import admin_factory
from treebeard.forms import movenodeform_factory
from treebeard.tests.models import BASE_MODELS, UNICODE_MODELS, DEP_MODELS
def register(admin_site, model):
form_class = movenodeform_factory(model)
admin_class = admin_factory(form_class)
admin_site.register(model, admin_class)
def register_all(admin_site=admin.site):
for model in itertools.chain(BASE_MODELS, UNICODE_MODELS, DEP_MODELS):
register(admin_site, model)

View File

@@ -0,0 +1,15 @@
"""Pytest configuration file
"""
import os
os.environ["DJANGO_SETTINGS_MODULE"] = "treebeard.tests.settings"
import django
def pytest_report_header(config):
return "Django: " + django.get_version()
def pytest_configure(config):
django.setup()

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "treebeard.tests.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)

View File

@@ -0,0 +1,381 @@
# Generated by Django 3.1.2 on 2021-02-24 20:44
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AL_TestNode',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sib_order', models.PositiveIntegerField()),
('desc', models.CharField(max_length=255)),
('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children_set', to='tests.al_testnode')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='MP_TestNode',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('path', models.CharField(max_length=255, unique=True)),
('depth', models.PositiveIntegerField()),
('numchild', models.PositiveIntegerField(default=0)),
('desc', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='MP_TestNodeAlphabet',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('path', models.CharField(max_length=255, unique=True)),
('depth', models.PositiveIntegerField()),
('numchild', models.PositiveIntegerField(default=0)),
('numval', models.IntegerField()),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='MP_TestNodeCustomId',
fields=[
('path', models.CharField(max_length=255, unique=True)),
('depth', models.PositiveIntegerField()),
('numchild', models.PositiveIntegerField(default=0)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('desc', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='MP_TestNodeShortPath',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('path', models.CharField(max_length=4, unique=True)),
('depth', models.PositiveIntegerField()),
('numchild', models.PositiveIntegerField(default=0)),
('desc', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='MP_TestNodeSmallStep',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('path', models.CharField(max_length=255, unique=True)),
('depth', models.PositiveIntegerField()),
('numchild', models.PositiveIntegerField(default=0)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='MP_TestNodeSorted',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('path', models.CharField(max_length=255, unique=True)),
('depth', models.PositiveIntegerField()),
('numchild', models.PositiveIntegerField(default=0)),
('val1', models.IntegerField()),
('val2', models.IntegerField()),
('desc', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='MP_TestNodeSortedAutoNow',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('path', models.CharField(max_length=255, unique=True)),
('depth', models.PositiveIntegerField()),
('numchild', models.PositiveIntegerField(default=0)),
('desc', models.CharField(max_length=255)),
('created', models.DateTimeField(auto_now_add=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='MP_TestNodeUuid',
fields=[
('path', models.CharField(max_length=255, unique=True)),
('depth', models.PositiveIntegerField()),
('numchild', models.PositiveIntegerField(default=0)),
('custom_id', models.UUIDField(default=uuid.uuid1, editable=False, primary_key=True, serialize=False)),
('desc', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='MP_TestSortedNodeShortPath',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('path', models.CharField(max_length=4, unique=True)),
('depth', models.PositiveIntegerField()),
('numchild', models.PositiveIntegerField(default=0)),
('desc', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='MP_UnicodeNode',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('path', models.CharField(max_length=255, unique=True)),
('depth', models.PositiveIntegerField()),
('numchild', models.PositiveIntegerField(default=0)),
('desc', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='NS_TestNode',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('lft', models.PositiveIntegerField(db_index=True)),
('rgt', models.PositiveIntegerField(db_index=True)),
('tree_id', models.PositiveIntegerField(db_index=True)),
('depth', models.PositiveIntegerField(db_index=True)),
('desc', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='NS_TestNodeSorted',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('lft', models.PositiveIntegerField(db_index=True)),
('rgt', models.PositiveIntegerField(db_index=True)),
('tree_id', models.PositiveIntegerField(db_index=True)),
('depth', models.PositiveIntegerField(db_index=True)),
('val1', models.IntegerField()),
('val2', models.IntegerField()),
('desc', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='NS_UnicodetNode',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('lft', models.PositiveIntegerField(db_index=True)),
('rgt', models.PositiveIntegerField(db_index=True)),
('tree_id', models.PositiveIntegerField(db_index=True)),
('depth', models.PositiveIntegerField(db_index=True)),
('desc', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='RelatedModel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('desc', models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='AL_TestNodeInherited',
fields=[
('al_testnode_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.al_testnode')),
('extra_desc', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
bases=('tests.al_testnode',),
),
migrations.CreateModel(
name='MP_TestNodeInherited',
fields=[
('mp_testnode_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.mp_testnode')),
('extra_desc', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
bases=('tests.mp_testnode',),
),
migrations.CreateModel(
name='NS_TestNodeInherited',
fields=[
('ns_testnode_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.ns_testnode')),
('extra_desc', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
bases=('tests.ns_testnode',),
),
migrations.CreateModel(
name='NS_TestNodeSomeDep',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('node', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.ns_testnode')),
],
),
migrations.CreateModel(
name='NS_TestNodeRelated',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('lft', models.PositiveIntegerField(db_index=True)),
('rgt', models.PositiveIntegerField(db_index=True)),
('tree_id', models.PositiveIntegerField(db_index=True)),
('depth', models.PositiveIntegerField(db_index=True)),
('desc', models.CharField(max_length=255)),
('related', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.relatedmodel')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='MP_TestNodeSomeDep',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('node', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.mp_testnode')),
],
),
migrations.CreateModel(
name='MP_TestNodeRelated',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('path', models.CharField(max_length=255, unique=True)),
('depth', models.PositiveIntegerField()),
('numchild', models.PositiveIntegerField(default=0)),
('desc', models.CharField(max_length=255)),
('related', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.relatedmodel')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='MP_TestManyToManyWithUser',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('path', models.CharField(max_length=255, unique=True)),
('depth', models.PositiveIntegerField()),
('numchild', models.PositiveIntegerField(default=0)),
('name', models.CharField(max_length=255)),
('users', models.ManyToManyField(to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='AL_UnicodeNode',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sib_order', models.PositiveIntegerField()),
('desc', models.CharField(max_length=255)),
('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children_set', to='tests.al_unicodenode')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='AL_TestNodeSorted',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('val1', models.IntegerField()),
('val2', models.IntegerField()),
('desc', models.CharField(max_length=255)),
('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children_set', to='tests.al_testnodesorted')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='AL_TestNodeSomeDep',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('node', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.al_testnode')),
],
),
migrations.CreateModel(
name='AL_TestNodeRelated',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sib_order', models.PositiveIntegerField()),
('desc', models.CharField(max_length=255)),
('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children_set', to='tests.al_testnoderelated')),
('related', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.relatedmodel')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='AL_TestNode_Proxy',
fields=[
],
options={
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('tests.al_testnode',),
),
migrations.CreateModel(
name='MP_TestNode_Proxy',
fields=[
],
options={
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('tests.mp_testnode',),
),
migrations.CreateModel(
name='NS_TestNode_Proxy',
fields=[
],
options={
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('tests.ns_testnode',),
),
]

View File

@@ -0,0 +1,299 @@
import uuid
from django.db import models
from django.contrib.auth.models import User
from treebeard.mp_tree import MP_Node
from treebeard.al_tree import AL_Node
from treebeard.ns_tree import NS_Node
class RelatedModel(models.Model):
desc = models.CharField(max_length=255)
def __str__(self):
return self.desc
class MP_TestNode(MP_Node):
steplen = 3
desc = models.CharField(max_length=255)
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class MP_UnicodeNode(MP_Node):
steplen = 3
desc = models.CharField(max_length=255)
def __str__(self): # pragma: no cover
return self.desc
class MP_TestNodeSomeDep(models.Model):
node = models.ForeignKey(MP_TestNode, on_delete=models.CASCADE)
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class MP_TestNodeRelated(MP_Node):
steplen = 3
desc = models.CharField(max_length=255)
related = models.ForeignKey(RelatedModel, on_delete=models.CASCADE)
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class MP_TestNodeInherited(MP_TestNode):
extra_desc = models.CharField(max_length=255)
class MP_TestNodeCustomId(MP_Node):
steplen = 3
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
desc = models.CharField(max_length=255)
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class NS_TestNode(NS_Node):
desc = models.CharField(max_length=255)
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class NS_UnicodetNode(NS_Node):
desc = models.CharField(max_length=255)
def __str__(self): # pragma: no cover
return self.desc
class NS_TestNodeSomeDep(models.Model):
node = models.ForeignKey(NS_TestNode, on_delete=models.CASCADE)
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class NS_TestNodeRelated(NS_Node):
desc = models.CharField(max_length=255)
related = models.ForeignKey(RelatedModel, on_delete=models.CASCADE)
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class NS_TestNodeInherited(NS_TestNode):
extra_desc = models.CharField(max_length=255)
class AL_TestNode(AL_Node):
parent = models.ForeignKey(
"self",
related_name="children_set",
null=True,
db_index=True,
on_delete=models.CASCADE,
)
sib_order = models.PositiveIntegerField()
desc = models.CharField(max_length=255)
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class AL_UnicodeNode(AL_Node):
parent = models.ForeignKey(
"self",
related_name="children_set",
null=True,
db_index=True,
on_delete=models.CASCADE,
)
sib_order = models.PositiveIntegerField()
desc = models.CharField(max_length=255)
def __str__(self): # pragma: no cover
return self.desc
class AL_TestNodeSomeDep(models.Model):
node = models.ForeignKey(AL_TestNode, on_delete=models.CASCADE)
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class AL_TestNodeRelated(AL_Node):
parent = models.ForeignKey(
"self",
related_name="children_set",
null=True,
db_index=True,
on_delete=models.CASCADE,
)
sib_order = models.PositiveIntegerField()
desc = models.CharField(max_length=255)
related = models.ForeignKey(RelatedModel, on_delete=models.CASCADE)
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class AL_TestNodeInherited(AL_TestNode):
extra_desc = models.CharField(max_length=255)
class MP_TestNodeSorted(MP_Node):
steplen = 1
node_order_by = ["val1", "val2", "desc"]
val1 = models.IntegerField()
val2 = models.IntegerField()
desc = models.CharField(max_length=255)
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class NS_TestNodeSorted(NS_Node):
node_order_by = ["val1", "val2", "desc"]
val1 = models.IntegerField()
val2 = models.IntegerField()
desc = models.CharField(max_length=255)
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class AL_TestNodeSorted(AL_Node):
parent = models.ForeignKey(
"self",
related_name="children_set",
null=True,
db_index=True,
on_delete=models.CASCADE,
)
node_order_by = ["val1", "val2", "desc"]
val1 = models.IntegerField()
val2 = models.IntegerField()
desc = models.CharField(max_length=255)
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class MP_TestNodeAlphabet(MP_Node):
steplen = 2
numval = models.IntegerField()
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class MP_TestNodeSmallStep(MP_Node):
steplen = 1
alphabet = "0123456789"
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class MP_TestNodeSortedAutoNow(MP_Node):
desc = models.CharField(max_length=255)
created = models.DateTimeField(auto_now_add=True)
node_order_by = ["created"]
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class MP_TestNodeShortPath(MP_Node):
steplen = 1
alphabet = "012345678"
desc = models.CharField(max_length=255)
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
class MP_TestNodeUuid(MP_Node):
steplen = 1
custom_id = models.UUIDField(primary_key=True, default=uuid.uuid1, editable=False)
desc = models.CharField(max_length=255)
def __str__(self): # pragma: no cover
return "Node %s" % self.pk
# This is how you change the default fields defined in a Django abstract class
# (in this case, MP_Node), since Django doesn't allow overriding fields, only
# mehods and attributes
MP_TestNodeShortPath._meta.get_field("path").max_length = 4
class MP_TestNode_Proxy(MP_TestNode):
class Meta:
proxy = True
class NS_TestNode_Proxy(NS_TestNode):
class Meta:
proxy = True
class AL_TestNode_Proxy(AL_TestNode):
class Meta:
proxy = True
class MP_TestSortedNodeShortPath(MP_Node):
steplen = 1
alphabet = "012345678"
desc = models.CharField(max_length=255)
node_order_by = ["desc"]
def __str__(self): # pragma: no cover
return "Node %d" % self.pk
MP_TestSortedNodeShortPath._meta.get_field("path").max_length = 4
class MP_TestManyToManyWithUser(MP_Node):
name = models.CharField(max_length=255)
users = models.ManyToManyField(User)
BASE_MODELS = (
AL_TestNode,
MP_TestNode,
NS_TestNode,
MP_TestNodeUuid,
MP_TestNodeCustomId,
)
PROXY_MODELS = AL_TestNode_Proxy, MP_TestNode_Proxy, NS_TestNode_Proxy
SORTED_MODELS = AL_TestNodeSorted, MP_TestNodeSorted, NS_TestNodeSorted
DEP_MODELS = AL_TestNodeSomeDep, MP_TestNodeSomeDep, NS_TestNodeSomeDep
MP_SHORTPATH_MODELS = MP_TestNodeShortPath, MP_TestSortedNodeShortPath
RELATED_MODELS = AL_TestNodeRelated, MP_TestNodeRelated, NS_TestNodeRelated
UNICODE_MODELS = AL_UnicodeNode, MP_UnicodeNode, NS_UnicodetNode
INHERITED_MODELS = (AL_TestNodeInherited, MP_TestNodeInherited, NS_TestNodeInherited)
def empty_models_tables(models):
for model in models:
model.objects.all().delete()

View File

@@ -0,0 +1,92 @@
"""
Django settings for testing treebeard
"""
import os
def get_db_conf():
"""
Configures database according to the DATABASE_ENGINE environment
variable. Defaults to SQlite.
This method is used to run tests against different database backends.
"""
database_engine = os.environ.get('DATABASE_ENGINE', 'sqlite')
if database_engine == 'sqlite':
return {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:'
}
elif database_engine == 'psql':
return {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'treebeard',
'USER': os.environ.get('DATABASE_USER_POSTGRES', 'treebeard'),
'PASSWORD': os.environ.get('DATABASE_PASSWORD', ''),
'HOST': os.environ.get('DATABASE_HOST', 'localhost'),
'PORT': os.environ.get('DATABASE_PORT_POSTGRES', ''),
}
elif database_engine == "mysql":
return {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'treebeard',
'USER': os.environ.get('DATABASE_USER_MYSQL', 'treebeard'),
'PASSWORD': os.environ.get('DATABASE_PASSWORD', ''),
'HOST': os.environ.get('DATABASE_HOST', 'localhost'),
'PORT': os.environ.get('DATABASE_PORT_MYSQL', ''),
}
elif database_engine == "mssql":
return {
'ENGINE': 'mssql',
'NAME': 'master',
'USER': 'sa',
'PASSWORD': 'Password12!',
'HOST': '(local)\\SQL2019',
'PORT': '',
'OPTIONS': {
'driver': 'SQL Server Native Client 11.0',
},
}
DATABASES = {'default': get_db_conf()}
SECRET_KEY = '7r33b34rd'
INSTALLED_APPS = [
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.admin',
'django.contrib.messages',
'treebeard',
'treebeard.tests'
]
MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware'
]
ROOT_URLCONF = 'treebeard.tests.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.contrib.auth.context_processors.auth',
'django.template.context_processors.debug',
'django.template.context_processors.i18n',
'django.template.context_processors.media',
'django.template.context_processors.static',
'django.template.context_processors.tz',
'django.template.context_processors.request',
'django.contrib.messages.context_processors.messages',
],
},
},
]

View File

@@ -0,0 +1,54 @@
"""
Check that all changes to Treebeard models have had migrations created in our test app. If there
are outstanding model changes that need migrations, fail the tests.
This module is taken from https://github.com/wagtail/wagtail/blob/master/wagtail/core/tests/test_migrations.py.
"""
from django.apps import apps
from django.db.migrations.autodetector import MigrationAutodetector
from django.db.migrations.loader import MigrationLoader
from django.db.migrations.questioner import MigrationQuestioner
from django.db.migrations.state import ProjectState
from django.test import TestCase
class TestForMigrations(TestCase):
def test__migrations(self):
app_labels = set(app.label for app in apps.get_app_configs() if app.name.startswith('tests.'))
for app_label in app_labels:
apps.get_app_config(app_label.split('.')[-1])
loader = MigrationLoader(None, ignore_no_migrations=True)
conflicts = dict(
(app_label, conflict)
for app_label, conflict in loader.detect_conflicts().items()
if app_label in app_labels
)
if conflicts:
name_str = "; ".join("%s in %s" % (", ".join(names), app) for app, names in conflicts.items())
self.fail("Conflicting migrations detected (%s)." % name_str)
autodetector = MigrationAutodetector(
loader.project_state(),
ProjectState.from_apps(apps),
MigrationQuestioner(specified_apps=app_labels, dry_run=True),
)
changes = autodetector.changes(
graph=loader.graph,
trim_to_apps=app_labels or None,
convert_apps=app_labels or None,
)
if changes:
migrations = '\n'.join((
' {migration}\n{changes}'.format(
migration=migration,
changes='\n'.join(' {0}'.format(operation.describe())
for operation in migration.operations))
for (_, migrations) in changes.items()
for migration in migrations))
self.fail('Model changes with no migrations detected:\n%s' % migrations)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
from django.contrib import admin
from django.urls import path
admin.autodiscover()
urlpatterns = [
path("admin/", admin.site.urls),
]