Navigation Menu

Skip to content

Commit

Permalink
Initial commit of topic hierarchy application.
Browse files Browse the repository at this point in the history
Still work in progress; adding features as I need them and have to implement.
For now, this is the start of breaking it out into a separate application.
  • Loading branch information
Malcolm Tredinnick committed Apr 5, 2009
0 parents commit 5293563
Show file tree
Hide file tree
Showing 8 changed files with 606 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
@@ -0,0 +1,2 @@
*.pyc

31 changes: 31 additions & 0 deletions LICENSE.txt
@@ -0,0 +1,31 @@
All the code in this package is licensed as follows. This is the standard "new
BSD" license (see http://www.opensource.org/licenses/bsd-license.php) and is
the same license that is used for Django itself.

--o----------o--

Copyright (c) 2009, Malcolm Tredinnick <malcolm@pointy-stick.com>
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* The name of Malcolm Tredinnick may not be used to endorse or promote
products derived from this software without specific prior written
permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
58 changes: 58 additions & 0 deletions README.txt
@@ -0,0 +1,58 @@
============================
Acacia -- Simple Topic Trees
============================

Acacia is a small Django application that provides hierarchical topic or
category naming. Other Django applications can then use the topic trees to
categorise articles or objects with human-readable names. For example::

root_1
root_1/child_1
root_1/child_2
root_2
root_2/child_1
root_2/child_1/grandchild_1
root_2/child_2

Each model instance in this application is one node in the hierarchy, defining
only the name for that node (which is the parent's name plus the node's
individual name). Further information could be added to the nodes through
subclassing.

Individual node names can be reused in multiple places in the tree, which is
where this system provides an advantage over tagging. It allows you to create
"debugging" nodes under both "software/" and "hardware/" and have them remain
distinct.

Admin Support
=============

The topic tree can be edited directly in the admin interface. Admin users can
create new node names and reparent existing nodes directly.

(Coming soon: nifty Javascript manipulation)

Dependencies
============

This code should run on Python 2.4 or later and Django 1.0.2 or later.

