Thursday, September 24, 2009

Suckerfish menus in Django

I have some data that I keep in a tree, and I wanted to see if people could browse it in a tree-like interface. All in Django, of course.

(Let me apologize for any errors in transcription below; this is my first time using blogger.)

I'm very wary of cross-browser incompatibilities. So any solution with a lot of Javascript, etc., leaves me worried that a year from now Safari will become incompatible and I'll be left racing to catch up. That'd be bad.

But suckerfish menus are based on CSS which is at least a fairly wide standard. I used a number of web sites as guides, but one that was especially handy was A List Apart.

Let me first cover what the data is. CTAN has perhaps four thousand packages. People should be able to browse them by some reasonable characterization: for instance, maybe "Document Styles > Books > Publisher Styles" would pop up a bunch of packages in that category.

I'd like to allow a number of different characterizations. For instance, Juergen Fenn has a good one that he uses in the TeX Catalogue and I have another. (Actually, my idea is to give each package a primary characterization and then a number of secondary characterizations, where the tree of primary characterizations is the same as the tree of secondary characterizations. For instance, a linguistics package may also contain a font of special symbols so the primary would be by subject area, but the secondary would be in fonts.)

I choose to store the characterization data using mptt (because at the time I was looking it seemed to be all that fit, but it has worked for me so I stuck with it). I call the different characterizations "dimensions" and so my models.py has this.

class Dimen(models.Model):
dimen=models.CharField(
max_length=32,
null=False,
blank=False,
primary_key=True,
help_text="What dimension is the package being characterized by?")
description=models.CharField(
max_length=72,
null=False,
blank=False,
db_index=False,
help_text="Description of this dimension.")

def __unicode__(self):
return self.dimen

class Meta:
pass


and also this.


class Characterizations(models.Model):
parent = models.ForeignKey('self', null=True, blank=True, related_name='children')
dimen=models.ForeignKey(
Dimen,
related_name='characterizations_fk_dimen',
null=True,
blank=False,
db_index=True,
help_text="Dimension of characterization")
title=models.CharField(
max_length=72,
null=False,
blank=False,
# unique=True,
db_index=True,
help_text="Title of this node (short)")
full_title=models.TextField( # denormalize for speed
null=False,
blank=False,
# unique=True,
db_index=True,
help_text="Title of this node, from the root")
description=models.CharField(
max_length=72*24,
null=False,
blank=False,
db_index=True,
help_text="Description of this node (a paragraph)")
path=models.CharField(
max_length=100,
null=True,
blank=True,
db_index=True,
help_text="Where full_title is a > b > c this is a/b/c"
)

def __unicode__(self):
return repr(unicode(self.full_title))

class Meta:
ordering=['id']


mptt.register(Characterizations)

(note that last line; it is not part of the class definition). Note the denormalization fields in the model class-- searching by url, for instance, is a help.

Finally, the packages are characterized in this way.

class Characterization(models.Model):
characterization=models.ForeignKey(
Characterizations,
related_name='characterization_fk_char_id',
null=False,
blank=False,
db_index=True,
help_text="Identifier of node in tree")
pkg_id=models.ForeignKey(
Package,
related_name='characterization_fk_pkg_id',
null=True,
blank=False,
db_index=True,
help_text="Identifier of package")

def __unicode__(self):
return self.pkg_id.pkg_id

class Meta:
pass

To populate the database, I can't put a .sql file in the sql/ directory of my project because mptt has fields that it uses for bookkeeping and I don't know what values it likes there. So I wrote a script characterizations.py to populate it. I have to import the relevant Django stuff


import django.core, django.core.management
import az.settings
import django.db
from django.db import models
from django import forms

and some stuff from my project

import az, az.pkgs
from az.pkgs.models import LicenseTypes, Languages, Package, Maintainers, PackageHistory, PackageText, License, OnCtan, OffCtan, RelatedPackages, Keywords, Keyword, Authors, Author, Distributions, Distribution, TexLiveTypes, TexLivePackages, TexLiveCatalogue, Upload, Dimen, Characterizations, Characterization
from az.util import util

