Code

. password edit now has a confirmation field
[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     def confirm(self, size = 30):
775         ''' Render a second form edit field for the property, used for 
776             confirmation that the user typed the password correctly. Generates
777             a field with name "name:confirm".
778         '''
779         return '<input type="password" name="%s:confirm" size="%s">'%(
780             self._name, size)
782 class NumberHTMLProperty(HTMLProperty):
783     def plain(self):
784         ''' Render a "plain" representation of the property
785         '''
786         return str(self._value)
788     def field(self, size = 30):
789         ''' Render a form edit field for the property
790         '''
791         if self._value is None:
792             value = ''
793         else:
794             value = cgi.escape(str(self._value))
795             value = '&quot;'.join(value.split('"'))
796         return '<input name="%s" value="%s" size="%s">'%(self._name, value, size)
798 class BooleanHTMLProperty(HTMLProperty):
799     def plain(self):
800         ''' Render a "plain" representation of the property
801         '''
802         if self.value is None:
803             return ''
804         return self._value and "Yes" or "No"
806     def field(self):
807         ''' Render a form edit field for the property
808         '''
809         checked = self._value and "checked" or ""
810         s = '<input type="radio" name="%s" value="yes" %s>Yes'%(self._name,
811             checked)
812         if checked:
813             checked = ""
814         else:
815             checked = "checked"
816         s += '<input type="radio" name="%s" value="no" %s>No'%(self._name,
817             checked)
818         return s
820 class DateHTMLProperty(HTMLProperty):
821     def plain(self):
822         ''' Render a "plain" representation of the property
823         '''
824         if self._value is None:
825             return ''
826         return str(self._value)
828     def field(self, size = 30):
829         ''' Render a form edit field for the property
830         '''
831         if self._value is None:
832             value = ''
833         else:
834             value = cgi.escape(str(self._value))
835             value = '&quot;'.join(value.split('"'))
836         return '<input name="%s" value="%s" size="%s">'%(self._name, value, size)
838     def reldate(self, pretty=1):
839         ''' Render the interval between the date and now.
841             If the "pretty" flag is true, then make the display pretty.
842         '''
843         if not self._value:
844             return ''
846         # figure the interval
847         interval = date.Date('.') - self._value
848         if pretty:
849             return interval.pretty()
850         return str(interval)
852 class IntervalHTMLProperty(HTMLProperty):
853     def plain(self):
854         ''' Render a "plain" representation of the property
855         '''
856         if self._value is None:
857             return ''
858         return str(self._value)
860     def pretty(self):
861         ''' Render the interval in a pretty format (eg. "yesterday")
862         '''
863         return self._value.pretty()
865     def field(self, size = 30):
866         ''' Render a form edit field for the property
867         '''
868         if self._value is None:
869             value = ''
870         else:
871             value = cgi.escape(str(self._value))
872             value = '&quot;'.join(value.split('"'))
873         return '<input name="%s" value="%s" size="%s">'%(self._name, value, size)
875 class LinkHTMLProperty(HTMLProperty):
876     ''' Link HTMLProperty
877         Include the above as well as being able to access the class
878         information. Stringifying the object itself results in the value
879         from the item being displayed. Accessing attributes of this object
880         result in the appropriate entry from the class being queried for the
881         property accessed (so item/assignedto/name would look up the user
882         entry identified by the assignedto property on item, and then the
883         name property of that user)
884     '''
885     def __getattr__(self, attr):
886         ''' return a new HTMLItem '''
887        #print 'Link.getattr', (self, attr, self._value)
888         if not self._value:
889             raise AttributeError, "Can't access missing value"
890         if self._prop.classname == 'user':
891             klass = HTMLUser
892         else:
893             klass = HTMLItem
894         i = klass(self._client, self._prop.classname, self._value)
895         return getattr(i, attr)
897     def plain(self, escape=0):
898         ''' Render a "plain" representation of the property
899         '''
900         if self._value is None:
901             return ''
902         linkcl = self._db.classes[self._prop.classname]
903         k = linkcl.labelprop(1)
904         value = str(linkcl.get(self._value, k))
905         if escape:
906             value = cgi.escape(value)
907         return value
909     def field(self, showid=0, size=None):
910         ''' Render a form edit field for the property
911         '''
912         linkcl = self._db.getclass(self._prop.classname)
913         if linkcl.getprops().has_key('order'):  
914             sort_on = 'order'  
915         else:  
916             sort_on = linkcl.labelprop()  
917         options = linkcl.filter(None, {}, ('+', sort_on), (None, None))
918         # TODO: make this a field display, not a menu one!
919         l = ['<select name="%s">'%self._name]
920         k = linkcl.labelprop(1)
921         if self._value is None:
922             s = 'selected '
923         else:
924             s = ''
925         l.append(_('<option %svalue="-1">- no selection -</option>')%s)
926         for optionid in options:
927             option = linkcl.get(optionid, k)
928             s = ''
929             if optionid == self._value:
930                 s = 'selected '
931             if showid:
932                 lab = '%s%s: %s'%(self._prop.classname, optionid, option)
933             else:
934                 lab = option
935             if size is not None and len(lab) > size:
936                 lab = lab[:size-3] + '...'
937             lab = cgi.escape(lab)
938             l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
939         l.append('</select>')
940         return '\n'.join(l)
942     def menu(self, size=None, height=None, showid=0, additional=[],
943             **conditions):
944         ''' Render a form select list for this property
945         '''
946         value = self._value
948         # sort function
949         sortfunc = make_sort_function(self._db, self._prop.classname)
951         # force the value to be a single choice
952         if isinstance(value, type('')):
953             value = value[0]
954         linkcl = self._db.getclass(self._prop.classname)
955         l = ['<select name="%s">'%self._name]
956         k = linkcl.labelprop(1)
957         s = ''
958         if value is None:
959             s = 'selected '
960         l.append(_('<option %svalue="-1">- no selection -</option>')%s)
961         if linkcl.getprops().has_key('order'):  
962             sort_on = ('+', 'order')
963         else:  
964             sort_on = ('+', linkcl.labelprop())
965         options = linkcl.filter(None, conditions, sort_on, (None, None))
966         for optionid in options:
967             option = linkcl.get(optionid, k)
968             s = ''
969             if value in [optionid, option]:
970                 s = 'selected '
971             if showid:
972                 lab = '%s%s: %s'%(self._prop.classname, optionid, option)
973             else:
974                 lab = option
975             if size is not None and len(lab) > size:
976                 lab = lab[:size-3] + '...'
977             if additional:
978                 m = []
979                 for propname in additional:
980                     m.append(linkcl.get(optionid, propname))
981                 lab = lab + ' (%s)'%', '.join(map(str, m))
982             lab = cgi.escape(lab)
983             l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
984         l.append('</select>')
985         return '\n'.join(l)
986 #    def checklist(self, ...)
988 class MultilinkHTMLProperty(HTMLProperty):
989     ''' Multilink HTMLProperty
991         Also be iterable, returning a wrapper object like the Link case for
992         each entry in the multilink.
993     '''
994     def __len__(self):
995         ''' length of the multilink '''
996         return len(self._value)
998     def __getattr__(self, attr):
999         ''' no extended attribute accesses make sense here '''
1000         raise AttributeError, attr
1002     def __getitem__(self, num):
1003         ''' iterate and return a new HTMLItem
1004         '''
1005        #print 'Multi.getitem', (self, num)
1006         value = self._value[num]
1007         if self._prop.classname == 'user':
1008             klass = HTMLUser
1009         else:
1010             klass = HTMLItem
1011         return klass(self._client, self._prop.classname, value)
1013     def __contains__(self, value):
1014         ''' Support the "in" operator
1015         '''
1016         return value in self._value
1018     def reverse(self):
1019         ''' return the list in reverse order
1020         '''
1021         l = self._value[:]
1022         l.reverse()
1023         if self._prop.classname == 'user':
1024             klass = HTMLUser
1025         else:
1026             klass = HTMLItem
1027         return [klass(self._client, self._prop.classname, value) for value in l]
1029     def plain(self, escape=0):
1030         ''' Render a "plain" representation of the property
1031         '''
1032         linkcl = self._db.classes[self._prop.classname]
1033         k = linkcl.labelprop(1)
1034         labels = []
1035         for v in self._value:
1036             labels.append(linkcl.get(v, k))
1037         value = ', '.join(labels)
1038         if escape:
1039             value = cgi.escape(value)
1040         return value
1042     def field(self, size=30, showid=0):
1043         ''' Render a form edit field for the property
1044         '''
1045         sortfunc = make_sort_function(self._db, self._prop.classname)
1046         linkcl = self._db.getclass(self._prop.classname)
1047         value = self._value[:]
1048         if value:
1049             value.sort(sortfunc)
1050         # map the id to the label property
1051         if not linkcl.getkey():
1052             showid=1
1053         if not showid:
1054             k = linkcl.labelprop(1)
1055             value = [linkcl.get(v, k) for v in value]
1056         value = cgi.escape(','.join(value))
1057         return '<input name="%s" size="%s" value="%s">'%(self._name, size, value)
1059     def menu(self, size=None, height=None, showid=0, additional=[],
1060             **conditions):
1061         ''' Render a form select list for this property
1062         '''
1063         value = self._value
1065         # sort function
1066         sortfunc = make_sort_function(self._db, self._prop.classname)
1068         linkcl = self._db.getclass(self._prop.classname)
1069         if linkcl.getprops().has_key('order'):  
1070             sort_on = ('+', 'order')
1071         else:  
1072             sort_on = ('+', linkcl.labelprop())
1073         options = linkcl.filter(None, conditions, sort_on, (None,None)) 
1074         height = height or min(len(options), 7)
1075         l = ['<select multiple name="%s" size="%s">'%(self._name, height)]
1076         k = linkcl.labelprop(1)
1077         for optionid in options:
1078             option = linkcl.get(optionid, k)
1079             s = ''
1080             if optionid in value or option in value:
1081                 s = 'selected '
1082             if showid:
1083                 lab = '%s%s: %s'%(self._prop.classname, optionid, option)
1084             else:
1085                 lab = option
1086             if size is not None and len(lab) > size:
1087                 lab = lab[:size-3] + '...'
1088             if additional:
1089                 m = []
1090                 for propname in additional:
1091                     m.append(linkcl.get(optionid, propname))
1092                 lab = lab + ' (%s)'%', '.join(m)
1093             lab = cgi.escape(lab)
1094             l.append('<option %svalue="%s">%s</option>'%(s, optionid,
1095                 lab))
1096         l.append('</select>')
1097         return '\n'.join(l)
1099 # set the propclasses for HTMLItem
1100 propclasses = (
1101     (hyperdb.String, StringHTMLProperty),
1102     (hyperdb.Number, NumberHTMLProperty),
1103     (hyperdb.Boolean, BooleanHTMLProperty),
1104     (hyperdb.Date, DateHTMLProperty),
1105     (hyperdb.Interval, IntervalHTMLProperty),
1106     (hyperdb.Password, PasswordHTMLProperty),
1107     (hyperdb.Link, LinkHTMLProperty),
1108     (hyperdb.Multilink, MultilinkHTMLProperty),
1111 def make_sort_function(db, classname):
1112     '''Make a sort function for a given class
1113     '''
1114     linkcl = db.getclass(classname)
1115     if linkcl.getprops().has_key('order'):
1116         sort_on = 'order'
1117     else:
1118         sort_on = linkcl.labelprop()
1119     def sortfunc(a, b, linkcl=linkcl, sort_on=sort_on):
1120         return cmp(linkcl.get(a, sort_on), linkcl.get(b, sort_on))
1121     return sortfunc
1123 def handleListCGIValue(value):
1124     ''' Value is either a single item or a list of items. Each item has a
1125         .value that we're actually interested in.
1126     '''
1127     if isinstance(value, type([])):
1128         return [value.value for value in value]
1129     else:
1130         value = value.value.strip()
1131         if not value:
1132             return []
1133         return value.split(',')
1135 class ShowDict:
1136     ''' A convenience access to the :columns index parameters
1137     '''
1138     def __init__(self, columns):
1139         self.columns = {}
1140         for col in columns:
1141             self.columns[col] = 1
1142     def __getitem__(self, name):
1143         return self.columns.has_key(name)
1145 class HTMLRequest:
1146     ''' The *request*, holding the CGI form and environment.
1148         "form" the CGI form as a cgi.FieldStorage
1149         "env" the CGI environment variables
1150         "url" the current URL path for this request
1151         "base" the base URL for this instance
1152         "user" a HTMLUser instance for this user
1153         "classname" the current classname (possibly None)
1154         "template" the current template (suffix, also possibly None)
1156         Index args:
1157         "columns" dictionary of the columns to display in an index page
1158         "show" a convenience access to columns - request/show/colname will
1159                be true if the columns should be displayed, false otherwise
1160         "sort" index sort column (direction, column name)
1161         "group" index grouping property (direction, column name)
1162         "filter" properties to filter the index on
1163         "filterspec" values to filter the index on
1164         "search_text" text to perform a full-text search on for an index
1166     '''
1167     def __init__(self, client):
1168         self.client = client
1170         # easier access vars
1171         self.form = client.form
1172         self.env = client.env
1173         self.base = client.base
1174         self.url = client.url
1175         self.user = HTMLUser(client, 'user', client.userid)
1177         # store the current class name and action
1178         self.classname = client.classname
1179         self.template = client.template
1181         self._post_init()
1183     def _post_init(self):
1184         ''' Set attributes based on self.form
1185         '''
1186         # extract the index display information from the form
1187         self.columns = []
1188         if self.form.has_key(':columns'):
1189             self.columns = handleListCGIValue(self.form[':columns'])
1190         self.show = ShowDict(self.columns)
1192         # sorting
1193         self.sort = (None, None)
1194         if self.form.has_key(':sort'):
1195             sort = self.form[':sort'].value
1196             if sort.startswith('-'):
1197                 self.sort = ('-', sort[1:])
1198             else:
1199                 self.sort = ('+', sort)
1200         if self.form.has_key(':sortdir'):
1201             self.sort = ('-', self.sort[1])
1203         # grouping
1204         self.group = (None, None)
1205         if self.form.has_key(':group'):
1206             group = self.form[':group'].value
1207             if group.startswith('-'):
1208                 self.group = ('-', group[1:])
1209             else:
1210                 self.group = ('+', group)
1211         if self.form.has_key(':groupdir'):
1212             self.group = ('-', self.group[1])
1214         # filtering
1215         self.filter = []
1216         if self.form.has_key(':filter'):
1217             self.filter = handleListCGIValue(self.form[':filter'])
1218         self.filterspec = {}
1219         if self.classname is not None:
1220             props = self.client.db.getclass(self.classname).getprops()
1221             for name in self.filter:
1222                 if self.form.has_key(name):
1223                     prop = props[name]
1224                     fv = self.form[name]
1225                     if (isinstance(prop, hyperdb.Link) or
1226                             isinstance(prop, hyperdb.Multilink)):
1227                         self.filterspec[name] = handleListCGIValue(fv)
1228                     else:
1229                         self.filterspec[name] = fv.value
1231         # full-text search argument
1232         self.search_text = None
1233         if self.form.has_key(':search_text'):
1234             self.search_text = self.form[':search_text'].value
1236         # pagination - size and start index
1237         # figure batch args
1238         if self.form.has_key(':pagesize'):
1239             self.pagesize = int(self.form[':pagesize'].value)
1240         else:
1241             self.pagesize = 50
1242         if self.form.has_key(':startwith'):
1243             self.startwith = int(self.form[':startwith'].value)
1244         else:
1245             self.startwith = 0
1247     def updateFromURL(self, url):
1248         ''' Parse the URL for query args, and update my attributes using the
1249             values.
1250         ''' 
1251         self.form = {}
1252         for name, value in cgi.parse_qsl(url):
1253             if self.form.has_key(name):
1254                 if isinstance(self.form[name], type([])):
1255                     self.form[name].append(cgi.MiniFieldStorage(name, value))
1256                 else:
1257                     self.form[name] = [self.form[name],
1258                         cgi.MiniFieldStorage(name, value)]
1259             else:
1260                 self.form[name] = cgi.MiniFieldStorage(name, value)
1261         self._post_init()
1263     def update(self, kwargs):
1264         ''' Update my attributes using the keyword args
1265         '''
1266         self.__dict__.update(kwargs)
1267         if kwargs.has_key('columns'):
1268             self.show = ShowDict(self.columns)
1270     def description(self):
1271         ''' Return a description of the request - handle for the page title.
1272         '''
1273         s = [self.client.db.config.TRACKER_NAME]
1274         if self.classname:
1275             if self.client.nodeid:
1276                 s.append('- %s%s'%(self.classname, self.client.nodeid))
1277             else:
1278                 if self.template == 'item':
1279                     s.append('- new %s'%self.classname)
1280                 elif self.template == 'index':
1281                     s.append('- %s index'%self.classname)
1282                 else:
1283                     s.append('- %s %s'%(self.classname, self.template))
1284         else:
1285             s.append('- home')
1286         return ' '.join(s)
1288     def __str__(self):
1289         d = {}
1290         d.update(self.__dict__)
1291         f = ''
1292         for k in self.form.keys():
1293             f += '\n      %r=%r'%(k,handleListCGIValue(self.form[k]))
1294         d['form'] = f
1295         e = ''
1296         for k,v in self.env.items():
1297             e += '\n     %r=%r'%(k, v)
1298         d['env'] = e
1299         return '''
1300 form: %(form)s
1301 url: %(url)r
1302 base: %(base)r
1303 classname: %(classname)r
1304 template: %(template)r
1305 columns: %(columns)r
1306 sort: %(sort)r
1307 group: %(group)r
1308 filter: %(filter)r
1309 search_text: %(search_text)r
1310 pagesize: %(pagesize)r
1311 startwith: %(startwith)r
1312 env: %(env)s
1313 '''%d
1315     def indexargs_form(self, columns=1, sort=1, group=1, filter=1,
1316             filterspec=1):
1317         ''' return the current index args as form elements '''
1318         l = []
1319         s = '<input type="hidden" name="%s" value="%s">'
1320         if columns and self.columns:
1321             l.append(s%(':columns', ','.join(self.columns)))
1322         if sort and self.sort[1] is not None:
1323             if self.sort[0] == '-':
1324                 val = '-'+self.sort[1]
1325             else:
1326                 val = self.sort[1]
1327             l.append(s%(':sort', val))
1328         if group and self.group[1] is not None:
1329             if self.group[0] == '-':
1330                 val = '-'+self.group[1]
1331             else:
1332                 val = self.group[1]
1333             l.append(s%(':group', val))
1334         if filter and self.filter:
1335             l.append(s%(':filter', ','.join(self.filter)))
1336         if filterspec:
1337             for k,v in self.filterspec.items():
1338                 l.append(s%(k, ','.join(v)))
1339         if self.search_text:
1340             l.append(s%(':search_text', self.search_text))
1341         l.append(s%(':pagesize', self.pagesize))
1342         l.append(s%(':startwith', self.startwith))
1343         return '\n'.join(l)
1345     def indexargs_url(self, url, args):
1346         ''' embed the current index args in a URL '''
1347         l = ['%s=%s'%(k,v) for k,v in args.items()]
1348         if self.columns and not args.has_key(':columns'):
1349             l.append(':columns=%s'%(','.join(self.columns)))
1350         if self.sort[1] is not None and not args.has_key(':sort'):
1351             if self.sort[0] == '-':
1352                 val = '-'+self.sort[1]
1353             else:
1354                 val = self.sort[1]
1355             l.append(':sort=%s'%val)
1356         if self.group[1] is not None and not args.has_key(':group'):
1357             if self.group[0] == '-':
1358                 val = '-'+self.group[1]
1359             else:
1360                 val = self.group[1]
1361             l.append(':group=%s'%val)
1362         if self.filter and not args.has_key(':columns'):
1363             l.append(':filter=%s'%(','.join(self.filter)))
1364         for k,v in self.filterspec.items():
1365             if not args.has_key(k):
1366                 l.append('%s=%s'%(k, ','.join(v)))
1367         if self.search_text and not args.has_key(':search_text'):
1368             l.append(':search_text=%s'%self.search_text)
1369         if not args.has_key(':pagesize'):
1370             l.append(':pagesize=%s'%self.pagesize)
1371         if not args.has_key(':startwith'):
1372             l.append(':startwith=%s'%self.startwith)
1373         return '%s?%s'%(url, '&'.join(l))
1374     indexargs_href = indexargs_url
1376     def base_javascript(self):
1377         return '''
1378 <script language="javascript">
1379 submitted = false;
1380 function submit_once() {
1381     if (submitted) {
1382         alert("Your request is being processed.\\nPlease be patient.");
1383         return 0;
1384     }
1385     submitted = true;
1386     return 1;
1389 function help_window(helpurl, width, height) {
1390     HelpWin = window.open('%s/' + helpurl, 'RoundupHelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
1392 </script>
1393 '''%self.base
1395     def batch(self):
1396         ''' Return a batch object for results from the "current search"
1397         '''
1398         filterspec = self.filterspec
1399         sort = self.sort
1400         group = self.group
1402         # get the list of ids we're batching over
1403         klass = self.client.db.getclass(self.classname)
1404         if self.search_text:
1405             matches = self.client.db.indexer.search(
1406                 re.findall(r'\b\w{2,25}\b', self.search_text), klass)
1407         else:
1408             matches = None
1409         l = klass.filter(matches, filterspec, sort, group)
1411         # map the item ids to instances
1412         if self.classname == 'user':
1413             klass = HTMLUser
1414         else:
1415             klass = HTMLItem
1416         l = [klass(self.client, self.classname, item) for item in l]
1418         # return the batch object
1419         return Batch(self.client, l, self.pagesize, self.startwith)
1421 # extend the standard ZTUtils Batch object to remove dependency on
1422 # Acquisition and add a couple of useful methods
1423 class Batch(ZTUtils.Batch):
1424     ''' Use me to turn a list of items, or item ids of a given class, into a
1425         series of batches.
1427         ========= ========================================================
1428         Parameter  Usage
1429         ========= ========================================================
1430         sequence  a list of HTMLItems
1431         size      how big to make the sequence.
1432         start     where to start (0-indexed) in the sequence.
1433         end       where to end (0-indexed) in the sequence.
1434         orphan    if the next batch would contain less items than this
1435                   value, then it is combined with this batch
1436         overlap   the number of items shared between adjacent batches
1437         ========= ========================================================
1439         Attributes: Note that the "start" attribute, unlike the
1440         argument, is a 1-based index (I know, lame).  "first" is the
1441         0-based index.  "length" is the actual number of elements in
1442         the batch.
1444         "sequence_length" is the length of the original, unbatched, sequence.
1445     '''
1446     def __init__(self, client, sequence, size, start, end=0, orphan=0,
1447             overlap=0):
1448         self.client = client
1449         self.last_index = self.last_item = None
1450         self.current_item = None
1451         self.sequence_length = len(sequence)
1452         ZTUtils.Batch.__init__(self, sequence, size, start, end, orphan,
1453             overlap)
1455     # overwrite so we can late-instantiate the HTMLItem instance
1456     def __getitem__(self, index):
1457         if index < 0:
1458             if index + self.end < self.first: raise IndexError, index
1459             return self._sequence[index + self.end]
1460         
1461         if index >= self.length:
1462             raise IndexError, index
1464         # move the last_item along - but only if the fetched index changes
1465         # (for some reason, index 0 is fetched twice)
1466         if index != self.last_index:
1467             self.last_item = self.current_item
1468             self.last_index = index
1470         self.current_item = self._sequence[index + self.first]
1471         return self.current_item
1473     def propchanged(self, property):
1474         ''' Detect if the property marked as being the group property
1475             changed in the last iteration fetch
1476         '''
1477         if (self.last_item is None or
1478                 self.last_item[property] != self.current_item[property]):
1479             return 1
1480         return 0
1482     # override these 'cos we don't have access to acquisition
1483     def previous(self):
1484         if self.start == 1:
1485             return None
1486         return Batch(self.client, self._sequence, self._size,
1487             self.first - self._size + self.overlap, 0, self.orphan,
1488             self.overlap)
1490     def next(self):
1491         try:
1492             self._sequence[self.end]
1493         except IndexError:
1494             return None
1495         return Batch(self.client, self._sequence, self._size,
1496             self.end - self.overlap, 0, self.orphan, self.overlap)
1498 class TemplatingUtils:
1499     ''' Utilities for templating
1500     '''
1501     def __init__(self, client):
1502         self.client = client
1503     def Batch(self, sequence, size, start, end=0, orphan=0, overlap=0):
1504         return Batch(self.client, sequence, size, start, end, orphan,
1505             overlap)