Acacia uses django-treebeard_ to provide the underlying tree implementation,
so that will need to be importable before you can use this code
(``django-treebeard`` doesn't require installation, so it only has to be on
the Python import path, not part of Django's ``INSTALLED_APPS`` setting).

.. _django-treebeard: http://code.google.com/p/django-treebeard/

More Documentation
==================

Further documentation, containing information about usage, extending the
nodes, using the hierarchy with multiple models, and providing easy use with
other applications in the admin application is coming soon. The current code
is an initial dump of what I've been working with.

Eager early adopters can probably work out most of the extensions themselves
in any case, using intermediate many-to-many tables and model inheritance in
appropriate places to provide extensions.

Empty file added acacia/__init__.py
Empty file.
113 changes: 113 additions & 0 deletions acacia/admin.py
@@ -0,0 +1,113 @@
from django import forms
from django.contrib import admin

import models

class TopicForm(forms.ModelForm):
"""
Instead of displaying the standard model fields in the admin, display the
name field and a synthesised "parent" field. The TopicAdmin class then
converts uses these fields to make the appropriate modifications to the
underlying Topic instances.
"""
parent = forms.ModelChoiceField(models.Topic.objects.all(), required=False,
empty_label="<root node>")

class Meta:
model = models.Topic
fields = ("name", "parent")

def __init__(self, *args, **kwargs):
"""
In addition to normal ModelForm initialisation, sets the initial value
of the "parent" form field to be correct for any passed in instance and
removes any descendants of the initial instance as possible parent
choices.
"""
super(TopicForm, self).__init__(*args, **kwargs)
if self.instance.pk is not None:
parent = self.instance.get_parent()
parent_field = self.fields["parent"]
parent_field.initial = parent and parent.pk or None
parent_field.queryset = models.Topic.objects.exclude(
path__startswith=self.instance.path)

def save_m2m(self):
"""
The admin expects to have indirectly called
TopicForm.save(commit=False) and will then call this method (it's
created as a result of commit=False). So it has to be stubbed out, but
we don't need to do anything here.
"""
pass


class TopicAdmin(admin.ModelAdmin):
# XXX: Duplicates the field definitions from the ModelForm, which should be
# unnecessary. Probably a Django admin bug (if I omit this, every field is
# shown in the admin form).
fields = ("name", "parent")
form = TopicForm

def save_form(self, request, form, change):
"""
Returns the updated and unsaved instance of the model being changed or
added. This instance isn't fully complete in our case, since attributes
are updated when it is saved (as part of the tree structure).
"""
name = form.cleaned_data["name"]
parent = form.cleaned_data.get("parent")
if change:
# Changing an existing instance.
instance = form.instance
instance.name = name
else:
# Adding a new instance.
instance = models.Topic(name=name)

# The full name is set to the *new* full_name, so that it appears
# correctly in the history logs, for example.
if parent:
instance.full_name = "%s%s%s" % (parent.full_name,
models.Topic.separator, name)
else:
instance.full_name = name
return instance

def save_model(self, request, instance, form, change):
"""
Add the new instance node to the tree at the appropriate point.
"""
parent = form.cleaned_data.get("parent")
if change:
# Changing an existing topic.
moved = False
if instance.depth != 1:
if not parent:
# Move a non-root node to the root.
root = models.Topic.get_first_root_node()
instance.move(root, "sorted-sibling")
moved = True
elif parent.id != instance.get_parent().id:
# Move a non-root node to a different, non-root, parent.
instance.move(parent, "sorted-child")
moved = True
else:
if parent:
# Move a root node to a lower point in the tree.
instance.move(parent, "sorted-child")
moved = True
if moved:
# Refresh from the database, as any moves will have changed the
# depth and path values.
instance = models.Topic.objects.get(pk=instance.pk)
instance.save()
else:
# Adding a new instance.
if not parent:
models.Topic.add_root(instance)
else:
parent.add_child(instance)

admin.site.register(models.Topic, TopicAdmin)

80 changes: 80 additions & 0 deletions acacia/models.py
@@ -0,0 +1,80 @@
"""
A hierarchical topics scheme.
The important piece here is the hierarchy. This is not tagging -- which tends
to be viewed as something flat by the kids these days.
"""

from django.db import models

import treebeard_mods


class TopicManager(models.Manager):
"""
Some useful methods that operate on topics as a whole. Mostly for locating
information about a Topic based on its full name.
"""
def get_by_full_name(self, full_name):
"""
Returns the topic with the given full name.
Raises Topic.DoesNotExist if there is no tag with 'full_name'.
"""
# Ensure foo//bar is the same as foo/bar. Nice to have.
sep = self.model.separator
pieces = [o for o in full_name.split(sep) if o]
normalised_path = sep.join(pieces)
return self.get(full_name=normalised_path)

def get_subtree(self, full_name):
"""
Returns a list containing the tag with the given full name and all tags
with this tag as an ancestor. The first item in the list will be the
tag with the passed in long name.
Raises Tag.DoesNotExist if there is no tag with 'long_name'.
"""
return self.model.get_tree(self.get_by_full_name(full_name))


class Topic(treebeard_mods.MP_Node):
"""
A node in a hierarchical storage tree, representing topics of some kind.
The name of the topic is the name of the parent, followed by the node's
name (with a configurable separator in between).
"""
name = models.CharField(max_length=50)
# Denormalise things a bit so that full name lookups are fast.
full_name = models.CharField(max_length=512, db_index=True)

node_order_by = ["name"]
separator = "/"

objects = TopicManager()

def __unicode__(self):
if not hasattr(self, "full_name") or not self.full_name:
self._set_full_name()
return self.full_name

@models.permalink
def get_absolute_url(self):
return ("topic", [unicode(self)])

def _set_full_name(self):
if self.depth == 1:
self.full_name = self.name
else:
parent = self.separator.join(
list(self.get_ancestors().values_list("name", flat=True)))
self.full_name = "%s%s%s" % (parent, self.separator, self.name)

def save(self, *args, **kwargs):
"""
Updates the full_name attribute prior to saving (incurs an extra lookup
query on each save, but saving is much less common than retrieval).
"""
self._set_full_name()
return super(Topic, self).save(*args, **kwargs)

0 comments on commit 5293563

Please sign in to comment.