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(' ', ' ')
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]')
656 #
657 # INDEX TEMPLATES
658 #
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%%"> </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"> 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> </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> </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> </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> </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%"> </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)
975 #
976 # ITEM TEMPLATES
977 #
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>')
1071 #
1072 # $Log: not supported by cvs2svn $
1073 # Revision 1.74 2002/02/16 08:39:42 richard
1074 # . #516854 ] "My Issues" and redisplay
1075 #
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.
1079 #
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.
1083 #
1084 # Revision 1.71 2002/01/23 06:15:24 richard
1085 # real (non-string, duh) sorting of lists by node id
1086 #
1087 # Revision 1.70 2002/01/23 05:47:57 richard
1088 # more HTML template cleanup and unit tests
1089 #
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]
1094 #
1095 # Revision 1.68 2002/01/22 22:55:28 richard
1096 # . htmltemplate list() wasn't sorting...
1097 #
1098 # Revision 1.67 2002/01/22 22:46:22 richard
1099 # more htmltemplate cleanups and unit tests
1100 #
1101 # Revision 1.66 2002/01/22 06:35:40 richard
1102 # more htmltemplate tests and cleanup
1103 #
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.
1107 #
1108 # Revision 1.64 2002/01/21 03:25:59 richard
1109 # oops
1110 #
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! :(
1114 #
1115 # Revision 1.62 2002/01/18 08:36:12 grubert
1116 # . add nowrap to history table date cell i.e. <td nowrap ...
1117 #
1118 # Revision 1.61 2002/01/17 23:04:53 richard
1119 # . much nicer history display (actualy real handling of property types etc)
1120 #
1121 # Revision 1.60 2002/01/17 08:48:19 grubert
1122 # . display superseder as html link in history.
1123 #
1124 # Revision 1.59 2002/01/17 07:58:24 grubert
1125 # . display links a html link in history.
1126 #
1127 # Revision 1.58 2002/01/15 00:50:03 richard
1128 # #502949 ] index view for non-issues and redisplay
1129 #
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!
1133 #
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.
1139 #
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.
1144 #
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.
1148 #
1149 # Revision 1.53 2002/01/14 04:03:32 richard
1150 # How about that ... date fields have never worked ...
1151 #
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.
1157 #
1158 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1159 # 0.5.0 switch, I hope!)
1160 #
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 ?
1164 #
1165 # Revision 1.50 2002/01/05 02:35:10 richard
1166 # I18N'ification
1167 #
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
1175 #
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
1185 #
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.
1194 #
1195 # Fixed:
1196 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1197 #
1198 # Revision 1.46 2001/11/24 00:53:12 jhermann
1199 # "except:" is bad, bad , bad!
1200 #
1201 # Revision 1.45 2001/11/22 15:46:42 jhermann
1202 # Added module docstrings to all modules.
1203 #
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
1206 #
1207 # Revision 1.43 2001/11/21 04:04:43 richard
1208 # *sigh* more missing value handling
1209 #
1210 # Revision 1.42 2001/11/21 03:40:54 richard
1211 # more new property handling
1212 #
1213 # Revision 1.41 2001/11/15 10:26:01 richard
1214 # . missing "return" in filter_section (thanks Roch'e Compaan)
1215 #
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.
1219 #
1220 # Revision 1.39 2001/11/03 01:43:47 richard
1221 # Ahah! Fixed the lynx problem - there was a hidden input field misplaced.
1222 #
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.
1226 #
1227 # Revision 1.37 2001/10/31 06:24:35 richard
1228 # Added do_stext to htmltemplate, thanks Brad Clements.
1229 #
1230 # Revision 1.36 2001/10/28 22:51:38 richard
1231 # Fixed ENOENT/WindowsError thing, thanks Juergen Hermann
1232 #
1233 # Revision 1.35 2001/10/24 00:04:41 richard
1234 # Removed the "infinite authentication loop", thanks Roch'e
1235 #
1236 # Revision 1.34 2001/10/23 22:56:36 richard
1237 # Bugfix in filter "widget" placement, thanks Roch'e
1238 #
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.
1245 #
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.
1251 #
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.
1256 #
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.
1261 #
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.)
1265 #
1266 # Revision 1.28 2001/10/21 00:00:16 richard
1267 # Fixed Checklist function - wasn't always working on a list.
1268 #
1269 # Revision 1.27 2001/10/20 12:13:44 richard
1270 # Fixed grouping of non-str properties (thanks Roch'e Compaan)
1271 #
1272 # Revision 1.26 2001/10/14 10:55:00 richard
1273 # Handle empty strings in HTML template Link function
1274 #
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.
1278 #
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.
1282 #
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.
1287 #
1288 # Revision 1.22 2001/08/30 06:01:17 richard
1289 # Fixed missing import in mailgw :(
1290 #
1291 # Revision 1.21 2001/08/16 07:34:59 richard
1292 # better CGI text searching - but hidden filter fields are disappearing...
1293 #
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.
1297 #
1298 # Revision 1.19 2001/08/12 06:32:36 richard
1299 # using isinstance(blah, Foo) now instead of isFooType
1300 #
1301 # Revision 1.18 2001/08/07 00:24:42 richard
1302 # stupid typo
1303 #
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.
1307 #
1308 # Revision 1.16 2001/08/01 03:52:23 richard
1309 # Checklist was using wrong name.
1310 #
1311 # Revision 1.15 2001/07/30 08:12:17 richard
1312 # Added time logging and file uploading to the templates.
1313 #
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.
1322 #
1323 # Revision 1.13 2001/07/30 02:37:53 richard
1324 # Temporary measure until we have decent schema migration.
1325 #
1326 # Revision 1.12 2001/07/30 01:24:33 richard
1327 # Handles new node display now.
1328 #
1329 # Revision 1.11 2001/07/29 09:31:35 richard
1330 # oops
1331 #
1332 # Revision 1.10 2001/07/29 09:28:23 richard
1333 # Fixed sorting by clicking on column headings.
1334 #
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)
1338 #
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 :)
1341 #
1342 # Revision 1.7 2001/07/29 05:36:14 richard
1343 # Cleanup of the link label generation.
1344 #
1345 # Revision 1.6 2001/07/29 04:06:42 richard
1346 # Fixed problem in link display when Link value is None.
1347 #
1348 # Revision 1.5 2001/07/28 08:17:09 richard
1349 # fixed use of stylesheet
1350 #
1351 # Revision 1.4 2001/07/28 07:59:53 richard
1352 # Replaced errno integers with their module values.
1353 # De-tabbed templatebuilder.py
1354 #
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...
1360 #
1361 # Revision 1.2 2001/07/22 12:09:32 richard
1362 # Final commit of Grande Splite
1363 #
1364 # Revision 1.1 2001/07/22 11:58:35 richard
1365 # More Grande Splite
1366 #
1367 #
1368 # vim: set filetype=python ts=4 sw=4 et si