and then I make use of a couple of add-a-node routines. The idea is to run code that does this (I'm showing just a small part).

def buildPrimaryByFunctionTree():
"""Build the primary by-function trees
"""
p=Dimen.objects.get(dimen__exact='primary')
# ------------------ Top -------------------------
pRoot=Characterizations.objects.create(
dimen=p,
title='',
full_title='',
description=p.description)
pRoot.parent=None
pRoot.save()
# ----------- Top > Document types -----------------
Document_types=addNode(pRoot,'Document types',
'Everything from books and articles to memos and letters. Also here are documents for specific publishers.')
# ----------- Top > Document types > Books -----------------
Books=addNode(Document_types,'Books',
'Books.')
Publisher_styles=addNode(Books,'Publisher styles',
'Styles for books from specific publishers.')
Others_books=addNode(Books,'Others',
'Other packages.')

The add-a-node routines were weird:

BRANCH_SEPARATOR=' > ' # separate parts of a branch; should not occur in titles
def getBranchTitle(n):
"""From the node, get the branch title, like 'Top > Fonts' (assumes
n.full_title is not available).
"""
# Like: return n.full_title but get it for setting in the dB
ancestorTitles=[a.title for a in n.get_ancestors(ascending=False)]
ancestorTitles.append(n.title)
branchTitle=BRANCH_SEPARATOR.join(ancestorTitles[1:])
return branchTitle

and

def getRootNode(dimen_str):
"""Get the root node of the tree with the given dimen
"""
nodeQ=Characterizations.objects.filter(dimen__exact=dimen_str)
if not(nodeQ):
return None
return nodeQ[0].get_root()

and

def getNodeFromBranchTitle(branchTitle,dimen_str):
"""From the branch title, like 'Top > Fonts', get the node.
""" n=Characterizations.objects.filter(dimen__dimen__exact=dimen_str).get(full_title__exact=branchTitle)
return n

were reasonable, but this one

def addNode(parentNode,title,description):
# I don't get this. I appear to have to fetch the parent node so that
# it is the most recent thing on mptt's mind. See mptt's doctests.py
# in the section marked "Creation"
pN=Characterizations.objects.get(
id=parentNode.id)
childNode=Characterizations(parent=pN,
dimen=pN.dimen,
title=title,
full_title=title,
description=description)
childNode.insert_at(pN,
position='first-child',
commit=False)
childNode.full_title=getBranchTitle(childNode)
childNode.path=util.path_components_to_url('',childNode.full_title.split(BRANCH_SEPARATOR),'')
childNode.save()
return childNode

was so strange that I thought I was doing it wrong. Anyway, it correctly populates the tree.

So now I have a tree. I need to offer the visitor the chance to pick a dimension, and then display the resulting tree using suckerfish menus.

In urls.py I put

urlpatterns += patterns('az.views',
(r'^characterization/choose_dimen/?$','characterization.choose_dimen'),
(r'^characterization/?(?P<pth>.*)$','characterization.main'),

and then I needed a views/characterization.py . I also needed to make a widget, a field, and a form.

The widget required some fiddling. I feed it choices that are pairs
(url_path, [list of tree node labels]) . It uses the length of the list in the second entry to decide if it needs to go to a child node, or if this is a sibling node, etc. Then it renders a simple html structure like this (based on the discussion from A List Apart; see above).

<ul id="sfish">
<li><a href="/characterization/primary/">pick one</a>
<ul><li><a href="/characterization/primary/"></a>
</li><li><a href="/characterization/primary/document-types/">Document types</a>
<ul><li><a href="/characterization/primary/document-types/books/">Books</a>
<ul><li><a href="/characterization/primary/document-types/books/publisher-styles/">Publisher styles</a></li>
<li><a href="/characterization/primary/document-types/books/others/">Others</a>
</li></ul>
</li><li><a href="/characterization/primary/document-types/articles/">Articles</a>

Here is the widget code.

class PickCharacterizationWidget(forms.RadioSelect):
def render(self,name,value,attrs=None,choices=()):
"""Display the tree in a form suitable for suckerfish menus
choices list of pairs (string, list of strings) where the first
is a link to the page you go to if you choose this, and
list of strings is a list like ['a','b','c'] to represent
'a > b > c'
"""
INDENT=" "*4
r=["<ul id="'sfish'">"]
prior_ft_list=[None]
for lnk,ft_list in self.choices:
# print "ft is ",ft
# r.append("\n++"+ft)
t,s=len(ft_list),len(prior_ft_list)
if t>s:
# r.append("")
r.append("\n"+INDENT*t+"<ul>")
elif t==s:
if prior_ft_list[-1]: # not very first entry
r.append("")
r.append("\n"+INDENT*t)
elif t<s:
for i in range(s-t):
r.append("\n"+INDENT*(s-i)+"</li></ul>")
r.append("\n"+INDENT*t)
r.append("</li><a href="http://www.blogger.com/%s">>" % (lnk,)+ft_list[-1]+"</a>")
prior_ft_list=ft_list
for i in range(len(prior_ft_list)-len(ft_list)):
if i>1:
r.append("</li></ul>")
else:
r.append("")
r.append("\n"+INDENT+"\n")
return "".join(r)

Note that this all needs CSS to work. Here is the relevant section of mine (it ain't beautiful).

/* See http://htmldog.com/articles/suckerfish/dropdowns/ */
#sfish, #sfish ul { /* all lists */
padding: 0;
margin: 0;
list-style: none;
float : left;
width : 11em;
}

#sfish li { /* all list items */
position : relative;
float : left;
line-height : 1.25em;
margin-bottom : -1px;
width: 11em;
}

#sfish li ul { /* second-level lists */
position : absolute;
left: -999em;
margin-left : 21.05em;
margin-top : -1.35em;
}

#sfish li ul ul { /* third-and-above-level lists */
left: -999em;
}

