Code

. Alternate email addresses are now available for users. See the MIGRATION
[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.73 2002-02-15 07:08:44 richard Exp $
20 __doc__ = """
21 Template engine.
22 """
24 import os, re, StringIO, urllib, cgi, errno
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.Link):
261             linkcl = self.db.classes[propclass.classname]
262             l = ['<select name="%s">'%property]
263             k = linkcl.labelprop()
264             s = ''
265             if value is None:
266                 s = 'selected '
267             l.append(_('<option %svalue="-1">- no selection -</option>')%s)
268             options = linkcl.list()
269             options.sort(sortfunc)
270             for optionid in options:
271                 option = linkcl.get(optionid, k)
272                 s = ''
273                 if optionid == value:
274                     s = 'selected '
275                 if showid:
276                     lab = '%s%s: %s'%(propclass.classname, optionid, option)
277                 else:
278                     lab = option
279                 if size is not None and len(lab) > size:
280                     lab = lab[:size-3] + '...'
281                 lab = cgi.escape(lab)
282                 l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
283             l.append('</select>')
284             return '\n'.join(l)
285         if isinstance(propclass, hyperdb.Multilink):
286             linkcl = self.db.classes[propclass.classname]
287             options = linkcl.list()
288             options.sort(sortfunc)
289             height = height or min(len(options), 7)
290             l = ['<select multiple name="%s" size="%s">'%(property, height)]
291             k = linkcl.labelprop()
292             for optionid in options:
293                 option = linkcl.get(optionid, k)
294                 s = ''
295                 if optionid in value:
296                     s = 'selected '
297                 if showid:
298                     lab = '%s%s: %s'%(propclass.classname, optionid, option)
299                 else:
300                     lab = option
301                 if size is not None and len(lab) > size:
302                     lab = lab[:size-3] + '...'
303                 lab = cgi.escape(lab)
304                 l.append('<option %svalue="%s">%s</option>'%(s, optionid,
305                     lab))
306             l.append('</select>')
307             return '\n'.join(l)
308         return _('[Menu: not a link]')
310     #XXX deviates from spec
311     def do_link(self, property=None, is_download=0):
312         '''For a Link or Multilink property, display the names of the linked
313            nodes, hyperlinked to the item views on those nodes.
314            For other properties, link to this node with the property as the
315            text.
317            If is_download is true, append the property value to the generated
318            URL so that the link may be used as a download link and the
319            downloaded file name is correct.
320         '''
321         if not self.nodeid and self.form is None:
322             return _('[Link: not called from item]')
324         # get the value
325         value = self.determine_value(property)
326         if not value:
327             return _('[no %(propname)s]')%{'propname':property.capitalize()}
329         propclass = self.properties[property]
330         if isinstance(propclass, hyperdb.Link):
331             linkname = propclass.classname
332             linkcl = self.db.classes[linkname]
333             k = linkcl.labelprop()
334             linkvalue = cgi.escape(linkcl.get(value, k))
335             if is_download:
336                 return '<a href="%s%s/%s">%s</a>'%(linkname, value,
337                     linkvalue, linkvalue)
338             else:
339                 return '<a href="%s%s">%s</a>'%(linkname, value, linkvalue)
340         if isinstance(propclass, hyperdb.Multilink):
341             linkname = propclass.classname
342             linkcl = self.db.classes[linkname]
343             k = linkcl.labelprop()
344             l = []
345             for value in value:
346                 linkvalue = cgi.escape(linkcl.get(value, k))
347                 if is_download:
348                     l.append('<a href="%s%s/%s">%s</a>'%(linkname, value,
349                         linkvalue, linkvalue))
350                 else:
351                     l.append('<a href="%s%s">%s</a>'%(linkname, value,
352                         linkvalue))
353             return ', '.join(l)
354         if is_download:
355             return '<a href="%s%s/%s">%s</a>'%(self.classname, self.nodeid,
356                 value, value)
357         else:
358             return '<a href="%s%s">%s</a>'%(self.classname, self.nodeid, value)
360     def do_count(self, property, **args):
361         ''' for a Multilink property, display a count of the number of links in
362             the list
363         '''
364         if not self.nodeid:
365             return _('[Count: not called from item]')
367         propclass = self.properties[property]
368         if not isinstance(propclass, hyperdb.Multilink):
369             return _('[Count: not a Multilink]')
371         # figure the length then...
372         value = self.cl.get(self.nodeid, property)
373         return str(len(value))
375     # XXX pretty is definitely new ;)
376     def do_reldate(self, property, pretty=0):
377         ''' display a Date property in terms of an interval relative to the
378             current date (e.g. "+ 3w", "- 2d").
380             with the 'pretty' flag, make it pretty
381         '''
382         if not self.nodeid and self.form is None:
383             return _('[Reldate: not called from item]')
385         propclass = self.properties[property]
386         if not isinstance(propclass, hyperdb.Date):
387             return _('[Reldate: not a Date]')
389         if self.nodeid:
390             value = self.cl.get(self.nodeid, property)
391         else:
392             return ''
393         if not value:
394             return ''
396         # figure the interval
397         interval = value - date.Date('.')
398         if pretty:
399             if not self.nodeid:
400                 return _('now')
401             pretty = interval.pretty()
402             if pretty is None:
403                 pretty = value.pretty()
404             return pretty
405         return str(interval)
407     def do_download(self, property, **args):
408         ''' show a Link("file") or Multilink("file") property using links that
409             allow you to download files
410         '''
411         if not self.nodeid:
412             return _('[Download: not called from item]')
413         return self.do_link(property, is_download=1)
416     def do_checklist(self, property, **args):
417         ''' for a Link or Multilink property, display checkboxes for the
418             available choices to permit filtering
419         '''
420         propclass = self.properties[property]
421         if (not isinstance(propclass, hyperdb.Link) and not
422                 isinstance(propclass, hyperdb.Multilink)):
423             return _('[Checklist: not a link]')
425         # get our current checkbox state
426         if self.nodeid:
427             # get the info from the node - make sure it's a list
428             if isinstance(propclass, hyperdb.Link):
429                 value = [self.cl.get(self.nodeid, property)]
430             else:
431                 value = self.cl.get(self.nodeid, property)
432         elif self.filterspec is not None:
433             # get the state from the filter specification (always a list)
434             value = self.filterspec.get(property, [])
435         else:
436             # it's a new node, so there's no state
437             value = []
439         # so we can map to the linked node's "lable" property
440         linkcl = self.db.classes[propclass.classname]
441         l = []
442         k = linkcl.labelprop()
443         for optionid in linkcl.list():
444             option = cgi.escape(linkcl.get(optionid, k))
445             if optionid in value or option in value:
446                 checked = 'checked'
447             else:
448                 checked = ''
449             l.append('%s:<input type="checkbox" %s name="%s" value="%s">'%(
450                 option, checked, property, option))
452         # for Links, allow the "unselected" option too
453         if isinstance(propclass, hyperdb.Link):
454             if value is None or '-1' in value:
455                 checked = 'checked'
456             else:
457                 checked = ''
458             l.append(_('[unselected]:<input type="checkbox" %s name="%s" '
459                 'value="-1">')%(checked, property))
460         return '\n'.join(l)
462     def do_note(self, rows=5, cols=80):
463         ''' display a "note" field, which is a text area for entering a note to
464             go along with a change. 
465         '''
466         # TODO: pull the value from the form
467         return '<textarea name="__note" wrap="hard" rows=%s cols=%s>'\
468             '</textarea>'%(rows, cols)
470     # XXX new function
471     def do_list(self, property, reverse=0):
472         ''' list the items specified by property using the standard index for
473             the class
474         '''
475         propcl = self.properties[property]
476         if not isinstance(propcl, hyperdb.Multilink):
477             return _('[List: not a Multilink]')
479         value = self.determine_value(property)
480         if not value:
481             return ''
483         # sort, possibly revers and then re-stringify
484         value = map(int, value)
485         value.sort()
486         if reverse:
487             value.reverse()
488         value = map(str, value)
490         # render the sub-index into a string
491         fp = StringIO.StringIO()
492         try:
493             write_save = self.client.write
494             self.client.write = fp.write
495             index = IndexTemplate(self.client, self.templates, propcl.classname)
496             index.render(nodeids=value, show_display_form=0)
497         finally:
498             self.client.write = write_save
500         return fp.getvalue()
502     # XXX new function
503     def do_history(self, direction='descending'):
504         ''' list the history of the item
506             If "direction" is 'descending' then the most recent event will
507             be displayed first. If it is 'ascending' then the oldest event
508             will be displayed first.
509         '''
510         if self.nodeid is None:
511             return _("[History: node doesn't exist]")
513         l = ['<table width=100% border=0 cellspacing=0 cellpadding=2>',
514             '<tr class="list-header">',
515             _('<th align=left><span class="list-item">Date</span></th>'),
516             _('<th align=left><span class="list-item">User</span></th>'),
517             _('<th align=left><span class="list-item">Action</span></th>'),
518             _('<th align=left><span class="list-item">Args</span></th>'),
519             '</tr>']
521         comments = {}
522         history = self.cl.history(self.nodeid)
523         history.sort()
524         if direction == 'descending':
525             history.reverse()
526         for id, evt_date, user, action, args in history:
527             date_s = str(evt_date).replace("."," ")
528             arg_s = ''
529             if action == 'link' and type(args) == type(()):
530                 if len(args) == 3:
531                     linkcl, linkid, key = args
532                     arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
533                         linkcl, linkid, key)
534                 else:
535                     arg_s = str(arg)
537             elif action == 'unlink' and type(args) == type(()):
538                 if len(args) == 3:
539                     linkcl, linkid, key = args
540                     arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
541                         linkcl, linkid, key)
542                 else:
543                     arg_s = str(arg)
545             elif type(args) == type({}):
546                 cell = []
547                 for k in args.keys():
548                     # try to get the relevant property and treat it
549                     # specially
550                     try:
551                         prop = self.properties[k]
552                     except:
553                         prop = None
554                     if prop is not None:
555                         if args[k] and (isinstance(prop, hyperdb.Multilink) or
556                                 isinstance(prop, hyperdb.Link)):
557                             # figure what the link class is
558                             classname = prop.classname
559                             try:
560                                 linkcl = self.db.classes[classname]
561                             except KeyError, message:
562                                 labelprop = None
563                                 comments[classname] = _('''The linked class
564                                     %(classname)s no longer exists''')%locals()
565                             labelprop = linkcl.labelprop()
567                         if isinstance(prop, hyperdb.Multilink) and \
568                                 len(args[k]) > 0:
569                             ml = []
570                             for linkid in args[k]:
571                                 label = classname + linkid
572                                 # if we have a label property, try to use it
573                                 # TODO: test for node existence even when
574                                 # there's no labelprop!
575                                 try:
576                                     if labelprop is not None:
577                                         label = linkcl.get(linkid, labelprop)
578                                 except IndexError:
579                                     comments['no_link'] = _('''<strike>The
580                                         linked node no longer
581                                         exists</strike>''')
582                                     ml.append('<strike>%s</strike>'%label)
583                                 else:
584                                     ml.append('<a href="%s%s">%s</a>'%(
585                                         classname, linkid, label))
586                             cell.append('%s:\n  %s'%(k, ',\n  '.join(ml)))
587                         elif isinstance(prop, hyperdb.Link) and args[k]:
588                             label = classname + args[k]
589                             # if we have a label property, try to use it
590                             # TODO: test for node existence even when
591                             # there's no labelprop!
592                             if labelprop is not None:
593                                 try:
594                                     label = linkcl.get(args[k], labelprop)
595                                 except IndexError:
596                                     comments['no_link'] = _('''<strike>The
597                                         linked node no longer
598                                         exists</strike>''')
599                                     cell.append(' <strike>%s</strike>,\n'%label)
600                                     # "flag" this is done .... euwww
601                                     label = None
602                             if label is not None:
603                                 cell.append('%s: <a href="%s%s">%s</a>\n'%(k,
604                                     classname, args[k], label))
606                         elif isinstance(prop, hyperdb.Date) and args[k]:
607                             d = date.Date(args[k])
608                             cell.append('%s: %s'%(k, str(d)))
610                         elif isinstance(prop, hyperdb.Interval) and args[k]:
611                             d = date.Interval(args[k])
612                             cell.append('%s: %s'%(k, str(d)))
614                         elif not args[k]:
615                             cell.append('%s: (no value)\n'%k)
617                         else:
618                             cell.append('%s: %s\n'%(k, str(args[k])))
619                     else:
620                         # property no longer exists
621                         comments['no_exist'] = _('''<em>The indicated property
622                             no longer exists</em>''')
623                         cell.append('<em>%s: %s</em>\n'%(k, str(args[k])))
624                 arg_s = '<br />'.join(cell)
625             else:
626                 # unkown event!!
627                 comments['unknown'] = _('''<strong><em>This event is not
628                     handled by the history display!</em></strong>''')
629                 arg_s = '<strong><em>' + str(args) + '</em></strong>'
630             date_s = date_s.replace(' ', '&nbsp;')
631             l.append('<tr><td nowrap valign=top>%s</td><td valign=top>%s</td>'
632                 '<td valign=top>%s</td><td valign=top>%s</td></tr>'%(date_s,
633                 user, action, arg_s))
634         if comments:
635             l.append(_('<tr><td colspan=4><strong>Note:</strong></td></tr>'))
636         for entry in comments.values():
637             l.append('<tr><td colspan=4>%s</td></tr>'%entry)
638         l.append('</table>')
639         return '\n'.join(l)
641     # XXX new function
642     def do_submit(self):
643         ''' add a submit button for the item
644         '''
645         if self.nodeid:
646             return _('<input type="submit" name="submit" value="Submit Changes">')
647         elif self.form is not None:
648             return _('<input type="submit" name="submit" value="Submit New Entry">')
649         else:
650             return _('[Submit: not called from item]')
654 #   INDEX TEMPLATES
656 class IndexTemplateReplace:
657     def __init__(self, globals, locals, props):
658         self.globals = globals
659         self.locals = locals
660         self.props = props
662     replace=re.compile(
663         r'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
664         r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)
665     def go(self, text):
666         return self.replace.sub(self, text)
668     def __call__(self, m, filter=None, columns=None, sort=None, group=None):
669         if m.group('name'):
670             if m.group('name') in self.props:
671                 text = m.group('text')
672                 replace = IndexTemplateReplace(self.globals, {}, self.props)
673                 return replace.go(m.group('text'))
674             else:
675                 return ''
676         if m.group('display'):
677             command = m.group('command')
678             return eval(command, self.globals, self.locals)
679         print '*** unhandled match', m.groupdict()
681 class IndexTemplate(TemplateFunctions):
682     def __init__(self, client, templates, classname):
683         self.client = client
684         self.instance = client.instance
685         self.templates = templates
686         self.classname = classname
688         # derived
689         self.db = self.client.db
690         self.cl = self.db.classes[self.classname]
691         self.properties = self.cl.getprops()
693         TemplateFunctions.__init__(self)
695     col_re=re.compile(r'<property\s+name="([^>]+)">')
696     def render(self, filterspec={}, filter=[], columns=[], sort=[], group=[],
697             show_display_form=1, nodeids=None, show_customization=1):
698         self.filterspec = filterspec
700         w = self.client.write
702         # get the filter template
703         try:
704             filter_template = open(os.path.join(self.templates,
705                 self.classname+'.filter')).read()
706             all_filters = self.col_re.findall(filter_template)
707         except IOError, error:
708             if error.errno not in (errno.ENOENT, errno.ESRCH): raise
709             filter_template = None
710             all_filters = []
712         # XXX deviate from spec here ...
713         # load the index section template and figure the default columns from it
714         template = open(os.path.join(self.templates,
715             self.classname+'.index')).read()
716         all_columns = self.col_re.findall(template)
717         if not columns:
718             columns = []
719             for name in all_columns:
720                 columns.append(name)
721         else:
722             # re-sort columns to be the same order as all_columns
723             l = []
724             for name in all_columns:
725                 if name in columns:
726                     l.append(name)
727             columns = l
729         # display the filter section
730         if (show_display_form and 
731                 self.instance.FILTER_POSITION in ('top and bottom', 'top')):
732             w('<form onSubmit="return submit_once()" action="%s">\n'%self.classname)
733             self.filter_section(filter_template, filter, columns, group,
734                 all_filters, all_columns, show_customization)
735             # make sure that the sorting doesn't get lost either
736             if sort:
737                 w('<input type="hidden" name=":sort" value="%s">'%
738                     ','.join(sort))
739             w('</form>\n')
742         # now display the index section
743         w('<table width=100% border=0 cellspacing=0 cellpadding=2>\n')
744         w('<tr class="list-header">\n')
745         for name in columns:
746             cname = name.capitalize()
747             if show_display_form:
748                 sb = self.sortby(name, filterspec, columns, filter, group, sort)
749                 anchor = "%s?%s"%(self.classname, sb)
750                 w('<td><span class="list-header"><a href="%s">%s</a></span></td>\n'%(
751                     anchor, cname))
752             else:
753                 w('<td><span class="list-header">%s</span></td>\n'%cname)
754         w('</tr>\n')
756         # this stuff is used for group headings - optimise the group names
757         old_group = None
758         group_names = []
759         if group:
760             for name in group:
761                 if name[0] == '-': group_names.append(name[1:])
762                 else: group_names.append(name)
764         # now actually loop through all the nodes we get from the filter and
765         # apply the template
766         if nodeids is None:
767             nodeids = self.cl.filter(filterspec, sort, group)
768         for nodeid in nodeids:
769             # check for a group heading
770             if group_names:
771                 this_group = [self.cl.get(nodeid, name, _('[no value]')) for name in group_names]
772                 if this_group != old_group:
773                     l = []
774                     for name in group_names:
775                         prop = self.properties[name]
776                         if isinstance(prop, hyperdb.Link):
777                             group_cl = self.db.classes[prop.classname]
778                             key = group_cl.getkey()
779                             value = self.cl.get(nodeid, name)
780                             if value is None:
781                                 l.append(_('[unselected %(classname)s]')%{
782                                     'classname': prop.classname})
783                             else:
784                                 l.append(group_cl.get(self.cl.get(nodeid,
785                                     name), key))
786                         elif isinstance(prop, hyperdb.Multilink):
787                             group_cl = self.db.classes[prop.classname]
788                             key = group_cl.getkey()
789                             for value in self.cl.get(nodeid, name):
790                                 l.append(group_cl.get(value, key))
791                         else:
792                             value = self.cl.get(nodeid, name, _('[no value]'))
793                             if value is None:
794                                 value = _('[empty %(name)s]')%locals()
795                             else:
796                                 value = str(value)
797                             l.append(value)
798                     w('<tr class="section-bar">'
799                       '<td align=middle colspan=%s><strong>%s</strong></td></tr>'%(
800                         len(columns), ', '.join(l)))
801                     old_group = this_group
803             # display this node's row
804             replace = IndexTemplateReplace(self.globals, locals(), columns)
805             self.nodeid = nodeid
806             w(replace.go(template))
807             self.nodeid = None
809         w('</table>')
811         # display the filter section
812         if (show_display_form and hasattr(self.instance, 'FILTER_POSITION') and
813                 self.instance.FILTER_POSITION in ('top and bottom', 'bottom')):
814             w('<form onSubmit="return submit_once()" action="%s">\n'%self.classname)
815             self.filter_section(filter_template, filter, columns, group,
816                 all_filters, all_columns, show_customization)
817             # make sure that the sorting doesn't get lost either
818             if sort:
819                 w('<input type="hidden" name=":sort" value="%s">'%
820                     ','.join(sort))
821             w('</form>\n')
824     def filter_section(self, template, filter, columns, group, all_filters,
825             all_columns, show_customization):
827         w = self.client.write
829         # wrap the template in a single table to ensure the whole widget
830         # is displayed at once
831         w('<table><tr><td>')
833         if template and filter:
834             # display the filter section
835             w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
836             w('<tr class="location-bar">')
837             w(_(' <th align="left" colspan="2">Filter specification...</th>'))
838             w('</tr>')
839             replace = IndexTemplateReplace(self.globals, locals(), filter)
840             w(replace.go(template))
841             w('<tr class="location-bar"><td width="1%%">&nbsp;</td>')
842             w(_('<td><input type="submit" name="action" value="Redisplay"></td></tr>'))
843             w('</table>')
845         # now add in the filter/columns/group/etc config table form
846         w('<input type="hidden" name="show_customization" value="%s">' %
847             show_customization )
848         w('<table width=100% border=0 cellspacing=0 cellpadding=2>\n')
849         names = []
850         for name in self.properties.keys():
851             if name in all_filters or name in all_columns:
852                 names.append(name)
853         if show_customization:
854             action = '-'
855         else:
856             action = '+'
857             # hide the values for filters, columns and grouping in the form
858             # if the customization widget is not visible
859             for name in names:
860                 if all_filters and name in filter:
861                     w('<input type="hidden" name=":filter" value="%s">' % name)
862                 if all_columns and name in columns:
863                     w('<input type="hidden" name=":columns" value="%s">' % name)
864                 if all_columns and name in group:
865                     w('<input type="hidden" name=":group" value="%s">' % name)
867         # TODO: The widget style can go into the stylesheet
868         w(_('<th align="left" colspan=%s>'
869           '<input style="height : 1em; width : 1em; font-size: 12pt" type="submit" name="action" value="%s">&nbsp;View '
870           'customisation...</th></tr>\n')%(len(names)+1, action))
872         if not show_customization:
873             w('</table>\n')
874             return
876         w('<tr class="location-bar"><th>&nbsp;</th>')
877         for name in names:
878             w('<th>%s</th>'%name.capitalize())
879         w('</tr>\n')
881         # Filter
882         if all_filters:
883             w(_('<tr><th width="1%" align=right class="location-bar">Filters</th>\n'))
884             for name in names:
885                 if name not in all_filters:
886                     w('<td>&nbsp;</td>')
887                     continue
888                 if name in filter: checked=' checked'
889                 else: checked=''
890                 w('<td align=middle>\n')
891                 w(' <input type="checkbox" name=":filter" value="%s" '
892                   '%s></td>\n'%(name, checked))
893             w('</tr>\n')
895         # Columns
896         if all_columns:
897             w(_('<tr><th width="1%" align=right class="location-bar">Columns</th>\n'))
898             for name in names:
899                 if name not in all_columns:
900                     w('<td>&nbsp;</td>')
901                     continue
902                 if name in columns: checked=' checked'
903                 else: checked=''
904                 w('<td align=middle>\n')
905                 w(' <input type="checkbox" name=":columns" value="%s"'
906                   '%s></td>\n'%(name, checked))
907             w('</tr>\n')
909             # Grouping
910             w(_('<tr><th width="1%" align=right class="location-bar">Grouping</th>\n'))
911             for name in names:
912                 prop = self.properties[name]
913                 if name not in all_columns:
914                     w('<td>&nbsp;</td>')
915                     continue
916                 if name in group: checked=' checked'
917                 else: checked=''
918                 w('<td align=middle>\n')
919                 w(' <input type="checkbox" name=":group" value="%s"'
920                   '%s></td>\n'%(name, checked))
921             w('</tr>\n')
923         w('<tr class="location-bar"><td width="1%">&nbsp;</td>')
924         w('<td colspan="%s">'%len(names))
925         w(_('<input type="submit" name="action" value="Redisplay"></td>'))
926         w('</tr>\n')
927         w('</table>\n')
929         # and the outer table
930         w('</td></tr></table>')
933     def sortby(self, sort_name, filterspec, columns, filter, group, sort):
934         l = []
935         w = l.append
936         for k, v in filterspec.items():
937             k = urllib.quote(k)
938             if type(v) == type([]):
939                 w('%s=%s'%(k, ','.join(map(urllib.quote, v))))
940             else:
941                 w('%s=%s'%(k, urllib.quote(v)))
942         if columns:
943             w(':columns=%s'%','.join(map(urllib.quote, columns)))
944         if filter:
945             w(':filter=%s'%','.join(map(urllib.quote, filter)))
946         if group:
947             w(':group=%s'%','.join(map(urllib.quote, group)))
948         m = []
949         s_dir = ''
950         for name in sort:
951             dir = name[0]
952             if dir == '-':
953                 name = name[1:]
954             else:
955                 dir = ''
956             if sort_name == name:
957                 if dir == '-':
958                     s_dir = ''
959                 else:
960                     s_dir = '-'
961             else:
962                 m.append(dir+urllib.quote(name))
963         m.insert(0, s_dir+urllib.quote(sort_name))
964         # so things don't get completely out of hand, limit the sort to
965         # two columns
966         w(':sort=%s'%','.join(m[:2]))
967         return '&'.join(l)
970 #   ITEM TEMPLATES
972 class ItemTemplateReplace:
973     def __init__(self, globals, locals, cl, nodeid):
974         self.globals = globals
975         self.locals = locals
976         self.cl = cl
977         self.nodeid = nodeid
979     replace=re.compile(
980         r'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
981         r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)
982     def go(self, text):
983         return self.replace.sub(self, text)
985     def __call__(self, m, filter=None, columns=None, sort=None, group=None):
986         if m.group('name'):
987             if self.nodeid and self.cl.get(self.nodeid, m.group('name')):
988                 replace = ItemTemplateReplace(self.globals, {}, self.cl,
989                     self.nodeid)
990                 return replace.go(m.group('text'))
991             else:
992                 return ''
993         if m.group('display'):
994             command = m.group('command')
995             return eval(command, self.globals, self.locals)
996         print '*** unhandled match', m.groupdict()
999 class ItemTemplate(TemplateFunctions):
1000     def __init__(self, client, templates, classname):
1001         self.client = client
1002         self.instance = client.instance
1003         self.templates = templates
1004         self.classname = classname
1006         # derived
1007         self.db = self.client.db
1008         self.cl = self.db.classes[self.classname]
1009         self.properties = self.cl.getprops()
1011         TemplateFunctions.__init__(self)
1013     def render(self, nodeid):
1014         self.nodeid = nodeid
1016         if (self.properties.has_key('type') and
1017                 self.properties.has_key('content')):
1018             pass
1019             # XXX we really want to return this as a downloadable...
1020             #  currently I handle this at a higher level by detecting 'file'
1021             #  designators...
1023         w = self.client.write
1024         w('<form onSubmit="return submit_once()" action="%s%s" method="POST" enctype="multipart/form-data">'%(
1025             self.classname, nodeid))
1026         s = open(os.path.join(self.templates, self.classname+'.item')).read()
1027         replace = ItemTemplateReplace(self.globals, locals(), self.cl, nodeid)
1028         w(replace.go(s))
1029         w('</form>')
1032 class NewItemTemplate(TemplateFunctions):
1033     def __init__(self, client, templates, classname):
1034         self.client = client
1035         self.instance = client.instance
1036         self.templates = templates
1037         self.classname = classname
1039         # derived
1040         self.db = self.client.db
1041         self.cl = self.db.classes[self.classname]
1042         self.properties = self.cl.getprops()
1044         TemplateFunctions.__init__(self)
1046     def render(self, form):
1047         self.form = form
1048         w = self.client.write
1049         c = self.classname
1050         try:
1051             s = open(os.path.join(self.templates, c+'.newitem')).read()
1052         except IOError:
1053             s = open(os.path.join(self.templates, c+'.item')).read()
1054         w('<form onSubmit="return submit_once()" action="new%s" method="POST" enctype="multipart/form-data">'%c)
1055         for key in form.keys():
1056             if key[0] == ':':
1057                 value = form[key].value
1058                 if type(value) != type([]): value = [value]
1059                 for value in value:
1060                     w('<input type="hidden" name="%s" value="%s">'%(key, value))
1061         replace = ItemTemplateReplace(self.globals, locals(), None, None)
1062         w(replace.go(s))
1063         w('</form>')
1066 # $Log: not supported by cvs2svn $
1067 # Revision 1.72  2002/02/14 23:39:18  richard
1068 # . All forms now have "double-submit" protection when Javascript is enabled
1069 #   on the client-side.
1071 # Revision 1.71  2002/01/23 06:15:24  richard
1072 # real (non-string, duh) sorting of lists by node id
1074 # Revision 1.70  2002/01/23 05:47:57  richard
1075 # more HTML template cleanup and unit tests
1077 # Revision 1.69  2002/01/23 05:10:27  richard
1078 # More HTML template cleanup and unit tests.
1079 #  - download() now implemented correctly, replacing link(is_download=1) [fixed in the
1080 #    templates, but link(is_download=1) will still work for existing templates]
1082 # Revision 1.68  2002/01/22 22:55:28  richard
1083 #  . htmltemplate list() wasn't sorting...
1085 # Revision 1.67  2002/01/22 22:46:22  richard
1086 # more htmltemplate cleanups and unit tests
1088 # Revision 1.66  2002/01/22 06:35:40  richard
1089 # more htmltemplate tests and cleanup
1091 # Revision 1.65  2002/01/22 00:12:06  richard
1092 # Wrote more unit tests for htmltemplate, and while I was at it, I polished
1093 # off the implementation of some of the functions so they behave sanely.
1095 # Revision 1.64  2002/01/21 03:25:59  richard
1096 # oops
1098 # Revision 1.63  2002/01/21 02:59:10  richard
1099 # Fixed up the HTML display of history so valid links are actually displayed.
1100 # Oh for some unit tests! :(
1102 # Revision 1.62  2002/01/18 08:36:12  grubert
1103 #  . add nowrap to history table date cell i.e. <td nowrap ...
1105 # Revision 1.61  2002/01/17 23:04:53  richard
1106 #  . much nicer history display (actualy real handling of property types etc)
1108 # Revision 1.60  2002/01/17 08:48:19  grubert
1109 #  . display superseder as html link in history.
1111 # Revision 1.59  2002/01/17 07:58:24  grubert
1112 #  . display links a html link in history.
1114 # Revision 1.58  2002/01/15 00:50:03  richard
1115 # #502949 ] index view for non-issues and redisplay
1117 # Revision 1.57  2002/01/14 23:31:21  richard
1118 # reverted the change that had plain() hyperlinking the link displays -
1119 # that's what link() is for!
1121 # Revision 1.56  2002/01/14 07:04:36  richard
1122 #  . plain rendering of links in the htmltemplate now generate a hyperlink to
1123 #    the linked node's page.
1124 #    ... this allows a display very similar to bugzilla's where you can actually
1125 #    find out information about the linked node.
1127 # Revision 1.55  2002/01/14 06:45:03  richard
1128 #  . #502953 ] nosy-like treatment of other multilinks
1129 #    ... had to revert most of the previous change to the multilink field
1130 #    display... not good.
1132 # Revision 1.54  2002/01/14 05:16:51  richard
1133 # The submit buttons need a name attribute or mozilla won't submit without a
1134 # file upload. Yeah, that's bloody obscure. Grr.
1136 # Revision 1.53  2002/01/14 04:03:32  richard
1137 # How about that ... date fields have never worked ...
1139 # Revision 1.52  2002/01/14 02:20:14  richard
1140 #  . changed all config accesses so they access either the instance or the
1141 #    config attriubute on the db. This means that all config is obtained from
1142 #    instance_config instead of the mish-mash of classes. This will make
1143 #    switching to a ConfigParser setup easier too, I hope.
1145 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1146 # 0.5.0 switch, I hope!)
1148 # Revision 1.51  2002/01/10 10:02:15  grubert
1149 # In do_history: replace "." in date by " " so html wraps more sensible.
1150 # Should this be done in date's string converter ?
1152 # Revision 1.50  2002/01/05 02:35:10  richard
1153 # I18N'ification
1155 # Revision 1.49  2001/12/20 15:43:01  rochecompaan
1156 # Features added:
1157 #  .  Multilink properties are now displayed as comma separated values in
1158 #     a textbox
1159 #  .  The add user link is now only visible to the admin user
1160 #  .  Modified the mail gateway to reject submissions from unknown
1161 #     addresses if ANONYMOUS_ACCESS is denied
1163 # Revision 1.48  2001/12/20 06:13:24  rochecompaan
1164 # Bugs fixed:
1165 #   . Exception handling in hyperdb for strings-that-look-like numbers got
1166 #     lost somewhere
1167 #   . Internet Explorer submits full path for filename - we now strip away
1168 #     the path
1169 # Features added:
1170 #   . Link and multilink properties are now displayed sorted in the cgi
1171 #     interface
1173 # Revision 1.47  2001/11/26 22:55:56  richard
1174 # Feature:
1175 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1176 #    the instance.
1177 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1178 #    signature info in e-mails.
1179 #  . Some more flexibility in the mail gateway and more error handling.
1180 #  . Login now takes you to the page you back to the were denied access to.
1182 # Fixed:
1183 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1185 # Revision 1.46  2001/11/24 00:53:12  jhermann
1186 # "except:" is bad, bad , bad!
1188 # Revision 1.45  2001/11/22 15:46:42  jhermann
1189 # Added module docstrings to all modules.
1191 # Revision 1.44  2001/11/21 23:35:45  jhermann
1192 # Added globbing for win32, and sample marking in a 2nd file to test it
1194 # Revision 1.43  2001/11/21 04:04:43  richard
1195 # *sigh* more missing value handling
1197 # Revision 1.42  2001/11/21 03:40:54  richard
1198 # more new property handling
1200 # Revision 1.41  2001/11/15 10:26:01  richard
1201 #  . missing "return" in filter_section (thanks Roch'e Compaan)
1203 # Revision 1.40  2001/11/03 01:56:51  richard
1204 # More HTML compliance fixes. This will probably fix the Netscape problem
1205 # too.
1207 # Revision 1.39  2001/11/03 01:43:47  richard
1208 # Ahah! Fixed the lynx problem - there was a hidden input field misplaced.
1210 # Revision 1.38  2001/10/31 06:58:51  richard
1211 # Added the wrap="hard" attribute to the textarea of the note field so the
1212 # messages wrap sanely.
1214 # Revision 1.37  2001/10/31 06:24:35  richard
1215 # Added do_stext to htmltemplate, thanks Brad Clements.
1217 # Revision 1.36  2001/10/28 22:51:38  richard
1218 # Fixed ENOENT/WindowsError thing, thanks Juergen Hermann
1220 # Revision 1.35  2001/10/24 00:04:41  richard
1221 # Removed the "infinite authentication loop", thanks Roch'e
1223 # Revision 1.34  2001/10/23 22:56:36  richard
1224 # Bugfix in filter "widget" placement, thanks Roch'e
1226 # Revision 1.33  2001/10/23 01:00:18  richard
1227 # Re-enabled login and registration access after lopping them off via
1228 # disabling access for anonymous users.
1229 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1230 # a couple of bugs while I was there. Probably introduced a couple, but
1231 # things seem to work OK at the moment.
1233 # Revision 1.32  2001/10/22 03:25:01  richard
1234 # Added configuration for:
1235 #  . anonymous user access and registration (deny/allow)
1236 #  . filter "widget" location on index page (top, bottom, both)
1237 # Updated some documentation.
1239 # Revision 1.31  2001/10/21 07:26:35  richard
1240 # feature #473127: Filenames. I modified the file.index and htmltemplate
1241 #  source so that the filename is used in the link and the creation
1242 #  information is displayed.
1244 # Revision 1.30  2001/10/21 04:44:50  richard
1245 # bug #473124: UI inconsistency with Link fields.
1246 #    This also prompted me to fix a fairly long-standing usability issue -
1247 #    that of being able to turn off certain filters.
1249 # Revision 1.29  2001/10/21 00:17:56  richard
1250 # CGI interface view customisation section may now be hidden (patch from
1251 #  Roch'e Compaan.)
1253 # Revision 1.28  2001/10/21 00:00:16  richard
1254 # Fixed Checklist function - wasn't always working on a list.
1256 # Revision 1.27  2001/10/20 12:13:44  richard
1257 # Fixed grouping of non-str properties (thanks Roch'e Compaan)
1259 # Revision 1.26  2001/10/14 10:55:00  richard
1260 # Handle empty strings in HTML template Link function
1262 # Revision 1.25  2001/10/09 07:25:59  richard
1263 # Added the Password property type. See "pydoc roundup.password" for
1264 # implementation details. Have updated some of the documentation too.
1266 # Revision 1.24  2001/09/27 06:45:58  richard
1267 # *gak* ... xmp is Old Skool apparently. Am using pre again by have the option
1268 # on the plain() template function to escape the text for HTML.
1270 # Revision 1.23  2001/09/10 09:47:18  richard
1271 # Fixed bug in the generation of links to Link/Multilink in indexes.
1272 #   (thanks Hubert Hoegl)
1273 # Added AssignedTo to the "classic" schema's item page.
1275 # Revision 1.22  2001/08/30 06:01:17  richard
1276 # Fixed missing import in mailgw :(
1278 # Revision 1.21  2001/08/16 07:34:59  richard
1279 # better CGI text searching - but hidden filter fields are disappearing...
1281 # Revision 1.20  2001/08/15 23:43:18  richard
1282 # Fixed some isFooTypes that I missed.
1283 # Refactored some code in the CGI code.
1285 # Revision 1.19  2001/08/12 06:32:36  richard
1286 # using isinstance(blah, Foo) now instead of isFooType
1288 # Revision 1.18  2001/08/07 00:24:42  richard
1289 # stupid typo
1291 # Revision 1.17  2001/08/07 00:15:51  richard
1292 # Added the copyright/license notice to (nearly) all files at request of
1293 # Bizar Software.
1295 # Revision 1.16  2001/08/01 03:52:23  richard
1296 # Checklist was using wrong name.
1298 # Revision 1.15  2001/07/30 08:12:17  richard
1299 # Added time logging and file uploading to the templates.
1301 # Revision 1.14  2001/07/30 06:17:45  richard
1302 # Features:
1303 #  . Added ability for cgi newblah forms to indicate that the new node
1304 #    should be linked somewhere.
1305 # Fixed:
1306 #  . Fixed the agument handling for the roundup-admin find command.
1307 #  . Fixed handling of summary when no note supplied for newblah. Again.
1308 #  . Fixed detection of no form in htmltemplate Field display.
1310 # Revision 1.13  2001/07/30 02:37:53  richard
1311 # Temporary measure until we have decent schema migration.
1313 # Revision 1.12  2001/07/30 01:24:33  richard
1314 # Handles new node display now.
1316 # Revision 1.11  2001/07/29 09:31:35  richard
1317 # oops
1319 # Revision 1.10  2001/07/29 09:28:23  richard
1320 # Fixed sorting by clicking on column headings.
1322 # Revision 1.9  2001/07/29 08:27:40  richard
1323 # Fixed handling of passed-in values in form elements (ie. during a
1324 # drill-down)
1326 # Revision 1.8  2001/07/29 07:01:39  richard
1327 # Added vim command to all source so that we don't get no steenkin' tabs :)
1329 # Revision 1.7  2001/07/29 05:36:14  richard
1330 # Cleanup of the link label generation.
1332 # Revision 1.6  2001/07/29 04:06:42  richard
1333 # Fixed problem in link display when Link value is None.
1335 # Revision 1.5  2001/07/28 08:17:09  richard
1336 # fixed use of stylesheet
1338 # Revision 1.4  2001/07/28 07:59:53  richard
1339 # Replaced errno integers with their module values.
1340 # De-tabbed templatebuilder.py
1342 # Revision 1.3  2001/07/25 03:39:47  richard
1343 # Hrm - displaying links to classes that don't specify a key property. I've
1344 # got it defaulting to 'name', then 'title' and then a "random" property (first
1345 # one returned by getprops().keys().
1346 # Needs to be moved onto the Class I think...
1348 # Revision 1.2  2001/07/22 12:09:32  richard
1349 # Final commit of Grande Splite
1351 # Revision 1.1  2001/07/22 11:58:35  richard
1352 # More Grande Splite
1355 # vim: set filetype=python ts=4 sw=4 et si