Code

. added generic item editing
[roundup.git] / roundup / cgi / templating.py
1 import sys, cgi, urllib, os, re, os.path, time, errno
3 from roundup import hyperdb, date
4 from roundup.i18n import _
6 try:
7     import cPickle as pickle
8 except ImportError:
9     import pickle
10 try:
11     import cStringIO as StringIO
12 except ImportError:
13     import StringIO
14 try:
15     import StructuredText
16 except ImportError:
17     StructuredText = None
19 # bring in the templating support
20 from roundup.cgi.PageTemplates import PageTemplate
21 from roundup.cgi.PageTemplates.Expressions import getEngine
22 from roundup.cgi.TAL.TALInterpreter import TALInterpreter
23 from roundup.cgi import ZTUtils
25 # XXX WAH pagetemplates aren't pickleable :(
26 #def getTemplate(dir, name, classname=None, request=None):
27 #    ''' Interface to get a template, possibly loading a compiled template.
28 #    '''
29 #    # source
30 #    src = os.path.join(dir, name)
31 #
32 #    # see if we can get a compile from the template"c" directory (most
33 #    # likely is "htmlc"
34 #    split = list(os.path.split(dir))
35 #    split[-1] = split[-1] + 'c'
36 #    cdir = os.path.join(*split)
37 #    split.append(name)
38 #    cpl = os.path.join(*split)
39 #
40 #    # ok, now see if the source is newer than the compiled (or if the
41 #    # compiled even exists)
42 #    MTIME = os.path.stat.ST_MTIME
43 #    if (not os.path.exists(cpl) or os.stat(cpl)[MTIME] < os.stat(src)[MTIME]):
44 #        # nope, we need to compile
45 #        pt = RoundupPageTemplate()
46 #        pt.write(open(src).read())
47 #        pt.id = name
48 #
49 #        # save off the compiled template
50 #        if not os.path.exists(cdir):
51 #            os.makedirs(cdir)
52 #        f = open(cpl, 'wb')
53 #        pickle.dump(pt, f)
54 #        f.close()
55 #    else:
56 #        # yay, use the compiled template
57 #        f = open(cpl, 'rb')
58 #        pt = pickle.load(f)
59 #    return pt
61 templates = {}
63 class NoTemplate(Exception):
64     pass
66 def getTemplate(dir, name, extension, classname=None, request=None):
67     ''' Interface to get a template, possibly loading a compiled template.
69         "name" and "extension" indicate the template we're after, which in
70         most cases will be "name.extension". If "extension" is None, then
71         we look for a template just called "name" with no extension.
73         If the file "name.extension" doesn't exist, we look for
74         "_generic.extension" as a fallback.
75     '''
76     # default the name to "home"
77     if name is None:
78         name = 'home'
80     # find the source, figure the time it was last modified
81     if extension:
82         filename = '%s.%s'%(name, extension)
83     else:
84         filename = name
85     src = os.path.join(dir, filename)
86     try:
87         stime = os.stat(src)[os.path.stat.ST_MTIME]
88     except os.error, error:
89         if error.errno != errno.ENOENT:
90             raise
91         if not extension:
92             raise NoTemplate, 'Template file "%s" doesn\'t exist'%name
94         # try for a generic template
95         generic = '_generic.%s'%extension
96         src = os.path.join(dir, generic)
97         try:
98             stime = os.stat(src)[os.path.stat.ST_MTIME]
99         except os.error, error:
100             if error.errno != errno.ENOENT:
101                 raise
102             # nicer error
103             raise NoTemplate, 'No template file exists for templating '\
104                 '"%s" with template "%s" (neither "%s" nor "%s")'%(name,
105                 extension, filename, generic)
106         filename = generic
108     key = (dir, filename)
109     if templates.has_key(key) and stime < templates[key].mtime:
110         # compiled template is up to date
111         return templates[key]
113     # compile the template
114     templates[key] = pt = RoundupPageTemplate()
115     pt.write(open(src).read())
116     pt.id = filename
117     pt.mtime = time.time()
118     return pt
120 class RoundupPageTemplate(PageTemplate.PageTemplate):
121     ''' A Roundup-specific PageTemplate.
123         Interrogate the client to set up the various template variables to
124         be available:
126         *context*
127          this is one of three things:
128          1. None - we're viewing a "home" page
129          2. The current class of item being displayed. This is an HTMLClass
130             instance.
131          3. The current item from the database, if we're viewing a specific
132             item, as an HTMLItem instance.
133         *request*
134           Includes information about the current request, including:
135            - the url
136            - the current index information (``filterspec``, ``filter`` args,
137              ``properties``, etc) parsed out of the form. 
138            - methods for easy filterspec link generation
139            - *user*, the current user node as an HTMLItem instance
140            - *form*, the current CGI form information as a FieldStorage
141         *instance*
142           The current instance
143         *db*
144           The current database, through which db.config may be reached.
145     '''
146     def getContext(self, client, classname, request):
147         c = {
148              'options': {},
149              'nothing': None,
150              'request': request,
151              'content': client.content,
152              'db': HTMLDatabase(client),
153              'instance': client.instance,
154              'utils': TemplatingUtils(client),
155         }
156         # add in the item if there is one
157         if client.nodeid:
158             if classname == 'user':
159                 c['context'] = HTMLUser(client, classname, client.nodeid)
160             else:
161                 c['context'] = HTMLItem(client, classname, client.nodeid)
162         else:
163             c['context'] = HTMLClass(client, classname)
164         return c
166     def render(self, client, classname, request, **options):
167         """Render this Page Template"""
169         if not self._v_cooked:
170             self._cook()
172         __traceback_supplement__ = (PageTemplate.PageTemplateTracebackSupplement, self)
174         if self._v_errors:
175             raise PageTemplate.PTRuntimeError, \
176                 'Page Template %s has errors.' % self.id
178         # figure the context
179         classname = classname or client.classname
180         request = request or HTMLRequest(client)
181         c = self.getContext(client, classname, request)
182         c.update({'options': options})
184         # and go
185         output = StringIO.StringIO()
186         TALInterpreter(self._v_program, self._v_macros,
187             getEngine().getContext(c), output, tal=1, strictinsert=0)()
188         return output.getvalue()
190 class HTMLDatabase:
191     ''' Return HTMLClasses for valid class fetches
192     '''
193     def __init__(self, client):
194         self._client = client
196         # we want config to be exposed
197         self.config = client.db.config
199     def __getitem__(self, item):
200         self._client.db.getclass(item)
201         return HTMLClass(self._client, item)
203     def __getattr__(self, attr):
204         try:
205             return self[attr]
206         except KeyError:
207             raise AttributeError, attr
209     def classes(self):
210         l = self._client.db.classes.keys()
211         l.sort()
212         return [HTMLClass(self._client, cn) for cn in l]
214 def lookupIds(db, prop, ids, num_re=re.compile('-?\d+')):
215     cl = db.getclass(prop.classname)
216     l = []
217     for entry in ids:
218         if num_re.match(entry):
219             l.append(entry)
220         else:
221             l.append(cl.lookup(entry))
222     return l
224 class HTMLPermissions:
225     ''' Helpers that provide answers to commonly asked Permission questions.
226     '''
227     def is_edit_ok(self):
228         ''' Is the user allowed to Edit the current class?
229         '''
230         return self._db.security.hasPermission('Edit', self._client.userid,
231             self._classname)
232     def is_view_ok(self):
233         ''' Is the user allowed to View the current class?
234         '''
235         return self._db.security.hasPermission('View', self._client.userid,
236             self._classname)
237     def is_only_view_ok(self):
238         ''' Is the user only allowed to View (ie. not Edit) the current class?
239         '''
240         return self.is_view_ok() and not self.is_edit_ok()
242 class HTMLClass(HTMLPermissions):
243     ''' Accesses through a class (either through *class* or *db.<classname>*)
244     '''
245     def __init__(self, client, classname):
246         self._client = client
247         self._db = client.db
249         # we want classname to be exposed, but _classname gives a
250         # consistent API for extending Class/Item
251         self._classname = self.classname = classname
252         if classname is not None:
253             self._klass = self._db.getclass(self.classname)
254             self._props = self._klass.getprops()
256     def __repr__(self):
257         return '<HTMLClass(0x%x) %s>'%(id(self), self.classname)
259     def __getitem__(self, item):
260         ''' return an HTMLProperty instance
261         '''
262        #print 'HTMLClass.getitem', (self, item)
264         # we don't exist
265         if item == 'id':
266             return None
268         # get the property
269         prop = self._props[item]
271         # look up the correct HTMLProperty class
272         form = self._client.form
273         for klass, htmlklass in propclasses:
274             if not isinstance(prop, klass):
275                 continue
276             if form.has_key(item):
277                 if isinstance(prop, hyperdb.Multilink):
278                     value = lookupIds(self._db, prop,
279                         handleListCGIValue(form[item]))
280                 elif isinstance(prop, hyperdb.Link):
281                     value = form[item].value.strip()
282                     if value:
283                         value = lookupIds(self._db, prop, [value])[0]
284                     else:
285                         value = None
286                 else:
287                     value = form[item].value.strip() or None
288             else:
289                 if isinstance(prop, hyperdb.Multilink):
290                     value = []
291                 else:
292                     value = None
293             return htmlklass(self._client, '', prop, item, value)
295         # no good
296         raise KeyError, item
298     def __getattr__(self, attr):
299         ''' convenience access '''
300         try:
301             return self[attr]
302         except KeyError:
303             raise AttributeError, attr
305     def properties(self):
306         ''' Return HTMLProperty for all of this class' properties.
307         '''
308         l = []
309         for name, prop in self._props.items():
310             for klass, htmlklass in propclasses:
311                 if isinstance(prop, hyperdb.Multilink):
312                     value = []
313                 else:
314                     value = None
315                 if isinstance(prop, klass):
316                     l.append(htmlklass(self._client, '', prop, name, value))
317         return l
319     def list(self):
320         ''' List all items in this class.
321         '''
322         if self.classname == 'user':
323             klass = HTMLUser
324         else:
325             klass = HTMLItem
326         l = [klass(self._client, self.classname, x) for x in self._klass.list()]
327         return l
329     def csv(self):
330         ''' Return the items of this class as a chunk of CSV text.
331         '''
332         # get the CSV module
333         try:
334             import csv
335         except ImportError:
336             return 'Sorry, you need the csv module to use this function.\n'\
337                 'Get it from: http://www.object-craft.com.au/projects/csv/'
339         props = self.propnames()
340         p = csv.parser()
341         s = StringIO.StringIO()
342         s.write(p.join(props) + '\n')
343         for nodeid in self._klass.list():
344             l = []
345             for name in props:
346                 value = self._klass.get(nodeid, name)
347                 if value is None:
348                     l.append('')
349                 elif isinstance(value, type([])):
350                     l.append(':'.join(map(str, value)))
351                 else:
352                     l.append(str(self._klass.get(nodeid, name)))
353             s.write(p.join(l) + '\n')
354         return s.getvalue()
356     def propnames(self):
357         ''' Return the list of the names of the properties of this class.
358         '''
359         idlessprops = self._klass.getprops(protected=0).keys()
360         idlessprops.sort()
361         return ['id'] + idlessprops
363     def filter(self, request=None):
364         ''' Return a list of items from this class, filtered and sorted
365             by the current requested filterspec/filter/sort/group args
366         '''
367         if request is not None:
368             filterspec = request.filterspec
369             sort = request.sort
370             group = request.group
371         if self.classname == 'user':
372             klass = HTMLUser
373         else:
374             klass = HTMLItem
375         l = [klass(self._client, self.classname, x)
376              for x in self._klass.filter(None, filterspec, sort, group)]
377         return l
379     def classhelp(self, properties=None, label='list', width='500',
380             height='400'):
381         ''' Pop up a javascript window with class help
383             This generates a link to a popup window which displays the 
384             properties indicated by "properties" of the class named by
385             "classname". The "properties" should be a comma-separated list
386             (eg. 'id,name,description'). Properties defaults to all the
387             properties of a class (excluding id, creator, created and
388             activity).
390             You may optionally override the label displayed, the width and
391             height. The popup window will be resizable and scrollable.
392         '''
393         if properties is None:
394             properties = self._klass.getprops(protected=0).keys()
395             properties.sort()
396             properties = ','.join(properties)
397         return '<a href="javascript:help_window(\'%s?:template=help&' \
398             ':contentonly=1&properties=%s\', \'%s\', \'%s\')"><b>'\
399             '(%s)</b></a>'%(self.classname, properties, width, height, label)
401     def submit(self, label="Submit New Entry"):
402         ''' Generate a submit button (and action hidden element)
403         '''
404         return '  <input type="hidden" name=":action" value="new">\n'\
405         '  <input type="submit" name="submit" value="%s">'%label
407     def history(self):
408         return 'New node - no history'
410     def renderWith(self, name, **kwargs):
411         ''' Render this class with the given template.
412         '''
413         # create a new request and override the specified args
414         req = HTMLRequest(self._client)
415         req.classname = self.classname
416         req.update(kwargs)
418         # new template, using the specified classname and request
419         pt = getTemplate(self._db.config.TEMPLATES, self.classname, name)
421         # use our fabricated request
422         return pt.render(self._client, self.classname, req)
424 class HTMLItem(HTMLPermissions):
425     ''' Accesses through an *item*
426     '''
427     def __init__(self, client, classname, nodeid):
428         self._client = client
429         self._db = client.db
430         self._classname = classname
431         self._nodeid = nodeid
432         self._klass = self._db.getclass(classname)
433         self._props = self._klass.getprops()
435     def __repr__(self):
436         return '<HTMLItem(0x%x) %s %s>'%(id(self), self._classname,
437             self._nodeid)
439     def __getitem__(self, item):
440         ''' return an HTMLProperty instance
441         '''
442        #print 'HTMLItem.getitem', (self, item)
443         if item == 'id':
444             return self._nodeid
446         # get the property
447         prop = self._props[item]
449         # get the value, handling missing values
450         value = self._klass.get(self._nodeid, item, None)
451         if value is None:
452             if isinstance(self._props[item], hyperdb.Multilink):
453                 value = []
455         # look up the correct HTMLProperty class
456         for klass, htmlklass in propclasses:
457             if isinstance(prop, klass):
458                 return htmlklass(self._client, self._nodeid, prop, item, value)
460         raise KeyErorr, item
462     def __getattr__(self, attr):
463         ''' convenience access to properties '''
464         try:
465             return self[attr]
466         except KeyError:
467             raise AttributeError, attr
468     
469     def submit(self, label="Submit Changes"):
470         ''' Generate a submit button (and action hidden element)
471         '''
472         return '  <input type="hidden" name=":action" value="edit">\n'\
473         '  <input type="submit" name="submit" value="%s">'%label
475     def journal(self, direction='descending'):
476         ''' Return a list of HTMLJournalEntry instances.
477         '''
478         # XXX do this
479         return []
481     def history(self, direction='descending'):
482         l = ['<table class="history">'
483              '<tr><th colspan="4" class="header">',
484              _('History'),
485              '</th></tr><tr>',
486              _('<th>Date</th>'),
487              _('<th>User</th>'),
488              _('<th>Action</th>'),
489              _('<th>Args</th>'),
490             '</tr>']
491         comments = {}
492         history = self._klass.history(self._nodeid)
493         history.sort()
494         if direction == 'descending':
495             history.reverse()
496         for id, evt_date, user, action, args in history:
497             date_s = str(evt_date).replace("."," ")
498             arg_s = ''
499             if action == 'link' and type(args) == type(()):
500                 if len(args) == 3:
501                     linkcl, linkid, key = args
502                     arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
503                         linkcl, linkid, key)
504                 else:
505                     arg_s = str(args)
507             elif action == 'unlink' and type(args) == type(()):
508                 if len(args) == 3:
509                     linkcl, linkid, key = args
510                     arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
511                         linkcl, linkid, key)
512                 else:
513                     arg_s = str(args)
515             elif type(args) == type({}):
516                 cell = []
517                 for k in args.keys():
518                     # try to get the relevant property and treat it
519                     # specially
520                     try:
521                         prop = self._props[k]
522                     except KeyError:
523                         prop = None
524                     if prop is not None:
525                         if args[k] and (isinstance(prop, hyperdb.Multilink) or
526                                 isinstance(prop, hyperdb.Link)):
527                             # figure what the link class is
528                             classname = prop.classname
529                             try:
530                                 linkcl = self._db.getclass(classname)
531                             except KeyError:
532                                 labelprop = None
533                                 comments[classname] = _('''The linked class
534                                     %(classname)s no longer exists''')%locals()
535                             labelprop = linkcl.labelprop(1)
536                             hrefable = os.path.exists(
537                                 os.path.join(self._db.config.TEMPLATES,
538                                 classname+'.item'))
540                         if isinstance(prop, hyperdb.Multilink) and \
541                                 len(args[k]) > 0:
542                             ml = []
543                             for linkid in args[k]:
544                                 if isinstance(linkid, type(())):
545                                     sublabel = linkid[0] + ' '
546                                     linkids = linkid[1]
547                                 else:
548                                     sublabel = ''
549                                     linkids = [linkid]
550                                 subml = []
551                                 for linkid in linkids:
552                                     label = classname + linkid
553                                     # if we have a label property, try to use it
554                                     # TODO: test for node existence even when
555                                     # there's no labelprop!
556                                     try:
557                                         if labelprop is not None:
558                                             label = linkcl.get(linkid, labelprop)
559                                     except IndexError:
560                                         comments['no_link'] = _('''<strike>The
561                                             linked node no longer
562                                             exists</strike>''')
563                                         subml.append('<strike>%s</strike>'%label)
564                                     else:
565                                         if hrefable:
566                                             subml.append('<a href="%s%s">%s</a>'%(
567                                                 classname, linkid, label))
568                                 ml.append(sublabel + ', '.join(subml))
569                             cell.append('%s:\n  %s'%(k, ', '.join(ml)))
570                         elif isinstance(prop, hyperdb.Link) and args[k]:
571                             label = classname + args[k]
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                             if labelprop is not None:
576                                 try:
577                                     label = linkcl.get(args[k], labelprop)
578                                 except IndexError:
579                                     comments['no_link'] = _('''<strike>The
580                                         linked node no longer
581                                         exists</strike>''')
582                                     cell.append(' <strike>%s</strike>,\n'%label)
583                                     # "flag" this is done .... euwww
584                                     label = None
585                             if label is not None:
586                                 if hrefable:
587                                     cell.append('%s: <a href="%s%s">%s</a>\n'%(k,
588                                         classname, args[k], label))
589                                 else:
590                                     cell.append('%s: %s' % (k,label))
592                         elif isinstance(prop, hyperdb.Date) and args[k]:
593                             d = date.Date(args[k])
594                             cell.append('%s: %s'%(k, str(d)))
596                         elif isinstance(prop, hyperdb.Interval) and args[k]:
597                             d = date.Interval(args[k])
598                             cell.append('%s: %s'%(k, str(d)))
600                         elif isinstance(prop, hyperdb.String) and args[k]:
601                             cell.append('%s: %s'%(k, cgi.escape(args[k])))
603                         elif not args[k]:
604                             cell.append('%s: (no value)\n'%k)
606                         else:
607                             cell.append('%s: %s\n'%(k, str(args[k])))
608                     else:
609                         # property no longer exists
610                         comments['no_exist'] = _('''<em>The indicated property
611                             no longer exists</em>''')
612                         cell.append('<em>%s: %s</em>\n'%(k, str(args[k])))
613                 arg_s = '<br />'.join(cell)
614             else:
615                 # unkown event!!
616                 comments['unknown'] = _('''<strong><em>This event is not
617                     handled by the history display!</em></strong>''')
618                 arg_s = '<strong><em>' + str(args) + '</em></strong>'
619             date_s = date_s.replace(' ', '&nbsp;')
620             l.append('<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>'%(
621                 date_s, user, action, arg_s))
622         if comments:
623             l.append(_('<tr><td colspan=4><strong>Note:</strong></td></tr>'))
624         for entry in comments.values():
625             l.append('<tr><td colspan=4>%s</td></tr>'%entry)
626         l.append('</table>')
627         return '\n'.join(l)
629     def renderQueryForm(self):
630         ''' Render this item, which is a query, as a search form.
631         '''
632         # create a new request and override the specified args
633         req = HTMLRequest(self._client)
634         req.classname = self._klass.get(self._nodeid, 'klass')
635         req.updateFromURL(self._klass.get(self._nodeid, 'url'))
637         # new template, using the specified classname and request
638         pt = getTemplate(self._db.config.TEMPLATES, req.classname, 'search')
640         # use our fabricated request
641         return pt.render(self._client, req.classname, req)
643 class HTMLUser(HTMLItem):
644     ''' Accesses through the *user* (a special case of item)
645     '''
646     def __init__(self, client, classname, nodeid):
647         HTMLItem.__init__(self, client, 'user', nodeid)
648         self._default_classname = client.classname
650         # used for security checks
651         self._security = client.db.security
653     _marker = []
654     def hasPermission(self, role, classname=_marker):
655         ''' Determine if the user has the Role.
657             The class being tested defaults to the template's class, but may
658             be overidden for this test by suppling an alternate classname.
659         '''
660         if classname is self._marker:
661             classname = self._default_classname
662         return self._security.hasPermission(role, self._nodeid, classname)
664     def is_edit_ok(self):
665         ''' Is the user allowed to Edit the current class?
666             Also check whether this is the current user's info.
667         '''
668         return self._db.security.hasPermission('Edit', self._client.userid,
669             self._classname) or self._nodeid == self._client.userid
671     def is_view_ok(self):
672         ''' Is the user allowed to View the current class?
673             Also check whether this is the current user's info.
674         '''
675         return self._db.security.hasPermission('Edit', self._client.userid,
676             self._classname) or self._nodeid == self._client.userid
678 class HTMLProperty:
679     ''' String, Number, Date, Interval HTMLProperty
681         Has useful attributes:
683          _name  the name of the property
684          _value the value of the property if any
686         A wrapper object which may be stringified for the plain() behaviour.
687     '''
688     def __init__(self, client, nodeid, prop, name, value):
689         self._client = client
690         self._db = client.db
691         self._nodeid = nodeid
692         self._prop = prop
693         self._name = name
694         self._value = value
695     def __repr__(self):
696         return '<HTMLProperty(0x%x) %s %r %r>'%(id(self), self._name, self._prop, self._value)
697     def __str__(self):
698         return self.plain()
699     def __cmp__(self, other):
700         if isinstance(other, HTMLProperty):
701             return cmp(self._value, other._value)
702         return cmp(self._value, other)
704 class StringHTMLProperty(HTMLProperty):
705     def plain(self, escape=0):
706         ''' Render a "plain" representation of the property
707         '''
708         if self._value is None:
709             return ''
710         if escape:
711             return cgi.escape(str(self._value))
712         return str(self._value)
714     def stext(self, escape=0):
715         ''' Render the value of the property as StructuredText.
717             This requires the StructureText module to be installed separately.
718         '''
719         s = self.plain(escape=escape)
720         if not StructuredText:
721             return s
722         return StructuredText(s,level=1,header=0)
724     def field(self, size = 30):
725         ''' Render a form edit field for the property
726         '''
727         if self._value is None:
728             value = ''
729         else:
730             value = cgi.escape(str(self._value))
731             value = '&quot;'.join(value.split('"'))
732         return '<input name="%s" value="%s" size="%s">'%(self._name, value, size)
734     def multiline(self, escape=0, rows=5, cols=40):
735         ''' Render a multiline form edit field for the property
736         '''
737         if self._value is None:
738             value = ''
739         else:
740             value = cgi.escape(str(self._value))
741             value = '&quot;'.join(value.split('"'))
742         return '<textarea name="%s" rows="%s" cols="%s">%s</textarea>'%(
743             self._name, rows, cols, value)
745     def email(self, escape=1):
746         ''' Render the value of the property as an obscured email address
747         '''
748         if self._value is None: value = ''
749         else: value = str(self._value)
750         if value.find('@') != -1:
751             name, domain = value.split('@')
752             domain = ' '.join(domain.split('.')[:-1])
753             name = name.replace('.', ' ')
754             value = '%s at %s ...'%(name, domain)
755         else:
756             value = value.replace('.', ' ')
757         if escape:
758             value = cgi.escape(value)
759         return value
761 class PasswordHTMLProperty(HTMLProperty):
762     def plain(self):
763         ''' Render a "plain" representation of the property
764         '''
765         if self._value is None:
766             return ''
767         return _('*encrypted*')
769     def field(self, size = 30):
770         ''' Render a form edit field for the property
771         '''
772         return '<input type="password" name="%s" size="%s">'%(self._name, size)
774 class NumberHTMLProperty(HTMLProperty):
775     def plain(self):
776         ''' Render a "plain" representation of the property
777         '''
778         return str(self._value)
780     def field(self, size = 30):
781         ''' Render a form edit field for the property
782         '''
783         if self._value is None:
784             value = ''
785         else:
786             value = cgi.escape(str(self._value))
787             value = '&quot;'.join(value.split('"'))
788         return '<input name="%s" value="%s" size="%s">'%(self._name, value, size)
790 class BooleanHTMLProperty(HTMLProperty):
791     def plain(self):
792         ''' Render a "plain" representation of the property
793         '''
794         if self.value is None:
795             return ''
796         return self._value and "Yes" or "No"
798     def field(self):
799         ''' Render a form edit field for the property
800         '''
801         checked = self._value and "checked" or ""
802         s = '<input type="radio" name="%s" value="yes" %s>Yes'%(self._name,
803             checked)
804         if checked:
805             checked = ""
806         else:
807             checked = "checked"
808         s += '<input type="radio" name="%s" value="no" %s>No'%(self._name,
809             checked)
810         return s
812 class DateHTMLProperty(HTMLProperty):
813     def plain(self):
814         ''' Render a "plain" representation of the property
815         '''
816         if self._value is None:
817             return ''
818         return str(self._value)
820     def field(self, size = 30):
821         ''' Render a form edit field for the property
822         '''
823         if self._value is None:
824             value = ''
825         else:
826             value = cgi.escape(str(self._value))
827             value = '&quot;'.join(value.split('"'))
828         return '<input name="%s" value="%s" size="%s">'%(self._name, value, size)
830     def reldate(self, pretty=1):
831         ''' Render the interval between the date and now.
833             If the "pretty" flag is true, then make the display pretty.
834         '''
835         if not self._value:
836             return ''
838         # figure the interval
839         interval = date.Date('.') - self._value
840         if pretty:
841             return interval.pretty()
842         return str(interval)
844 class IntervalHTMLProperty(HTMLProperty):
845     def plain(self):
846         ''' Render a "plain" representation of the property
847         '''
848         if self._value is None:
849             return ''
850         return str(self._value)
852     def pretty(self):
853         ''' Render the interval in a pretty format (eg. "yesterday")
854         '''
855         return self._value.pretty()
857     def field(self, size = 30):
858         ''' Render a form edit field for the property
859         '''
860         if self._value is None:
861             value = ''
862         else:
863             value = cgi.escape(str(self._value))
864             value = '&quot;'.join(value.split('"'))
865         return '<input name="%s" value="%s" size="%s">'%(self._name, value, size)
867 class LinkHTMLProperty(HTMLProperty):
868     ''' Link HTMLProperty
869         Include the above as well as being able to access the class
870         information. Stringifying the object itself results in the value
871         from the item being displayed. Accessing attributes of this object
872         result in the appropriate entry from the class being queried for the
873         property accessed (so item/assignedto/name would look up the user
874         entry identified by the assignedto property on item, and then the
875         name property of that user)
876     '''
877     def __getattr__(self, attr):
878         ''' return a new HTMLItem '''
879        #print 'Link.getattr', (self, attr, self._value)
880         if not self._value:
881             raise AttributeError, "Can't access missing value"
882         if self._prop.classname == 'user':
883             klass = HTMLUser
884         else:
885             klass = HTMLItem
886         i = klass(self._client, self._prop.classname, self._value)
887         return getattr(i, attr)
889     def plain(self, escape=0):
890         ''' Render a "plain" representation of the property
891         '''
892         if self._value is None:
893             return ''
894         linkcl = self._db.classes[self._prop.classname]
895         k = linkcl.labelprop(1)
896         value = str(linkcl.get(self._value, k))
897         if escape:
898             value = cgi.escape(value)
899         return value
901     def field(self, showid=0, size=None):
902         ''' Render a form edit field for the property
903         '''
904         linkcl = self._db.getclass(self._prop.classname)
905         if linkcl.getprops().has_key('order'):  
906             sort_on = 'order'  
907         else:  
908             sort_on = linkcl.labelprop()  
909         options = linkcl.filter(None, {}, ('+', sort_on), (None, None))
910         # TODO: make this a field display, not a menu one!
911         l = ['<select name="%s">'%self._name]
912         k = linkcl.labelprop(1)
913         if self._value is None:
914             s = 'selected '
915         else:
916             s = ''
917         l.append(_('<option %svalue="-1">- no selection -</option>')%s)
918         for optionid in options:
919             option = linkcl.get(optionid, k)
920             s = ''
921             if optionid == self._value:
922                 s = 'selected '
923             if showid:
924                 lab = '%s%s: %s'%(self._prop.classname, optionid, option)
925             else:
926                 lab = option
927             if size is not None and len(lab) > size:
928                 lab = lab[:size-3] + '...'
929             lab = cgi.escape(lab)
930             l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
931         l.append('</select>')
932         return '\n'.join(l)
934     def menu(self, size=None, height=None, showid=0, additional=[],
935             **conditions):
936         ''' Render a form select list for this property
937         '''
938         value = self._value
940         # sort function
941         sortfunc = make_sort_function(self._db, self._prop.classname)
943         # force the value to be a single choice
944         if isinstance(value, type('')):
945             value = value[0]
946         linkcl = self._db.getclass(self._prop.classname)
947         l = ['<select name="%s">'%self._name]
948         k = linkcl.labelprop(1)
949         s = ''
950         if value is None:
951             s = 'selected '
952         l.append(_('<option %svalue="-1">- no selection -</option>')%s)
953         if linkcl.getprops().has_key('order'):  
954             sort_on = ('+', 'order')
955         else:  
956             sort_on = ('+', linkcl.labelprop())
957         options = linkcl.filter(None, conditions, sort_on, (None, None))
958         for optionid in options:
959             option = linkcl.get(optionid, k)
960             s = ''
961             if value in [optionid, option]:
962                 s = 'selected '
963             if showid:
964                 lab = '%s%s: %s'%(self._prop.classname, optionid, option)
965             else:
966                 lab = option
967             if size is not None and len(lab) > size:
968                 lab = lab[:size-3] + '...'
969             if additional:
970                 m = []
971                 for propname in additional:
972                     m.append(linkcl.get(optionid, propname))
973                 lab = lab + ' (%s)'%', '.join(map(str, m))
974             lab = cgi.escape(lab)
975             l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
976         l.append('</select>')
977         return '\n'.join(l)
978 #    def checklist(self, ...)
980 class MultilinkHTMLProperty(HTMLProperty):
981     ''' Multilink HTMLProperty
983         Also be iterable, returning a wrapper object like the Link case for
984         each entry in the multilink.
985     '''
986     def __len__(self):
987         ''' length of the multilink '''
988         return len(self._value)
990     def __getattr__(self, attr):
991         ''' no extended attribute accesses make sense here '''
992         raise AttributeError, attr
994     def __getitem__(self, num):
995         ''' iterate and return a new HTMLItem
996         '''
997        #print 'Multi.getitem', (self, num)
998         value = self._value[num]
999         if self._prop.classname == 'user':
1000             klass = HTMLUser
1001         else:
1002             klass = HTMLItem
1003         return klass(self._client, self._prop.classname, value)
1005     def __contains__(self, value):
1006         ''' Support the "in" operator
1007         '''
1008         return value in self._value
1010     def reverse(self):
1011         ''' return the list in reverse order
1012         '''
1013         l = self._value[:]
1014         l.reverse()
1015         if self._prop.classname == 'user':
1016             klass = HTMLUser
1017         else:
1018             klass = HTMLItem
1019         return [klass(self._client, self._prop.classname, value) for value in l]
1021     def plain(self, escape=0):
1022         ''' Render a "plain" representation of the property
1023         '''
1024         linkcl = self._db.classes[self._prop.classname]
1025         k = linkcl.labelprop(1)
1026         labels = []
1027         for v in self._value:
1028             labels.append(linkcl.get(v, k))
1029         value = ', '.join(labels)
1030         if escape:
1031             value = cgi.escape(value)
1032         return value
1034     def field(self, size=30, showid=0):
1035         ''' Render a form edit field for the property
1036         '''
1037         sortfunc = make_sort_function(self._db, self._prop.classname)
1038         linkcl = self._db.getclass(self._prop.classname)
1039         value = self._value[:]
1040         if value:
1041             value.sort(sortfunc)
1042         # map the id to the label property
1043         if not linkcl.getkey():
1044             showid=1
1045         if not showid:
1046             k = linkcl.labelprop(1)
1047             value = [linkcl.get(v, k) for v in value]
1048         value = cgi.escape(','.join(value))
1049         return '<input name="%s" size="%s" value="%s">'%(self._name, size, value)
1051     def menu(self, size=None, height=None, showid=0, additional=[],
1052             **conditions):
1053         ''' Render a form select list for this property
1054         '''
1055         value = self._value
1057         # sort function
1058         sortfunc = make_sort_function(self._db, self._prop.classname)
1060         linkcl = self._db.getclass(self._prop.classname)
1061         if linkcl.getprops().has_key('order'):  
1062             sort_on = ('+', 'order')
1063         else:  
1064             sort_on = ('+', linkcl.labelprop())
1065         options = linkcl.filter(None, conditions, sort_on, (None,None)) 
1066         height = height or min(len(options), 7)
1067         l = ['<select multiple name="%s" size="%s">'%(self._name, height)]
1068         k = linkcl.labelprop(1)
1069         for optionid in options:
1070             option = linkcl.get(optionid, k)
1071             s = ''
1072             if optionid in value or option in value:
1073                 s = 'selected '
1074             if showid:
1075                 lab = '%s%s: %s'%(self._prop.classname, optionid, option)
1076             else:
1077                 lab = option
1078             if size is not None and len(lab) > size:
1079                 lab = lab[:size-3] + '...'
1080             if additional:
1081                 m = []
1082                 for propname in additional:
1083                     m.append(linkcl.get(optionid, propname))
1084                 lab = lab + ' (%s)'%', '.join(m)
1085             lab = cgi.escape(lab)
1086             l.append('<option %svalue="%s">%s</option>'%(s, optionid,
1087                 lab))
1088         l.append('</select>')
1089         return '\n'.join(l)
1091 # set the propclasses for HTMLItem
1092 propclasses = (
1093     (hyperdb.String, StringHTMLProperty),
1094     (hyperdb.Number, NumberHTMLProperty),
1095     (hyperdb.Boolean, BooleanHTMLProperty),
1096     (hyperdb.Date, DateHTMLProperty),
1097     (hyperdb.Interval, IntervalHTMLProperty),
1098     (hyperdb.Password, PasswordHTMLProperty),
1099     (hyperdb.Link, LinkHTMLProperty),
1100     (hyperdb.Multilink, MultilinkHTMLProperty),
1103 def make_sort_function(db, classname):
1104     '''Make a sort function for a given class
1105     '''
1106     linkcl = db.getclass(classname)
1107     if linkcl.getprops().has_key('order'):
1108         sort_on = 'order'
1109     else:
1110         sort_on = linkcl.labelprop()
1111     def sortfunc(a, b, linkcl=linkcl, sort_on=sort_on):
1112         return cmp(linkcl.get(a, sort_on), linkcl.get(b, sort_on))
1113     return sortfunc
1115 def handleListCGIValue(value):
1116     ''' Value is either a single item or a list of items. Each item has a
1117         .value that we're actually interested in.
1118     '''
1119     if isinstance(value, type([])):
1120         return [value.value for value in value]
1121     else:
1122         value = value.value.strip()
1123         if not value:
1124             return []
1125         return value.split(',')
1127 class ShowDict:
1128     ''' A convenience access to the :columns index parameters
1129     '''
1130     def __init__(self, columns):
1131         self.columns = {}
1132         for col in columns:
1133             self.columns[col] = 1
1134     def __getitem__(self, name):
1135         return self.columns.has_key(name)
1137 class HTMLRequest:
1138     ''' The *request*, holding the CGI form and environment.
1140         "form" the CGI form as a cgi.FieldStorage
1141         "env" the CGI environment variables
1142         "url" the current URL path for this request
1143         "base" the base URL for this instance
1144         "user" a HTMLUser instance for this user
1145         "classname" the current classname (possibly None)
1146         "template" the current template (suffix, also possibly None)
1148         Index args:
1149         "columns" dictionary of the columns to display in an index page
1150         "show" a convenience access to columns - request/show/colname will
1151                be true if the columns should be displayed, false otherwise
1152         "sort" index sort column (direction, column name)
1153         "group" index grouping property (direction, column name)
1154         "filter" properties to filter the index on
1155         "filterspec" values to filter the index on
1156         "search_text" text to perform a full-text search on for an index
1158     '''
1159     def __init__(self, client):
1160         self.client = client
1162         # easier access vars
1163         self.form = client.form
1164         self.env = client.env
1165         self.base = client.base
1166         self.url = client.url
1167         self.user = HTMLUser(client, 'user', client.userid)
1169         # store the current class name and action
1170         self.classname = client.classname
1171         self.template = client.template
1173         self._post_init()
1175     def _post_init(self):
1176         ''' Set attributes based on self.form
1177         '''
1178         # extract the index display information from the form
1179         self.columns = []
1180         if self.form.has_key(':columns'):
1181             self.columns = handleListCGIValue(self.form[':columns'])
1182         self.show = ShowDict(self.columns)
1184         # sorting
1185         self.sort = (None, None)
1186         if self.form.has_key(':sort'):
1187             sort = self.form[':sort'].value
1188             if sort.startswith('-'):
1189                 self.sort = ('-', sort[1:])
1190             else:
1191                 self.sort = ('+', sort)
1192         if self.form.has_key(':sortdir'):
1193             self.sort = ('-', self.sort[1])
1195         # grouping
1196         self.group = (None, None)
1197         if self.form.has_key(':group'):
1198             group = self.form[':group'].value
1199             if group.startswith('-'):
1200                 self.group = ('-', group[1:])
1201             else:
1202                 self.group = ('+', group)
1203         if self.form.has_key(':groupdir'):
1204             self.group = ('-', self.group[1])
1206         # filtering
1207         self.filter = []
1208         if self.form.has_key(':filter'):
1209             self.filter = handleListCGIValue(self.form[':filter'])
1210         self.filterspec = {}
1211         if self.classname is not None:
1212             props = self.client.db.getclass(self.classname).getprops()
1213             for name in self.filter:
1214                 if self.form.has_key(name):
1215                     prop = props[name]
1216                     fv = self.form[name]
1217                     if (isinstance(prop, hyperdb.Link) or
1218                             isinstance(prop, hyperdb.Multilink)):
1219                         self.filterspec[name] = handleListCGIValue(fv)
1220                     else:
1221                         self.filterspec[name] = fv.value
1223         # full-text search argument
1224         self.search_text = None
1225         if self.form.has_key(':search_text'):
1226             self.search_text = self.form[':search_text'].value
1228         # pagination - size and start index
1229         # figure batch args
1230         if self.form.has_key(':pagesize'):
1231             self.pagesize = int(self.form[':pagesize'].value)
1232         else:
1233             self.pagesize = 50
1234         if self.form.has_key(':startwith'):
1235             self.startwith = int(self.form[':startwith'].value)
1236         else:
1237             self.startwith = 0
1239     def updateFromURL(self, url):
1240         ''' Parse the URL for query args, and update my attributes using the
1241             values.
1242         ''' 
1243         self.form = {}
1244         for name, value in cgi.parse_qsl(url):
1245             if self.form.has_key(name):
1246                 if isinstance(self.form[name], type([])):
1247                     self.form[name].append(cgi.MiniFieldStorage(name, value))
1248                 else:
1249                     self.form[name] = [self.form[name],
1250                         cgi.MiniFieldStorage(name, value)]
1251             else:
1252                 self.form[name] = cgi.MiniFieldStorage(name, value)
1253         self._post_init()
1255     def update(self, kwargs):
1256         ''' Update my attributes using the keyword args
1257         '''
1258         self.__dict__.update(kwargs)
1259         if kwargs.has_key('columns'):
1260             self.show = ShowDict(self.columns)
1262     def description(self):
1263         ''' Return a description of the request - handle for the page title.
1264         '''
1265         s = [self.client.db.config.TRACKER_NAME]
1266         if self.classname:
1267             if self.client.nodeid:
1268                 s.append('- %s%s'%(self.classname, self.client.nodeid))
1269             else:
1270                 if self.template == 'item':
1271                     s.append('- new %s'%self.classname)
1272                 elif self.template == 'index':
1273                     s.append('- %s index'%self.classname)
1274                 else:
1275                     s.append('- %s %s'%(self.classname, self.template))
1276         else:
1277             s.append('- home')
1278         return ' '.join(s)
1280     def __str__(self):
1281         d = {}
1282         d.update(self.__dict__)
1283         f = ''
1284         for k in self.form.keys():
1285             f += '\n      %r=%r'%(k,handleListCGIValue(self.form[k]))
1286         d['form'] = f
1287         e = ''
1288         for k,v in self.env.items():
1289             e += '\n     %r=%r'%(k, v)
1290         d['env'] = e
1291         return '''
1292 form: %(form)s
1293 url: %(url)r
1294 base: %(base)r
1295 classname: %(classname)r
1296 template: %(template)r
1297 columns: %(columns)r
1298 sort: %(sort)r
1299 group: %(group)r
1300 filter: %(filter)r
1301 search_text: %(search_text)r
1302 pagesize: %(pagesize)r
1303 startwith: %(startwith)r
1304 env: %(env)s
1305 '''%d
1307     def indexargs_form(self, columns=1, sort=1, group=1, filter=1,
1308             filterspec=1):
1309         ''' return the current index args as form elements '''
1310         l = []
1311         s = '<input type="hidden" name="%s" value="%s">'
1312         if columns and self.columns:
1313             l.append(s%(':columns', ','.join(self.columns)))
1314         if sort and self.sort[1] is not None:
1315             if self.sort[0] == '-':
1316                 val = '-'+self.sort[1]
1317             else:
1318                 val = self.sort[1]
1319             l.append(s%(':sort', val))
1320         if group and self.group[1] is not None:
1321             if self.group[0] == '-':
1322                 val = '-'+self.group[1]
1323             else:
1324                 val = self.group[1]
1325             l.append(s%(':group', val))
1326         if filter and self.filter:
1327             l.append(s%(':filter', ','.join(self.filter)))
1328         if filterspec:
1329             for k,v in self.filterspec.items():
1330                 l.append(s%(k, ','.join(v)))
1331         if self.search_text:
1332             l.append(s%(':search_text', self.search_text))
1333         l.append(s%(':pagesize', self.pagesize))
1334         l.append(s%(':startwith', self.startwith))
1335         return '\n'.join(l)
1337     def indexargs_url(self, url, args):
1338         ''' embed the current index args in a URL '''
1339         l = ['%s=%s'%(k,v) for k,v in args.items()]
1340         if self.columns and not args.has_key(':columns'):
1341             l.append(':columns=%s'%(','.join(self.columns)))
1342         if self.sort[1] is not None and not args.has_key(':sort'):
1343             if self.sort[0] == '-':
1344                 val = '-'+self.sort[1]
1345             else:
1346                 val = self.sort[1]
1347             l.append(':sort=%s'%val)
1348         if self.group[1] is not None and not args.has_key(':group'):
1349             if self.group[0] == '-':
1350                 val = '-'+self.group[1]
1351             else:
1352                 val = self.group[1]
1353             l.append(':group=%s'%val)
1354         if self.filter and not args.has_key(':columns'):
1355             l.append(':filter=%s'%(','.join(self.filter)))
1356         for k,v in self.filterspec.items():
1357             if not args.has_key(k):
1358                 l.append('%s=%s'%(k, ','.join(v)))
1359         if self.search_text and not args.has_key(':search_text'):
1360             l.append(':search_text=%s'%self.search_text)
1361         if not args.has_key(':pagesize'):
1362             l.append(':pagesize=%s'%self.pagesize)
1363         if not args.has_key(':startwith'):
1364             l.append(':startwith=%s'%self.startwith)
1365         return '%s?%s'%(url, '&'.join(l))
1366     indexargs_href = indexargs_url
1368     def base_javascript(self):
1369         return '''
1370 <script language="javascript">
1371 submitted = false;
1372 function submit_once() {
1373     if (submitted) {
1374         alert("Your request is being processed.\\nPlease be patient.");
1375         return 0;
1376     }
1377     submitted = true;
1378     return 1;
1381 function help_window(helpurl, width, height) {
1382     HelpWin = window.open('%s/' + helpurl, 'RoundupHelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
1384 </script>
1385 '''%self.base
1387     def batch(self):
1388         ''' Return a batch object for results from the "current search"
1389         '''
1390         filterspec = self.filterspec
1391         sort = self.sort
1392         group = self.group
1394         # get the list of ids we're batching over
1395         klass = self.client.db.getclass(self.classname)
1396         if self.search_text:
1397             matches = self.client.db.indexer.search(
1398                 re.findall(r'\b\w{2,25}\b', self.search_text), klass)
1399         else:
1400             matches = None
1401         l = klass.filter(matches, filterspec, sort, group)
1403         # map the item ids to instances
1404         if self.classname == 'user':
1405             klass = HTMLUser
1406         else:
1407             klass = HTMLItem
1408         l = [klass(self.client, self.classname, item) for item in l]
1410         # return the batch object
1411         return Batch(self.client, l, self.pagesize, self.startwith)
1413 # extend the standard ZTUtils Batch object to remove dependency on
1414 # Acquisition and add a couple of useful methods
1415 class Batch(ZTUtils.Batch):
1416     ''' Use me to turn a list of items, or item ids of a given class, into a
1417         series of batches.
1419         ========= ========================================================
1420         Parameter  Usage
1421         ========= ========================================================
1422         sequence  a list of HTMLItems
1423         size      how big to make the sequence.
1424         start     where to start (0-indexed) in the sequence.
1425         end       where to end (0-indexed) in the sequence.
1426         orphan    if the next batch would contain less items than this
1427                   value, then it is combined with this batch
1428         overlap   the number of items shared between adjacent batches
1429         ========= ========================================================
1431         Attributes: Note that the "start" attribute, unlike the
1432         argument, is a 1-based index (I know, lame).  "first" is the
1433         0-based index.  "length" is the actual number of elements in
1434         the batch.
1436         "sequence_length" is the length of the original, unbatched, sequence.
1437     '''
1438     def __init__(self, client, sequence, size, start, end=0, orphan=0,
1439             overlap=0):
1440         self.client = client
1441         self.last_index = self.last_item = None
1442         self.current_item = None
1443         self.sequence_length = len(sequence)
1444         ZTUtils.Batch.__init__(self, sequence, size, start, end, orphan,
1445             overlap)
1447     # overwrite so we can late-instantiate the HTMLItem instance
1448     def __getitem__(self, index):
1449         if index < 0:
1450             if index + self.end < self.first: raise IndexError, index
1451             return self._sequence[index + self.end]
1452         
1453         if index >= self.length:
1454             raise IndexError, index
1456         # move the last_item along - but only if the fetched index changes
1457         # (for some reason, index 0 is fetched twice)
1458         if index != self.last_index:
1459             self.last_item = self.current_item
1460             self.last_index = index
1462         self.current_item = self._sequence[index + self.first]
1463         return self.current_item
1465     def propchanged(self, property):
1466         ''' Detect if the property marked as being the group property
1467             changed in the last iteration fetch
1468         '''
1469         if (self.last_item is None or
1470                 self.last_item[property] != self.current_item[property]):
1471             return 1
1472         return 0
1474     # override these 'cos we don't have access to acquisition
1475     def previous(self):
1476         if self.start == 1:
1477             return None
1478         return Batch(self.client, self._sequence, self._size,
1479             self.first - self._size + self.overlap, 0, self.orphan,
1480             self.overlap)
1482     def next(self):
1483         try:
1484             self._sequence[self.end]
1485         except IndexError:
1486             return None
1487         return Batch(self.client, self._sequence, self._size,
1488             self.end - self.overlap, 0, self.orphan, self.overlap)
1490 class TemplatingUtils:
1491     ''' Utilities for templating
1492     '''
1493     def __init__(self, client):
1494         self.client = client
1495     def Batch(self, sequence, size, start, end=0, orphan=0, overlap=0):
1496         return Batch(self.client, sequence, size, start, end, orphan,
1497             overlap)