Code

. #517906 ] Attribute order in "View customisation"
[roundup.git] / roundup / htmltemplate.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 # $Id: htmltemplate.py,v 1.75 2002-02-16 08:43:23 richard Exp $
20 __doc__ = """
21 Template engine.
22 """
24 import os, re, StringIO, urllib, cgi, errno, types
26 import hyperdb, date, password
27 from i18n import _
29 # This imports the StructureText functionality for the do_stext function
30 # get it from http://dev.zope.org/Members/jim/StructuredTextWiki/NGReleases
31 try:
32     from StructuredText.StructuredText import HTML as StructuredText
33 except ImportError:
34     StructuredText = None
36 class TemplateFunctions:
37     def __init__(self):
38         self.form = None
39         self.nodeid = None
40         self.filterspec = None
41         self.globals = {}
42         for key in TemplateFunctions.__dict__.keys():
43             if key[:3] == 'do_':
44                 self.globals[key[3:]] = getattr(self, key)
46     def do_plain(self, property, escape=0):
47         ''' display a String property directly;
49             display a Date property in a specified time zone with an option to
50             omit the time from the date stamp;
52             for a Link or Multilink property, display the key strings of the
53             linked nodes (or the ids if the linked class has no key property)
54         '''
55         if not self.nodeid and self.form is None:
56             return _('[Field: not called from item]')
57         propclass = self.properties[property]
58         if self.nodeid:
59             # make sure the property is a valid one
60             # TODO: this tests, but we should handle the exception
61             prop_test = self.cl.getprops()[property]
63             # get the value for this property
64             try:
65                 value = self.cl.get(self.nodeid, property)
66             except KeyError:
67                 # a KeyError here means that the node doesn't have a value
68                 # for the specified property
69                 if isinstance(propclass, hyperdb.Multilink): value = []
70                 else: value = ''
71         else:
72             # TODO: pull the value from the form
73             if isinstance(propclass, hyperdb.Multilink): value = []
74             else: value = ''
75         if isinstance(propclass, hyperdb.String):
76             if value is None: value = ''
77             else: value = str(value)
78         elif isinstance(propclass, hyperdb.Password):
79             if value is None: value = ''
80             else: value = _('*encrypted*')
81         elif isinstance(propclass, hyperdb.Date):
82             # this gives "2002-01-17.06:54:39", maybe replace the "." by a " ".
83             value = str(value)
84         elif isinstance(propclass, hyperdb.Interval):
85             value = str(value)
86         elif isinstance(propclass, hyperdb.Link):
87             linkcl = self.db.classes[propclass.classname]
88             k = linkcl.labelprop()
89             if value:
90                 value = linkcl.get(value, k)
91             else:
92                 value = _('[unselected]')
93         elif isinstance(propclass, hyperdb.Multilink):
94             linkcl = self.db.classes[propclass.classname]
95             k = linkcl.labelprop()
96             value = ', '.join(value)
97         else:
98             s = _('Plain: bad propclass "%(propclass)s"')%locals()
99         if escape:
100             value = cgi.escape(value)
101         return value
103     def do_stext(self, property, escape=0):
104         '''Render as structured text using the StructuredText module
105            (see above for details)
106         '''
107         s = self.do_plain(property, escape=escape)
108         if not StructuredText:
109             return s
110         return StructuredText(s,level=1,header=0)
112     def determine_value(self, property):
113         '''determine the value of a property using the node, form or
114            filterspec
115         '''
116         propclass = self.properties[property]
117         if self.nodeid:
118             value = self.cl.get(self.nodeid, property, None)
119             if isinstance(propclass, hyperdb.Multilink) and value is None:
120                 return []
121             return value
122         elif self.filterspec is not None:
123             if isinstance(propclass, hyperdb.Multilink):
124                 return self.filterspec.get(property, [])
125             else:
126                 return self.filterspec.get(property, '')
127         # TODO: pull the value from the form
128         if isinstance(propclass, hyperdb.Multilink):
129             return []
130         else:
131             return ''
133     def make_sort_function(self, classname):
134         '''Make a sort function for a given class
135         '''
136         linkcl = self.db.classes[classname]
137         if linkcl.getprops().has_key('order'):
138             sort_on = 'order'
139         else:
140             sort_on = linkcl.labelprop()
141         def sortfunc(a, b, linkcl=linkcl, sort_on=sort_on):
142             return cmp(linkcl.get(a, sort_on), linkcl.get(b, sort_on))
143         return sortfunc
145     def do_field(self, property, size=None, showid=0):
146         ''' display a property like the plain displayer, but in a text field
147             to be edited
149             Note: if you would prefer an option list style display for
150             link or multilink editing, use menu().
151         '''
152         if not self.nodeid and self.form is None and self.filterspec is None:
153             return _('[Field: not called from item]')
155         if size is None:
156             size = 30
158         propclass = self.properties[property]
160         # get the value
161         value = self.determine_value(property)
163         # now display
164         if (isinstance(propclass, hyperdb.String) or
165                 isinstance(propclass, hyperdb.Date) or
166                 isinstance(propclass, hyperdb.Interval)):
167             if value is None:
168                 value = ''
169             else:
170                 value = cgi.escape(str(value))
171                 value = '"'.join(value.split('"'))
172             s = '<input name="%s" value="%s" size="%s">'%(property, value, size)
173         elif isinstance(propclass, hyperdb.Password):
174             s = '<input type="password" name="%s" size="%s">'%(property, size)
175         elif isinstance(propclass, hyperdb.Link):
176             sortfunc = self.make_sort_function(propclass.classname)
177             linkcl = self.db.classes[propclass.classname]
178             options = linkcl.list()
179             options.sort(sortfunc)
180             # TODO: make this a field display, not a menu one!
181             l = ['<select name="%s">'%property]
182             k = linkcl.labelprop()
183             if value is None:
184                 s = 'selected '
185             else:
186                 s = ''
187             l.append(_('<option %svalue="-1">- no selection -</option>')%s)
188             for optionid in options:
189                 option = linkcl.get(optionid, k)
190                 s = ''
191                 if optionid == value:
192                     s = 'selected '
193                 if showid:
194                     lab = '%s%s: %s'%(propclass.classname, optionid, option)
195                 else:
196                     lab = option
197                 if size is not None and len(lab) > size:
198                     lab = lab[:size-3] + '...'
199                 lab = cgi.escape(lab)
200                 l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
201             l.append('</select>')
202             s = '\n'.join(l)
203         elif isinstance(propclass, hyperdb.Multilink):
204             sortfunc = self.make_sort_function(propclass.classname)
205             linkcl = self.db.classes[propclass.classname]
206             list = linkcl.list()
207             list.sort(sortfunc)
208             l = []
209             # map the id to the label property
210             if not showid:
211                 k = linkcl.labelprop()
212                 value = [linkcl.get(v, k) for v in value]
213             value = cgi.escape(','.join(value))
214             s = '<input name="%s" size="%s" value="%s">'%(property, size, value)
215         else:
216             s = _('Plain: bad propclass "%(propclass)s"')%locals()
217         return s
219     def do_multiline(self, property, rows=5, cols=40):
220         ''' display a string property in a multiline text edit field
221         '''
222         if not self.nodeid and self.form is None and self.filterspec is None:
223             return _('[Multiline: not called from item]')
225         propclass = self.properties[property]
227         # make sure this is a link property
228         if not isinstance(propclass, hyperdb.String):
229             return _('[Multiline: not a string]')
231         # get the value
232         value = self.determine_value(property)
233         if value is None:
234             value = ''
236         # display
237         return '<textarea name="%s" rows="%s" cols="%s">%s</textarea>'%(
238             property, rows, cols, value)
240     def do_menu(self, property, size=None, height=None, showid=0):
241         ''' for a Link property, display a menu of the available choices
242         '''
243         if not self.nodeid and self.form is None and self.filterspec is None:
244             return _('[Field: not called from item]')
246         propclass = self.properties[property]
248         # make sure this is a link property
249         if not (isinstance(propclass, hyperdb.Link) or
250                 isinstance(propclass, hyperdb.Multilink)):
251             return _('[Menu: not a link]')
253         # sort function
254         sortfunc = self.make_sort_function(propclass.classname)
256         # get the value
257         value = self.determine_value(property)
259         # display
260         if isinstance(propclass, hyperdb.Multilink):
261             linkcl = self.db.classes[propclass.classname]
262             options = linkcl.list()
263             options.sort(sortfunc)
264             height = height or min(len(options), 7)
265             l = ['<select multiple name="%s" size="%s">'%(property, height)]
266             k = linkcl.labelprop()
267             for optionid in options:
268                 option = linkcl.get(optionid, k)
269                 s = ''
270                 if optionid in value:
271                     s = 'selected '
272                 if showid:
273                     lab = '%s%s: %s'%(propclass.classname, optionid, option)
274                 else:
275                     lab = option
276                 if size is not None and len(lab) > size:
277                     lab = lab[:size-3] + '...'
278                 lab = cgi.escape(lab)
279                 l.append('<option %svalue="%s">%s</option>'%(s, optionid,
280                     lab))
281             l.append('</select>')
282             return '\n'.join(l)
283         if isinstance(propclass, hyperdb.Link):
284             # force the value to be a single choice
285             if type(value) is types.ListType:
286                 value = value[0]
287             linkcl = self.db.classes[propclass.classname]
288             l = ['<select name="%s">'%property]
289             k = linkcl.labelprop()
290             s = ''
291             if value is None:
292                 s = 'selected '
293             l.append(_('<option %svalue="-1">- no selection -</option>')%s)
294             options = linkcl.list()
295             options.sort(sortfunc)
296             for optionid in options:
297                 option = linkcl.get(optionid, k)
298                 s = ''
299                 if optionid == value:
300                     s = 'selected '
301                 if showid:
302                     lab = '%s%s: %s'%(propclass.classname, optionid, option)
303                 else:
304                     lab = option
305                 if size is not None and len(lab) > size:
306                     lab = lab[:size-3] + '...'
307                 lab = cgi.escape(lab)
308                 l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
309             l.append('</select>')
310             return '\n'.join(l)
311         return _('[Menu: not a link]')
313     #XXX deviates from spec
314     def do_link(self, property=None, is_download=0):
315         '''For a Link or Multilink property, display the names of the linked
316            nodes, hyperlinked to the item views on those nodes.
317            For other properties, link to this node with the property as the
318            text.
320            If is_download is true, append the property value to the generated
321            URL so that the link may be used as a download link and the
322            downloaded file name is correct.
323         '''
324         if not self.nodeid and self.form is None:
325             return _('[Link: not called from item]')
327         # get the value
328         value = self.determine_value(property)
329         if not value:
330             return _('[no %(propname)s]')%{'propname':property.capitalize()}
332         propclass = self.properties[property]
333         if isinstance(propclass, hyperdb.Link):
334             linkname = propclass.classname
335             linkcl = self.db.classes[linkname]
336             k = linkcl.labelprop()
337             linkvalue = cgi.escape(linkcl.get(value, k))
338             if is_download:
339                 return '<a href="%s%s/%s">%s</a>'%(linkname, value,
340                     linkvalue, linkvalue)
341             else:
342                 return '<a href="%s%s">%s</a>'%(linkname, value, linkvalue)
343         if isinstance(propclass, hyperdb.Multilink):
344             linkname = propclass.classname
345             linkcl = self.db.classes[linkname]
346             k = linkcl.labelprop()
347             l = []
348             for value in value:
349                 linkvalue = cgi.escape(linkcl.get(value, k))
350                 if is_download:
351                     l.append('<a href="%s%s/%s">%s</a>'%(linkname, value,
352                         linkvalue, linkvalue))
353                 else:
354                     l.append('<a href="%s%s">%s</a>'%(linkname, value,
355                         linkvalue))
356             return ', '.join(l)
357         if is_download:
358             return '<a href="%s%s/%s">%s</a>'%(self.classname, self.nodeid,
359                 value, value)
360         else:
361             return '<a href="%s%s">%s</a>'%(self.classname, self.nodeid, value)
363     def do_count(self, property, **args):
364         ''' for a Multilink property, display a count of the number of links in
365             the list
366         '''
367         if not self.nodeid:
368             return _('[Count: not called from item]')
370         propclass = self.properties[property]
371         if not isinstance(propclass, hyperdb.Multilink):
372             return _('[Count: not a Multilink]')
374         # figure the length then...
375         value = self.cl.get(self.nodeid, property)
376         return str(len(value))
378     # XXX pretty is definitely new ;)
379     def do_reldate(self, property, pretty=0):
380         ''' display a Date property in terms of an interval relative to the
381             current date (e.g. "+ 3w", "- 2d").
383             with the 'pretty' flag, make it pretty
384         '''
385         if not self.nodeid and self.form is None:
386             return _('[Reldate: not called from item]')
388         propclass = self.properties[property]
389         if not isinstance(propclass, hyperdb.Date):
390             return _('[Reldate: not a Date]')
392         if self.nodeid:
393             value = self.cl.get(self.nodeid, property)
394         else:
395             return ''
396         if not value:
397             return ''
399         # figure the interval
400         interval = value - date.Date('.')
401         if pretty:
402             if not self.nodeid:
403                 return _('now')
404             pretty = interval.pretty()
405             if pretty is None:
406                 pretty = value.pretty()
407             return pretty
408         return str(interval)
410     def do_download(self, property, **args):
411         ''' show a Link("file") or Multilink("file") property using links that
412             allow you to download files
413         '''
414         if not self.nodeid:
415             return _('[Download: not called from item]')
416         return self.do_link(property, is_download=1)
419     def do_checklist(self, property, **args):
420         ''' for a Link or Multilink property, display checkboxes for the
421             available choices to permit filtering
422         '''
423         propclass = self.properties[property]
424         if (not isinstance(propclass, hyperdb.Link) and not
425                 isinstance(propclass, hyperdb.Multilink)):
426             return _('[Checklist: not a link]')
428         # get our current checkbox state
429         if self.nodeid:
430             # get the info from the node - make sure it's a list
431             if isinstance(propclass, hyperdb.Link):
432                 value = [self.cl.get(self.nodeid, property)]
433             else:
434                 value = self.cl.get(self.nodeid, property)
435         elif self.filterspec is not None:
436             # get the state from the filter specification (always a list)
437             value = self.filterspec.get(property, [])
438         else:
439             # it's a new node, so there's no state
440             value = []
442         # so we can map to the linked node's "lable" property
443         linkcl = self.db.classes[propclass.classname]
444         l = []
445         k = linkcl.labelprop()
446         for optionid in linkcl.list():
447             option = cgi.escape(linkcl.get(optionid, k))
448             if optionid in value or option in value:
449                 checked = 'checked'
450             else:
451                 checked = ''
452             l.append('%s:<input type="checkbox" %s name="%s" value="%s">'%(
453                 option, checked, property, option))
455         # for Links, allow the "unselected" option too
456         if isinstance(propclass, hyperdb.Link):
457             if value is None or '-1' in value:
458                 checked = 'checked'
459             else:
460                 checked = ''
461             l.append(_('[unselected]:<input type="checkbox" %s name="%s" '
462                 'value="-1">')%(checked, property))
463         return '\n'.join(l)
465     def do_note(self, rows=5, cols=80):
466         ''' display a "note" field, which is a text area for entering a note to
467             go along with a change. 
468         '''
469         # TODO: pull the value from the form
470         return '<textarea name="__note" wrap="hard" rows=%s cols=%s>'\
471             '</textarea>'%(rows, cols)
473     # XXX new function
474     def do_list(self, property, reverse=0):
475         ''' list the items specified by property using the standard index for
476             the class
477         '''
478         propcl = self.properties[property]
479         if not isinstance(propcl, hyperdb.Multilink):
480             return _('[List: not a Multilink]')
482         value = self.determine_value(property)
483         if not value:
484             return ''
486         # sort, possibly revers and then re-stringify
487         value = map(int, value)
488         value.sort()
489         if reverse:
490             value.reverse()
491         value = map(str, value)
493         # render the sub-index into a string
494         fp = StringIO.StringIO()
495         try:
496             write_save = self.client.write
497             self.client.write = fp.write
498             index = IndexTemplate(self.client, self.templates, propcl.classname)
499             index.render(nodeids=value, show_display_form=0)
500         finally:
501             self.client.write = write_save
503         return fp.getvalue()
505     # XXX new function
506     def do_history(self, direction='descending'):
507         ''' list the history of the item
509             If "direction" is 'descending' then the most recent event will
510             be displayed first. If it is 'ascending' then the oldest event
511             will be displayed first.
512         '''
513         if self.nodeid is None:
514             return _("[History: node doesn't exist]")
516         l = ['<table width=100% border=0 cellspacing=0 cellpadding=2>',
517             '<tr class="list-header">',
518             _('<th align=left><span class="list-item">Date</span></th>'),
519             _('<th align=left><span class="list-item">User</span></th>'),
520             _('<th align=left><span class="list-item">Action</span></th>'),
521             _('<th align=left><span class="list-item">Args</span></th>'),
522             '</tr>']
524         comments = {}
525         history = self.cl.history(self.nodeid)
526         history.sort()
527         if direction == 'descending':
528             history.reverse()
529         for id, evt_date, user, action, args in history:
530             date_s = str(evt_date).replace("."," ")
531             arg_s = ''
532             if action == 'link' and type(args) == type(()):
533                 if len(args) == 3:
534                     linkcl, linkid, key = args
535                     arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
536                         linkcl, linkid, key)
537                 else:
538                     arg_s = str(arg)
540             elif action == 'unlink' and type(args) == type(()):
541                 if len(args) == 3:
542                     linkcl, linkid, key = args
543                     arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
544                         linkcl, linkid, key)
545                 else:
546                     arg_s = str(arg)
548             elif type(args) == type({}):
549                 cell = []
550                 for k in args.keys():
551                     # try to get the relevant property and treat it
552                     # specially
553                     try:
554                         prop = self.properties[k]
555                     except:
556                         prop = None
557                     if prop is not None:
558                         if args[k] and (isinstance(prop, hyperdb.Multilink) or
559                                 isinstance(prop, hyperdb.Link)):
560                             # figure what the link class is
561                             classname = prop.classname
562                             try:
563                                 linkcl = self.db.classes[classname]
564                             except KeyError, message:
565                                 labelprop = None
566                                 comments[classname] = _('''The linked class
567                                     %(classname)s no longer exists''')%locals()
568                             labelprop = linkcl.labelprop()
570                         if isinstance(prop, hyperdb.Multilink) and \
571                                 len(args[k]) > 0:
572                             ml = []
573                             for linkid in args[k]:
574                                 label = classname + linkid
575                                 # if we have a label property, try to use it
576                                 # TODO: test for node existence even when
577                                 # there's no labelprop!
578                                 try:
579                                     if labelprop is not None:
580                                         label = linkcl.get(linkid, labelprop)
581                                 except IndexError:
582                                     comments['no_link'] = _('''<strike>The
583                                         linked node no longer
584                                         exists</strike>''')
585                                     ml.append('<strike>%s</strike>'%label)
586                                 else:
587                                     ml.append('<a href="%s%s">%s</a>'%(
588                                         classname, linkid, label))
589                             cell.append('%s:\n  %s'%(k, ',\n  '.join(ml)))
590                         elif isinstance(prop, hyperdb.Link) and args[k]:
591                             label = classname + args[k]
592                             # if we have a label property, try to use it
593                             # TODO: test for node existence even when
594                             # there's no labelprop!
595                             if labelprop is not None:
596                                 try:
597                                     label = linkcl.get(args[k], labelprop)
598                                 except IndexError:
599                                     comments['no_link'] = _('''<strike>The
600                                         linked node no longer
601                                         exists</strike>''')
602                                     cell.append(' <strike>%s</strike>,\n'%label)
603                                     # "flag" this is done .... euwww
604                                     label = None
605                             if label is not None:
606                                 cell.append('%s: <a href="%s%s">%s</a>\n'%(k,
607                                     classname, args[k], label))
609                         elif isinstance(prop, hyperdb.Date) and args[k]:
610                             d = date.Date(args[k])
611                             cell.append('%s: %s'%(k, str(d)))
613                         elif isinstance(prop, hyperdb.Interval) and args[k]:
614                             d = date.Interval(args[k])
615                             cell.append('%s: %s'%(k, str(d)))
617                         elif not args[k]:
618                             cell.append('%s: (no value)\n'%k)
620                         else:
621                             cell.append('%s: %s\n'%(k, str(args[k])))
622                     else:
623                         # property no longer exists
624                         comments['no_exist'] = _('''<em>The indicated property
625                             no longer exists</em>''')
626                         cell.append('<em>%s: %s</em>\n'%(k, str(args[k])))
627                 arg_s = '<br />'.join(cell)
628             else:
629                 # unkown event!!
630                 comments['unknown'] = _('''<strong><em>This event is not
631                     handled by the history display!</em></strong>''')
632                 arg_s = '<strong><em>' + str(args) + '</em></strong>'
633             date_s = date_s.replace(' ', '&nbsp;')
634             l.append('<tr><td nowrap valign=top>%s</td><td valign=top>%s</td>'
635                 '<td valign=top>%s</td><td valign=top>%s</td></tr>'%(date_s,
636                 user, action, arg_s))
637         if comments:
638             l.append(_('<tr><td colspan=4><strong>Note:</strong></td></tr>'))
639         for entry in comments.values():
640             l.append('<tr><td colspan=4>%s</td></tr>'%entry)
641         l.append('</table>')
642         return '\n'.join(l)
644     # XXX new function
645     def do_submit(self):
646         ''' add a submit button for the item
647         '''
648         if self.nodeid:
649             return _('<input type="submit" name="submit" value="Submit Changes">')
650         elif self.form is not None:
651             return _('<input type="submit" name="submit" value="Submit New Entry">')
652         else:
653             return _('[Submit: not called from item]')
657 #   INDEX TEMPLATES
659 class IndexTemplateReplace:
660     def __init__(self, globals, locals, props):
661         self.globals = globals
662         self.locals = locals
663         self.props = props
665     replace=re.compile(
666         r'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
667         r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)
668     def go(self, text):
669         return self.replace.sub(self, text)
671     def __call__(self, m, filter=None, columns=None, sort=None, group=None):
672         if m.group('name'):
673             if m.group('name') in self.props:
674                 text = m.group('text')
675                 replace = IndexTemplateReplace(self.globals, {}, self.props)
676                 return replace.go(m.group('text'))
677             else:
678                 return ''
679         if m.group('display'):
680             command = m.group('command')
681             return eval(command, self.globals, self.locals)
682         print '*** unhandled match', m.groupdict()
684 class IndexTemplate(TemplateFunctions):
685     def __init__(self, client, templates, classname):
686         self.client = client
687         self.instance = client.instance
688         self.templates = templates
689         self.classname = classname
691         # derived
692         self.db = self.client.db
693         self.cl = self.db.classes[self.classname]
694         self.properties = self.cl.getprops()
696         TemplateFunctions.__init__(self)
698     col_re=re.compile(r'<property\s+name="([^>]+)">')
699     def render(self, filterspec={}, filter=[], columns=[], sort=[], group=[],
700             show_display_form=1, nodeids=None, show_customization=1):
701         self.filterspec = filterspec
703         w = self.client.write
705         # get the filter template
706         try:
707             filter_template = open(os.path.join(self.templates,
708                 self.classname+'.filter')).read()
709             all_filters = self.col_re.findall(filter_template)
710         except IOError, error:
711             if error.errno not in (errno.ENOENT, errno.ESRCH): raise
712             filter_template = None
713             all_filters = []
715         # XXX deviate from spec here ...
716         # load the index section template and figure the default columns from it
717         template = open(os.path.join(self.templates,
718             self.classname+'.index')).read()
719         all_columns = self.col_re.findall(template)
720         if not columns:
721             columns = []
722             for name in all_columns:
723                 columns.append(name)
724         else:
725             # re-sort columns to be the same order as all_columns
726             l = []
727             for name in all_columns:
728                 if name in columns:
729                     l.append(name)
730             columns = l
732         # display the filter section
733         if (show_display_form and 
734                 self.instance.FILTER_POSITION in ('top and bottom', 'top')):
735             w('<form onSubmit="return submit_once()" action="%s">\n'%self.classname)
736             self.filter_section(filter_template, filter, columns, group,
737                 all_filters, all_columns, show_customization)
738             # make sure that the sorting doesn't get lost either
739             if sort:
740                 w('<input type="hidden" name=":sort" value="%s">'%
741                     ','.join(sort))
742             w('</form>\n')
745         # now display the index section
746         w('<table width=100% border=0 cellspacing=0 cellpadding=2>\n')
747         w('<tr class="list-header">\n')
748         for name in columns:
749             cname = name.capitalize()
750             if show_display_form:
751                 sb = self.sortby(name, filterspec, columns, filter, group, sort)
752                 anchor = "%s?%s"%(self.classname, sb)
753                 w('<td><span class="list-header"><a href="%s">%s</a></span></td>\n'%(
754                     anchor, cname))
755             else:
756                 w('<td><span class="list-header">%s</span></td>\n'%cname)
757         w('</tr>\n')
759         # this stuff is used for group headings - optimise the group names
760         old_group = None
761         group_names = []
762         if group:
763             for name in group:
764                 if name[0] == '-': group_names.append(name[1:])
765                 else: group_names.append(name)
767         # now actually loop through all the nodes we get from the filter and
768         # apply the template
769         if nodeids is None:
770             nodeids = self.cl.filter(filterspec, sort, group)
771         for nodeid in nodeids:
772             # check for a group heading
773             if group_names:
774                 this_group = [self.cl.get(nodeid, name, _('[no value]'))
775                     for name in group_names]
776                 if this_group != old_group:
777                     l = []
778                     for name in group_names:
779                         prop = self.properties[name]
780                         if isinstance(prop, hyperdb.Link):
781                             group_cl = self.db.classes[prop.classname]
782                             key = group_cl.getkey()
783                             value = self.cl.get(nodeid, name)
784                             if value is None:
785                                 l.append(_('[unselected %(classname)s]')%{
786                                     'classname': prop.classname})
787                             else:
788                                 l.append(group_cl.get(self.cl.get(nodeid,
789                                     name), key))
790                         elif isinstance(prop, hyperdb.Multilink):
791                             group_cl = self.db.classes[prop.classname]
792                             key = group_cl.getkey()
793                             for value in self.cl.get(nodeid, name):
794                                 l.append(group_cl.get(value, key))
795                         else:
796                             value = self.cl.get(nodeid, name, _('[no value]'))
797                             if value is None:
798                                 value = _('[empty %(name)s]')%locals()
799                             else:
800                                 value = str(value)
801                             l.append(value)
802                     w('<tr class="section-bar">'
803                       '<td align=middle colspan=%s><strong>%s</strong></td></tr>'%(
804                         len(columns), ', '.join(l)))
805                     old_group = this_group
807             # display this node's row
808             replace = IndexTemplateReplace(self.globals, locals(), columns)
809             self.nodeid = nodeid
810             w(replace.go(template))
811             self.nodeid = None
813         w('</table>')
815         # display the filter section
816         if (show_display_form and hasattr(self.instance, 'FILTER_POSITION') and
817                 self.instance.FILTER_POSITION in ('top and bottom', 'bottom')):
818             w('<form onSubmit="return submit_once()" action="%s">\n'%self.classname)
819             self.filter_section(filter_template, filter, columns, group,
820                 all_filters, all_columns, show_customization)
821             # make sure that the sorting doesn't get lost either
822             if sort:
823                 w('<input type="hidden" name=":sort" value="%s">'%
824                     ','.join(sort))
825             w('</form>\n')
828     def filter_section(self, template, filter, columns, group, all_filters,
829             all_columns, show_customization):
831         w = self.client.write
833         # wrap the template in a single table to ensure the whole widget
834         # is displayed at once
835         w('<table><tr><td>')
837         if template and filter:
838             # display the filter section
839             w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
840             w('<tr class="location-bar">')
841             w(_(' <th align="left" colspan="2">Filter specification...</th>'))
842             w('</tr>')
843             replace = IndexTemplateReplace(self.globals, locals(), filter)
844             w(replace.go(template))
845             w('<tr class="location-bar"><td width="1%%">&nbsp;</td>')
846             w(_('<td><input type="submit" name="action" value="Redisplay"></td></tr>'))
847             w('</table>')
849         # now add in the filter/columns/group/etc config table form
850         w('<input type="hidden" name="show_customization" value="%s">' %
851             show_customization )
852         w('<table width=100% border=0 cellspacing=0 cellpadding=2>\n')
853         names = []
854         seen = []
855         for name in all_filters + all_columns:
856             if self.properties.has_key(name) and not seen.has_key(name):
857                 names.append(name)
858             seen[name] = 1
859         if show_customization:
860             action = '-'
861         else:
862             action = '+'
863             # hide the values for filters, columns and grouping in the form
864             # if the customization widget is not visible
865             for name in names:
866                 if all_filters and name in filter:
867                     w('<input type="hidden" name=":filter" value="%s">' % name)
868                 if all_columns and name in columns:
869                     w('<input type="hidden" name=":columns" value="%s">' % name)
870                 if all_columns and name in group:
871                     w('<input type="hidden" name=":group" value="%s">' % name)
873         # TODO: The widget style can go into the stylesheet
874         w(_('<th align="left" colspan=%s>'
875           '<input style="height : 1em; width : 1em; font-size: 12pt" type="submit" name="action" value="%s">&nbsp;View '
876           'customisation...</th></tr>\n')%(len(names)+1, action))
878         if not show_customization:
879             w('</table>\n')
880             return
882         w('<tr class="location-bar"><th>&nbsp;</th>')
883         for name in names:
884             w('<th>%s</th>'%name.capitalize())
885         w('</tr>\n')
887         # Filter
888         if all_filters:
889             w(_('<tr><th width="1%" align=right class="location-bar">Filters</th>\n'))
890             for name in names:
891                 if name not in all_filters:
892                     w('<td>&nbsp;</td>')
893                     continue
894                 if name in filter: checked=' checked'
895                 else: checked=''
896                 w('<td align=middle>\n')
897                 w(' <input type="checkbox" name=":filter" value="%s" '
898                   '%s></td>\n'%(name, checked))
899             w('</tr>\n')
901         # Columns
902         if all_columns:
903             w(_('<tr><th width="1%" align=right class="location-bar">Columns</th>\n'))
904             for name in names:
905                 if name not in all_columns:
906                     w('<td>&nbsp;</td>')
907                     continue
908                 if name in columns: checked=' checked'
909                 else: checked=''
910                 w('<td align=middle>\n')
911                 w(' <input type="checkbox" name=":columns" value="%s"'
912                   '%s></td>\n'%(name, checked))
913             w('</tr>\n')
915             # Grouping
916             w(_('<tr><th width="1%" align=right class="location-bar">Grouping</th>\n'))
917             for name in names:
918                 prop = self.properties[name]
919                 if name not in all_columns:
920                     w('<td>&nbsp;</td>')
921                     continue
922                 if name in group: checked=' checked'
923                 else: checked=''
924                 w('<td align=middle>\n')
925                 w(' <input type="checkbox" name=":group" value="%s"'
926                   '%s></td>\n'%(name, checked))
927             w('</tr>\n')
929         w('<tr class="location-bar"><td width="1%">&nbsp;</td>')
930         w('<td colspan="%s">'%len(names))
931         w(_('<input type="submit" name="action" value="Redisplay"></td>'))
932         w('</tr>\n')
933         w('</table>\n')
935         # and the outer table
936         w('</td></tr></table>')
939     def sortby(self, sort_name, filterspec, columns, filter, group, sort):
940         l = []
941         w = l.append
942         for k, v in filterspec.items():
943             k = urllib.quote(k)
944             if type(v) == type([]):
945                 w('%s=%s'%(k, ','.join(map(urllib.quote, v))))
946             else:
947                 w('%s=%s'%(k, urllib.quote(v)))
948         if columns:
949             w(':columns=%s'%','.join(map(urllib.quote, columns)))
950         if filter:
951             w(':filter=%s'%','.join(map(urllib.quote, filter)))
952         if group:
953             w(':group=%s'%','.join(map(urllib.quote, group)))
954         m = []
955         s_dir = ''
956         for name in sort:
957             dir = name[0]
958             if dir == '-':
959                 name = name[1:]
960             else:
961                 dir = ''
962             if sort_name == name:
963                 if dir == '-':
964                     s_dir = ''
965                 else:
966                     s_dir = '-'
967             else:
968                 m.append(dir+urllib.quote(name))
969         m.insert(0, s_dir+urllib.quote(sort_name))
970         # so things don't get completely out of hand, limit the sort to
971         # two columns
972         w(':sort=%s'%','.join(m[:2]))
973         return '&'.join(l)
976 #   ITEM TEMPLATES
978 class ItemTemplateReplace:
979     def __init__(self, globals, locals, cl, nodeid):
980         self.globals = globals
981         self.locals = locals
982         self.cl = cl
983         self.nodeid = nodeid
985     replace=re.compile(
986         r'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
987         r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)
988     def go(self, text):
989         return self.replace.sub(self, text)
991     def __call__(self, m, filter=None, columns=None, sort=None, group=None):
992         if m.group('name'):
993             if self.nodeid and self.cl.get(self.nodeid, m.group('name')):
994                 replace = ItemTemplateReplace(self.globals, {}, self.cl,
995                     self.nodeid)
996                 return replace.go(m.group('text'))
997             else:
998                 return ''
999         if m.group('display'):
1000             command = m.group('command')
1001             return eval(command, self.globals, self.locals)
1002         print '*** unhandled match', m.groupdict()
1005 class ItemTemplate(TemplateFunctions):
1006     def __init__(self, client, templates, classname):
1007         self.client = client
1008         self.instance = client.instance
1009         self.templates = templates
1010         self.classname = classname
1012         # derived
1013         self.db = self.client.db
1014         self.cl = self.db.classes[self.classname]
1015         self.properties = self.cl.getprops()
1017         TemplateFunctions.__init__(self)
1019     def render(self, nodeid):
1020         self.nodeid = nodeid
1022         if (self.properties.has_key('type') and
1023                 self.properties.has_key('content')):
1024             pass
1025             # XXX we really want to return this as a downloadable...
1026             #  currently I handle this at a higher level by detecting 'file'
1027             #  designators...
1029         w = self.client.write
1030         w('<form onSubmit="return submit_once()" action="%s%s" method="POST" enctype="multipart/form-data">'%(
1031             self.classname, nodeid))
1032         s = open(os.path.join(self.templates, self.classname+'.item')).read()
1033         replace = ItemTemplateReplace(self.globals, locals(), self.cl, nodeid)
1034         w(replace.go(s))
1035         w('</form>')
1038 class NewItemTemplate(TemplateFunctions):
1039     def __init__(self, client, templates, classname):
1040         self.client = client
1041         self.instance = client.instance
1042         self.templates = templates
1043         self.classname = classname
1045         # derived
1046         self.db = self.client.db
1047         self.cl = self.db.classes[self.classname]
1048         self.properties = self.cl.getprops()
1050         TemplateFunctions.__init__(self)
1052     def render(self, form):
1053         self.form = form
1054         w = self.client.write
1055         c = self.classname
1056         try:
1057             s = open(os.path.join(self.templates, c+'.newitem')).read()
1058         except IOError:
1059             s = open(os.path.join(self.templates, c+'.item')).read()
1060         w('<form onSubmit="return submit_once()" action="new%s" method="POST" enctype="multipart/form-data">'%c)
1061         for key in form.keys():
1062             if key[0] == ':':
1063                 value = form[key].value
1064                 if type(value) != type([]): value = [value]
1065                 for value in value:
1066                     w('<input type="hidden" name="%s" value="%s">'%(key, value))
1067         replace = ItemTemplateReplace(self.globals, locals(), None, None)
1068         w(replace.go(s))
1069         w('</form>')
1072 # $Log: not supported by cvs2svn $
1073 # Revision 1.74  2002/02/16 08:39:42  richard
1074 #  . #516854 ] "My Issues" and redisplay
1076 # Revision 1.73  2002/02/15 07:08:44  richard
1077 #  . Alternate email addresses are now available for users. See the MIGRATION
1078 #    file for info on how to activate the feature.
1080 # Revision 1.72  2002/02/14 23:39:18  richard
1081 # . All forms now have "double-submit" protection when Javascript is enabled
1082 #   on the client-side.
1084 # Revision 1.71  2002/01/23 06:15:24  richard
1085 # real (non-string, duh) sorting of lists by node id
1087 # Revision 1.70  2002/01/23 05:47:57  richard
1088 # more HTML template cleanup and unit tests
1090 # Revision 1.69  2002/01/23 05:10:27  richard
1091 # More HTML template cleanup and unit tests.
1092 #  - download() now implemented correctly, replacing link(is_download=1) [fixed in the
1093 #    templates, but link(is_download=1) will still work for existing templates]
1095 # Revision 1.68  2002/01/22 22:55:28  richard
1096 #  . htmltemplate list() wasn't sorting...
1098 # Revision 1.67  2002/01/22 22:46:22  richard
1099 # more htmltemplate cleanups and unit tests
1101 # Revision 1.66  2002/01/22 06:35:40  richard
1102 # more htmltemplate tests and cleanup
1104 # Revision 1.65  2002/01/22 00:12:06  richard
1105 # Wrote more unit tests for htmltemplate, and while I was at it, I polished
1106 # off the implementation of some of the functions so they behave sanely.
1108 # Revision 1.64  2002/01/21 03:25:59  richard
1109 # oops
1111 # Revision 1.63  2002/01/21 02:59:10  richard
1112 # Fixed up the HTML display of history so valid links are actually displayed.
1113 # Oh for some unit tests! :(
1115 # Revision 1.62  2002/01/18 08:36:12  grubert
1116 #  . add nowrap to history table date cell i.e. <td nowrap ...
1118 # Revision 1.61  2002/01/17 23:04:53  richard
1119 #  . much nicer history display (actualy real handling of property types etc)
1121 # Revision 1.60  2002/01/17 08:48:19  grubert
1122 #  . display superseder as html link in history.
1124 # Revision 1.59  2002/01/17 07:58:24  grubert
1125 #  . display links a html link in history.
1127 # Revision 1.58  2002/01/15 00:50:03  richard
1128 # #502949 ] index view for non-issues and redisplay
1130 # Revision 1.57  2002/01/14 23:31:21  richard
1131 # reverted the change that had plain() hyperlinking the link displays -
1132 # that's what link() is for!
1134 # Revision 1.56  2002/01/14 07:04:36  richard
1135 #  . plain rendering of links in the htmltemplate now generate a hyperlink to
1136 #    the linked node's page.
1137 #    ... this allows a display very similar to bugzilla's where you can actually
1138 #    find out information about the linked node.
1140 # Revision 1.55  2002/01/14 06:45:03  richard
1141 #  . #502953 ] nosy-like treatment of other multilinks
1142 #    ... had to revert most of the previous change to the multilink field
1143 #    display... not good.
1145 # Revision 1.54  2002/01/14 05:16:51  richard
1146 # The submit buttons need a name attribute or mozilla won't submit without a
1147 # file upload. Yeah, that's bloody obscure. Grr.
1149 # Revision 1.53  2002/01/14 04:03:32  richard
1150 # How about that ... date fields have never worked ...
1152 # Revision 1.52  2002/01/14 02:20:14  richard
1153 #  . changed all config accesses so they access either the instance or the
1154 #    config attriubute on the db. This means that all config is obtained from
1155 #    instance_config instead of the mish-mash of classes. This will make
1156 #    switching to a ConfigParser setup easier too, I hope.
1158 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1159 # 0.5.0 switch, I hope!)
1161 # Revision 1.51  2002/01/10 10:02:15  grubert
1162 # In do_history: replace "." in date by " " so html wraps more sensible.
1163 # Should this be done in date's string converter ?
1165 # Revision 1.50  2002/01/05 02:35:10  richard
1166 # I18N'ification
1168 # Revision 1.49  2001/12/20 15:43:01  rochecompaan
1169 # Features added:
1170 #  .  Multilink properties are now displayed as comma separated values in
1171 #     a textbox
1172 #  .  The add user link is now only visible to the admin user
1173 #  .  Modified the mail gateway to reject submissions from unknown
1174 #     addresses if ANONYMOUS_ACCESS is denied
1176 # Revision 1.48  2001/12/20 06:13:24  rochecompaan
1177 # Bugs fixed:
1178 #   . Exception handling in hyperdb for strings-that-look-like numbers got
1179 #     lost somewhere
1180 #   . Internet Explorer submits full path for filename - we now strip away
1181 #     the path
1182 # Features added:
1183 #   . Link and multilink properties are now displayed sorted in the cgi
1184 #     interface
1186 # Revision 1.47  2001/11/26 22:55:56  richard
1187 # Feature:
1188 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1189 #    the instance.
1190 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1191 #    signature info in e-mails.
1192 #  . Some more flexibility in the mail gateway and more error handling.
1193 #  . Login now takes you to the page you back to the were denied access to.
1195 # Fixed:
1196 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1198 # Revision 1.46  2001/11/24 00:53:12  jhermann
1199 # "except:" is bad, bad , bad!
1201 # Revision 1.45  2001/11/22 15:46:42  jhermann
1202 # Added module docstrings to all modules.
1204 # Revision 1.44  2001/11/21 23:35:45  jhermann
1205 # Added globbing for win32, and sample marking in a 2nd file to test it
1207 # Revision 1.43  2001/11/21 04:04:43  richard
1208 # *sigh* more missing value handling
1210 # Revision 1.42  2001/11/21 03:40:54  richard
1211 # more new property handling
1213 # Revision 1.41  2001/11/15 10:26:01  richard
1214 #  . missing "return" in filter_section (thanks Roch'e Compaan)
1216 # Revision 1.40  2001/11/03 01:56:51  richard
1217 # More HTML compliance fixes. This will probably fix the Netscape problem
1218 # too.
1220 # Revision 1.39  2001/11/03 01:43:47  richard
1221 # Ahah! Fixed the lynx problem - there was a hidden input field misplaced.
1223 # Revision 1.38  2001/10/31 06:58:51  richard
1224 # Added the wrap="hard" attribute to the textarea of the note field so the
1225 # messages wrap sanely.
1227 # Revision 1.37  2001/10/31 06:24:35  richard
1228 # Added do_stext to htmltemplate, thanks Brad Clements.
1230 # Revision 1.36  2001/10/28 22:51:38  richard
1231 # Fixed ENOENT/WindowsError thing, thanks Juergen Hermann
1233 # Revision 1.35  2001/10/24 00:04:41  richard
1234 # Removed the "infinite authentication loop", thanks Roch'e
1236 # Revision 1.34  2001/10/23 22:56:36  richard
1237 # Bugfix in filter "widget" placement, thanks Roch'e
1239 # Revision 1.33  2001/10/23 01:00:18  richard
1240 # Re-enabled login and registration access after lopping them off via
1241 # disabling access for anonymous users.
1242 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1243 # a couple of bugs while I was there. Probably introduced a couple, but
1244 # things seem to work OK at the moment.
1246 # Revision 1.32  2001/10/22 03:25:01  richard
1247 # Added configuration for:
1248 #  . anonymous user access and registration (deny/allow)
1249 #  . filter "widget" location on index page (top, bottom, both)
1250 # Updated some documentation.
1252 # Revision 1.31  2001/10/21 07:26:35  richard
1253 # feature #473127: Filenames. I modified the file.index and htmltemplate
1254 #  source so that the filename is used in the link and the creation
1255 #  information is displayed.
1257 # Revision 1.30  2001/10/21 04:44:50  richard
1258 # bug #473124: UI inconsistency with Link fields.
1259 #    This also prompted me to fix a fairly long-standing usability issue -
1260 #    that of being able to turn off certain filters.
1262 # Revision 1.29  2001/10/21 00:17:56  richard
1263 # CGI interface view customisation section may now be hidden (patch from
1264 #  Roch'e Compaan.)
1266 # Revision 1.28  2001/10/21 00:00:16  richard
1267 # Fixed Checklist function - wasn't always working on a list.
1269 # Revision 1.27  2001/10/20 12:13:44  richard
1270 # Fixed grouping of non-str properties (thanks Roch'e Compaan)
1272 # Revision 1.26  2001/10/14 10:55:00  richard
1273 # Handle empty strings in HTML template Link function
1275 # Revision 1.25  2001/10/09 07:25:59  richard
1276 # Added the Password property type. See "pydoc roundup.password" for
1277 # implementation details. Have updated some of the documentation too.
1279 # Revision 1.24  2001/09/27 06:45:58  richard
1280 # *gak* ... xmp is Old Skool apparently. Am using pre again by have the option
1281 # on the plain() template function to escape the text for HTML.
1283 # Revision 1.23  2001/09/10 09:47:18  richard
1284 # Fixed bug in the generation of links to Link/Multilink in indexes.
1285 #   (thanks Hubert Hoegl)
1286 # Added AssignedTo to the "classic" schema's item page.
1288 # Revision 1.22  2001/08/30 06:01:17  richard
1289 # Fixed missing import in mailgw :(
1291 # Revision 1.21  2001/08/16 07:34:59  richard
1292 # better CGI text searching - but hidden filter fields are disappearing...
1294 # Revision 1.20  2001/08/15 23:43:18  richard
1295 # Fixed some isFooTypes that I missed.
1296 # Refactored some code in the CGI code.
1298 # Revision 1.19  2001/08/12 06:32:36  richard
1299 # using isinstance(blah, Foo) now instead of isFooType
1301 # Revision 1.18  2001/08/07 00:24:42  richard
1302 # stupid typo
1304 # Revision 1.17  2001/08/07 00:15:51  richard
1305 # Added the copyright/license notice to (nearly) all files at request of
1306 # Bizar Software.
1308 # Revision 1.16  2001/08/01 03:52:23  richard
1309 # Checklist was using wrong name.
1311 # Revision 1.15  2001/07/30 08:12:17  richard
1312 # Added time logging and file uploading to the templates.
1314 # Revision 1.14  2001/07/30 06:17:45  richard
1315 # Features:
1316 #  . Added ability for cgi newblah forms to indicate that the new node
1317 #    should be linked somewhere.
1318 # Fixed:
1319 #  . Fixed the agument handling for the roundup-admin find command.
1320 #  . Fixed handling of summary when no note supplied for newblah. Again.
1321 #  . Fixed detection of no form in htmltemplate Field display.
1323 # Revision 1.13  2001/07/30 02:37:53  richard
1324 # Temporary measure until we have decent schema migration.
1326 # Revision 1.12  2001/07/30 01:24:33  richard
1327 # Handles new node display now.
1329 # Revision 1.11  2001/07/29 09:31:35  richard
1330 # oops
1332 # Revision 1.10  2001/07/29 09:28:23  richard
1333 # Fixed sorting by clicking on column headings.
1335 # Revision 1.9  2001/07/29 08:27:40  richard
1336 # Fixed handling of passed-in values in form elements (ie. during a
1337 # drill-down)
1339 # Revision 1.8  2001/07/29 07:01:39  richard
1340 # Added vim command to all source so that we don't get no steenkin' tabs :)
1342 # Revision 1.7  2001/07/29 05:36:14  richard
1343 # Cleanup of the link label generation.
1345 # Revision 1.6  2001/07/29 04:06:42  richard
1346 # Fixed problem in link display when Link value is None.
1348 # Revision 1.5  2001/07/28 08:17:09  richard
1349 # fixed use of stylesheet
1351 # Revision 1.4  2001/07/28 07:59:53  richard
1352 # Replaced errno integers with their module values.
1353 # De-tabbed templatebuilder.py
1355 # Revision 1.3  2001/07/25 03:39:47  richard
1356 # Hrm - displaying links to classes that don't specify a key property. I've
1357 # got it defaulting to 'name', then 'title' and then a "random" property (first
1358 # one returned by getprops().keys().
1359 # Needs to be moved onto the Class I think...
1361 # Revision 1.2  2001/07/22 12:09:32  richard
1362 # Final commit of Grande Splite
1364 # Revision 1.1  2001/07/22 11:58:35  richard
1365 # More Grande Splite
1368 # vim: set filetype=python ts=4 sw=4 et si