#sfish li a {
width: 20em;
w\idth : 20em;
display : block;
color : black;
font-weight : normal;
text-decoration : none;
background-color : white;
border : 1px solid black;
padding : 0 0.5em;
}

#sfish li a:hover {
color : black;
background-color : #bababa;
}

#sfish li:hover ul ul,
#sfish li:hover ul ul ul,
#sfish li.sfhover ul ul,
#sfish li.sfhover ul ul ul
#sfish li.sfhover ul ul ul ul
{
left: -999em;
}

#sfish li:hover ul,
#sfish li li:hover ul,
#sfish li li li:hover ul,
#sfish li li li li:hover ul,
#sfish li.sfhover ul,
#sfish li li.sfhover ul,
#sfish li li li.sfhover ul
#sfish li li li li.sfhover ul
{ /* lists nested under hovered list items */
left: auto;
}

#content {
margin-left : 12em;
}

There is also a little bit of Javascript.


sfHover = function() {
var sfEls = document.getElementById("nav").getElementsByTagName("LI");
for (var i=0; i<sfEls.length; i++) {
sfEls[i].onmouseover=function() {
this.className+=" sfhover";
}
sfEls[i].onmouseout=function() {
this.className=this.className.replace(new RegExp(" sfhover\\b"), "");
}
}
}
if (window.attachEvent) window.attachEvent("onload", sfHover);


I don't understand JavaScript so I can't vouch for it, but it came from A List Apart so I threw it in.

Here is the field that makes the choices.

from az.pkgs.data.characterizations import BRANCH_SEPARATOR
DEFAULT_CHARACTERIZATION_DIMENSION='primary'
class PickCharacterizationField(forms.ChoiceField):
"""The user picks one characterization to look at.
"""
def __init__(self, dimen=None, choices=(), required=True, widget=widgets.PickCharacterizationWidget, label=None, initial=None, help_text=None):
util.printFlush("forms.py PickCharacterizationField() dimen="+repr(dimen))
if dimen:
self.dimen=dimen
else:
try:
self.dimen=initial['dimen']
except:
self.dimen=DEFAULT_CHARACTERIZATION_DIMENSION
if not(choices):
choices=self.get_characterizations(self.dimen)
super(PickCharacterizationField, self).__init__(choices=choices, required=required, widget=widget, label=label, initial=initial, help_text=help_text)
self.widget.choices=self.choices

