Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial commit of topic hierarchy application.
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
Showing
8 changed files
with
606 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
*.pyc | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
|
Oops, something went wrong.