def get_characterizations(self,dimen,separator=BRANCH_SEPARATOR):
"""Return a list from a traversal of the tree:
(node (subnode1, (..)), (subnode2, ())..)
dimen string The Dimen.dimen field pointed to by this characterization
separator string How fields are separated in the full_title's
"""
try:
d=Dimen.objects.get(dimen__exact=dimen)
nodes=Characterizations.objects.filter(dimen=d)
except:
nodes=[]
node_list=[n.full_title.split(separator) for n in nodes] choices=[(util.path_components_to_url(u'/characterization/'+dimen+u'/',ft_list,u'/'),ft_list) for ft_list in node_list]
choices=[(x,['pick one']+y) for x,y in choices]
choices=[(u'/characterization/'+dimen+u'/',['pick one'])]+choices
return choices

Now came the main question: how to get the choices into the widget? I had to ask on the Django Users list and a nice person there helped me out (thanks to you, Daniel Roseman!). I made a form where the initialization function reaches out and touches the field.

class PickCharacterizationForm(forms.Form):
def __init__(self,*args,**kwargs):
dimen=kwargs.pop('dimen',None)
super(PickCharacterizationForm,self).__init__(*args,**kwargs)
pick_field=self.fields['pick']
if dimen:
pick_field.dimen = dimen
pick_field.choices=pick_field.get_characterizations(dimen)

pick=PickCharacterizationField(required=True,help_text='Select a characterization',widget=widgets.PickCharacterizationWidget)

class Media:
js=('js/sfish/sfhover.js',)

And it works! I have two routines in characterization.py
that either search for the list of dimensions.
def choose_dimen(req,tmplt=CHARACTERIZATION_TEMPLATE):
"""Allow a person to choose a dimension for the characterization
dimen string Default choice
"""
pageis=PageNameMap(PAGES,initialPageName='choose_dimen')
dI=Dimen.objects.all()
dimen_triples=[(d.dimen,d.dimen.capitalize(),d.description) for d in dI]
d={'pageis':pageis,
'dimen_triples':dimen_triples,
'breadcrumbs':breadcrumbs(None,None),
}
ctext=Context(d)
return render_to_response(tmplt,ctext)

or else invoke the form.

def main(req,pth=None,tmplt=CHARACTERIZATION_TEMPLATE):
pageis=PageNameMap(PAGES,initialPageName='main')
d={'pageis':pageis,
'breadcrumbs':breadcrumbs(None,None),
}
ctext=Context(d)
parts=pth.split('/')
try:
dimen=parts[0].strip()
cdr=parts[1:]
except:
dimen,cdr=None,None
if not(dimen):
return HttpResponseRedirect('/characterization/choose_dimen/')
try:
dI=Dimen.objects.get(dimen__exact=dimen)
dimen_description=dI.description
except Exception, err:
dimen=re.sub('[a-zA-Z0-9_\-]','',dimen) # safe for display on page
dimen_description=None
if not(dI):
return HttpResponseRedirect('/characterization/choose_dimen/')
f0=PickCharacterizationForm(dimen=dimen)
forms=[f0]
# See which packages are characterized in this way
pkg_list=[]
if cdr:
cQ=Characterization.objects.filter(characterization__path__exact='/'.join(cdr))
for cI in cQ:
pkg_list.append(cI.pkg_id.pkg_id)
# Cash out
ctext['forms']=forms
ctext['dimen']=dimen
ctext['dimen_description']=dI.description
ctext['full_title']=' > '.join(cdr)
ctext['pkg_list']=pkg_list
# Finish
return render_to_response(tmplt,ctext)

It is all still rough, and I haven't said what some of the routines do (such as PageNameMap), but perhaps this might help someone who is looking for a place to start with suckerfish and Django.

In the end, though, my menus proved to be too long for the page. The slide off the bottom of the page and so are very hard to use. Oh well.





Link