Code

forward-port of fix from maint-0-6
[roundup.git] / roundup / cgi / templating.py
1 """Implements the API used in the HTML templating for the web interface.
2 """
3 __docformat__ = 'restructuredtext'
5 from __future__ import nested_scopes
7 import sys, cgi, urllib, os, re, os.path, time, errno, mimetypes
9 from roundup import hyperdb, date, rcsv
10 from roundup.i18n import _
12 try:
13     import cPickle as pickle
14 except ImportError:
15     import pickle
16 try:
17     import cStringIO as StringIO
18 except ImportError:
19     import StringIO
20 try:
21     import StructuredText
22 except ImportError:
23     StructuredText = None
25 # bring in the templating support
26 from roundup.cgi.PageTemplates import PageTemplate
27 from roundup.cgi.PageTemplates.Expressions import getEngine
28 from roundup.cgi.TAL.TALInterpreter import TALInterpreter
29 from roundup.cgi import ZTUtils
31 class NoTemplate(Exception):
32     pass
34 class Unauthorised(Exception):
35     def __init__(self, action, klass):
36         self.action = action
37         self.klass = klass
38     def __str__(self):
39         return 'You are not allowed to %s items of class %s'%(self.action,
40             self.klass)
42 def find_template(dir, name, extension):
43     ''' Find a template in the nominated dir
44     '''
45     # find the source
46     if extension:
47         filename = '%s.%s'%(name, extension)
48     else:
49         filename = name
51     # try old-style
52     src = os.path.join(dir, filename)
53     if os.path.exists(src):
54         return (src, filename)
56     # try with a .html extension (new-style)
57     filename = filename + '.html'
58     src = os.path.join(dir, filename)
59     if os.path.exists(src):
60         return (src, filename)
62     # no extension == no generic template is possible
63     if not extension:
64         raise NoTemplate, 'Template file "%s" doesn\'t exist'%name
66     # try for a _generic template
67     generic = '_generic.%s'%extension
68     src = os.path.join(dir, generic)
69     if os.path.exists(src):
70         return (src, generic)
72     # finally, try _generic.html
73     generic = generic + '.html'
74     src = os.path.join(dir, generic)
75     if os.path.exists(src):
76         return (src, generic)
78     raise NoTemplate, 'No template file exists for templating "%s" '\
79         'with template "%s" (neither "%s" nor "%s")'%(name, extension,
80         filename, generic)
82 class Templates:
83     templates = {}
85     def __init__(self, dir):
86         self.dir = dir
88     def precompileTemplates(self):
89         ''' Go through a directory and precompile all the templates therein
90         '''
91         for filename in os.listdir(self.dir):
92             if os.path.isdir(filename): continue
93             if '.' in filename:
94                 name, extension = filename.split('.')
95                 self.get(name, extension)
96             else:
97                 self.get(filename, None)
99     def get(self, name, extension=None):
100         ''' Interface to get a template, possibly loading a compiled template.
102             "name" and "extension" indicate the template we're after, which in
103             most cases will be "name.extension". If "extension" is None, then
104             we look for a template just called "name" with no extension.
106             If the file "name.extension" doesn't exist, we look for
107             "_generic.extension" as a fallback.
108         '''
109         # default the name to "home"
110         if name is None:
111             name = 'home'
112         elif extension is None and '.' in name:
113             # split name
114             name, extension = name.split('.')
116         # find the source
117         src, filename = find_template(self.dir, name, extension)
119         # has it changed?
120         try:
121             stime = os.stat(src)[os.path.stat.ST_MTIME]
122         except os.error, error:
123             if error.errno != errno.ENOENT:
124                 raise
126         if self.templates.has_key(src) and \
127                 stime < self.templates[src].mtime:
128             # compiled template is up to date
129             return self.templates[src]
131         # compile the template
132         self.templates[src] = pt = RoundupPageTemplate()
133         # use pt_edit so we can pass the content_type guess too
134         content_type = mimetypes.guess_type(filename)[0] or 'text/html'
135         pt.pt_edit(open(src).read(), content_type)
136         pt.id = filename
137         pt.mtime = time.time()
138         return pt
140     def __getitem__(self, name):
141         name, extension = os.path.splitext(name)
142         if extension:
143             extension = extension[1:]
144         try:
145             return self.get(name, extension)
146         except NoTemplate, message:
147             raise KeyError, message
149 class RoundupPageTemplate(PageTemplate.PageTemplate):
150     '''A Roundup-specific PageTemplate.
152     Interrogate the client to set up the various template variables to
153     be available:
155     *context*
156      this is one of three things:
158      1. None - we're viewing a "home" page
159      2. The current class of item being displayed. This is an HTMLClass
160         instance.
161      3. The current item from the database, if we're viewing a specific
162         item, as an HTMLItem instance.
163     *request*
164       Includes information about the current request, including:
166        - the url
167        - the current index information (``filterspec``, ``filter`` args,
168          ``properties``, etc) parsed out of the form. 
169        - methods for easy filterspec link generation
170        - *user*, the current user node as an HTMLItem instance
171        - *form*, the current CGI form information as a FieldStorage
172     *config*
173       The current tracker config.
174     *db*
175       The current database, used to access arbitrary database items.
176     *utils*
177       This is a special class that has its base in the TemplatingUtils
178       class in this file. If the tracker interfaces module defines a
179       TemplatingUtils class then it is mixed in, overriding the methods
180       in the base class.
181     '''
182     def getContext(self, client, classname, request):
183         # construct the TemplatingUtils class
184         utils = TemplatingUtils
185         if hasattr(client.instance.interfaces, 'TemplatingUtils'):
186             class utils(client.instance.interfaces.TemplatingUtils, utils):
187                 pass
189         c = {
190              'options': {},
191              'nothing': None,
192              'request': request,
193              'db': HTMLDatabase(client),
194              'config': client.instance.config,
195              'tracker': client.instance,
196              'utils': utils(client),
197              'templates': Templates(client.instance.config.TEMPLATES),
198         }
199         # add in the item if there is one
200         if client.nodeid:
201             if classname == 'user':
202                 c['context'] = HTMLUser(client, classname, client.nodeid,
203                     anonymous=1)
204             else:
205                 c['context'] = HTMLItem(client, classname, client.nodeid,
206                     anonymous=1)
207         elif client.db.classes.has_key(classname):
208             if classname == 'user':
209                 c['context'] = HTMLUserClass(client, classname, anonymous=1)
210             else:
211                 c['context'] = HTMLClass(client, classname, anonymous=1)
212         return c
214     def render(self, client, classname, request, **options):
215         """Render this Page Template"""
217         if not self._v_cooked:
218             self._cook()
220         __traceback_supplement__ = (PageTemplate.PageTemplateTracebackSupplement, self)
222         if self._v_errors:
223             raise PageTemplate.PTRuntimeError, \
224                 'Page Template %s has errors.'%self.id
226         # figure the context
227         classname = classname or client.classname
228         request = request or HTMLRequest(client)
229         c = self.getContext(client, classname, request)
230         c.update({'options': options})
232         # and go
233         output = StringIO.StringIO()
234         TALInterpreter(self._v_program, self.macros,
235             getEngine().getContext(c), output, tal=1, strictinsert=0)()
236         return output.getvalue()
238     def __repr__(self):
239         return '<Roundup PageTemplate %r>'%self.id
241 class HTMLDatabase:
242     ''' Return HTMLClasses for valid class fetches
243     '''
244     def __init__(self, client):
245         self._client = client
246         self._db = client.db
248         # we want config to be exposed
249         self.config = client.db.config
251     def __getitem__(self, item, desre=re.compile(r'(?P<cl>\w+)(?P<id>[-\d]+)')):
252         # check to see if we're actually accessing an item
253         m = desre.match(item)
254         if m:
255             self._client.db.getclass(m.group('cl'))
256             return HTMLItem(self._client, m.group('cl'), m.group('id'))
257         else:
258             self._client.db.getclass(item)
259             if item == 'user':
260                 return HTMLUserClass(self._client, item)
261             return HTMLClass(self._client, item)
263     def __getattr__(self, attr):
264         try:
265             return self[attr]
266         except KeyError:
267             raise AttributeError, attr
269     def classes(self):
270         l = self._client.db.classes.keys()
271         l.sort()
272         r = []
273         for item in l:
274             if item == 'user':
275                 m.append(HTMLUserClass(self._client, item))
276             m.append(HTMLClass(self._client, item))
277         return r
279 def lookupIds(db, prop, ids, fail_ok=False, num_re=re.compile('-?\d+')):
280     ''' "fail_ok" should be specified if we wish to pass through bad values
281         (most likely form values that we wish to represent back to the user)
282     '''
283     cl = db.getclass(prop.classname)
284     l = []
285     for entry in ids:
286         if num_re.match(entry):
287             l.append(entry)
288         else:
289             try:
290                 l.append(cl.lookup(entry))
291             except (TypeError, KeyError):
292                 if fail_ok:
293                     # pass through the bad value
294                     l.append(entry)
295     return l
297 class HTMLPermissions:
298     ''' Helpers that provide answers to commonly asked Permission questions.
299     '''
300     def is_edit_ok(self):
301         ''' Is the user allowed to Edit the current class?
302         '''
303         return self._db.security.hasPermission('Edit', self._client.userid,
304             self._classname)
306     def is_view_ok(self):
307         ''' Is the user allowed to View the current class?
308         '''
309         return self._db.security.hasPermission('View', self._client.userid,
310             self._classname)
312     def is_only_view_ok(self):
313         ''' Is the user only allowed to View (ie. not Edit) the current class?
314         '''
315         return self.is_view_ok() and not self.is_edit_ok()
317     def view_check(self):
318         ''' Raise the Unauthorised exception if the user's not permitted to
319             view this class.
320         '''
321         if not self.is_view_ok():
322             raise Unauthorised("view", self._classname)
324     def edit_check(self):
325         ''' Raise the Unauthorised exception if the user's not permitted to
326             edit this class.
327         '''
328         if not self.is_edit_ok():
329             raise Unauthorised("edit", self._classname)
331 def input_html4(**attrs):
332     """Generate an 'input' (html4) element with given attributes"""
333     return '<input %s>'%' '.join(['%s="%s"'%item for item in attrs.items()])
335 def input_xhtml(**attrs):
336     """Generate an 'input' (xhtml) element with given attributes"""
337     return '<input %s/>'%' '.join(['%s="%s"'%item for item in attrs.items()])
339 class HTMLInputMixin:
340     ''' requires a _client property '''
341     def __init__(self):
342         html_version = 'html4'
343         if hasattr(self._client.instance.config, 'HTML_VERSION'):
344             html_version = self._client.instance.config.HTML_VERSION
345         if html_version == 'xhtml':
346             self.input = input_xhtml
347         else:
348             self.input = input_html4
350 class HTMLClass(HTMLInputMixin, HTMLPermissions):
351     ''' Accesses through a class (either through *class* or *db.<classname>*)
352     '''
353     def __init__(self, client, classname, anonymous=0):
354         self._client = client
355         self._db = client.db
356         self._anonymous = anonymous
358         # we want classname to be exposed, but _classname gives a
359         # consistent API for extending Class/Item
360         self._classname = self.classname = classname
361         self._klass = self._db.getclass(self.classname)
362         self._props = self._klass.getprops()
364         HTMLInputMixin.__init__(self)
366     def __repr__(self):
367         return '<HTMLClass(0x%x) %s>'%(id(self), self.classname)
369     def __getitem__(self, item):
370         ''' return an HTMLProperty instance
371         '''
372        #print 'HTMLClass.getitem', (self, item)
374         # we don't exist
375         if item == 'id':
376             return None
378         # get the property
379         prop = self._props[item]
381         # look up the correct HTMLProperty class
382         form = self._client.form
383         for klass, htmlklass in propclasses:
384             if not isinstance(prop, klass):
385                 continue
386             if form.has_key(item):
387                 if isinstance(prop, hyperdb.Multilink):
388                     value = lookupIds(self._db, prop,
389                         handleListCGIValue(form[item]), fail_ok=True)
390                 elif isinstance(prop, hyperdb.Link):
391                     value = form[item].value.strip()
392                     if value:
393                         value = lookupIds(self._db, prop, [value],
394                             fail_ok=True)[0]
395                     else:
396                         value = None
397                 else:
398                     value = form[item].value.strip() or None
399             else:
400                 if isinstance(prop, hyperdb.Multilink):
401                     value = []
402                 else:
403                     value = None
404             return htmlklass(self._client, self._classname, '', prop, item,
405                 value, self._anonymous)
407         # no good
408         raise KeyError, item
410     def __getattr__(self, attr):
411         ''' convenience access '''
412         try:
413             return self[attr]
414         except KeyError:
415             raise AttributeError, attr
417     def designator(self):
418         ''' Return this class' designator (classname) '''
419         return self._classname
421     def getItem(self, itemid, num_re=re.compile('-?\d+')):
422         ''' Get an item of this class by its item id.
423         '''
424         # make sure we're looking at an itemid
425         if not isinstance(itemid, type(1)) and not num_re.match(itemid):
426             itemid = self._klass.lookup(itemid)
428         if self.classname == 'user':
429             klass = HTMLUser
430         else:
431             klass = HTMLItem
433         return klass(self._client, self.classname, itemid)
435     def properties(self, sort=1):
436         ''' Return HTMLProperty for all of this class' properties.
437         '''
438         l = []
439         for name, prop in self._props.items():
440             for klass, htmlklass in propclasses:
441                 if isinstance(prop, hyperdb.Multilink):
442                     value = []
443                 else:
444                     value = None
445                 if isinstance(prop, klass):
446                     l.append(htmlklass(self._client, self._classname, '',
447                         prop, name, value, self._anonymous))
448         if sort:
449             l.sort(lambda a,b:cmp(a._name, b._name))
450         return l
452     def list(self, sort_on=None):
453         ''' List all items in this class.
454         '''
455         if self.classname == 'user':
456             klass = HTMLUser
457         else:
458             klass = HTMLItem
460         # get the list and sort it nicely
461         l = self._klass.list()
462         sortfunc = make_sort_function(self._db, self.classname, sort_on)
463         l.sort(sortfunc)
465         l = [klass(self._client, self.classname, x) for x in l]
466         return l
468     def csv(self):
469         ''' Return the items of this class as a chunk of CSV text.
470         '''
471         if rcsv.error:
472             return rcsv.error
474         props = self.propnames()
475         s = StringIO.StringIO()
476         writer = rcsv.writer(s, rcsv.comma_separated)
477         writer.writerow(props)
478         for nodeid in self._klass.list():
479             l = []
480             for name in props:
481                 value = self._klass.get(nodeid, name)
482                 if value is None:
483                     l.append('')
484                 elif isinstance(value, type([])):
485                     l.append(':'.join(map(str, value)))
486                 else:
487                     l.append(str(self._klass.get(nodeid, name)))
488             writer.writerow(l)
489         return s.getvalue()
491     def propnames(self):
492         ''' Return the list of the names of the properties of this class.
493         '''
494         idlessprops = self._klass.getprops(protected=0).keys()
495         idlessprops.sort()
496         return ['id'] + idlessprops
498     def filter(self, request=None, filterspec={}, sort=(None,None),
499             group=(None,None)):
500         ''' Return a list of items from this class, filtered and sorted
501             by the current requested filterspec/filter/sort/group args
503             "request" takes precedence over the other three arguments.
504         '''
505         if request is not None:
506             filterspec = request.filterspec
507             sort = request.sort
508             group = request.group
509         if self.classname == 'user':
510             klass = HTMLUser
511         else:
512             klass = HTMLItem
513         l = [klass(self._client, self.classname, x)
514              for x in self._klass.filter(None, filterspec, sort, group)]
515         return l
517     def classhelp(self, properties=None, label='(list)', width='500',
518             height='400', property=''):
519         ''' Pop up a javascript window with class help
521             This generates a link to a popup window which displays the 
522             properties indicated by "properties" of the class named by
523             "classname". The "properties" should be a comma-separated list
524             (eg. 'id,name,description'). Properties defaults to all the
525             properties of a class (excluding id, creator, created and
526             activity).
528             You may optionally override the label displayed, the width and
529             height. The popup window will be resizable and scrollable.
531             If the "property" arg is given, it's passed through to the
532             javascript help_window function.
533         '''
534         if properties is None:
535             properties = self._klass.getprops(protected=0).keys()
536             properties.sort()
537             properties = ','.join(properties)
538         if property:
539             property = '&amp;property=%s'%property
540         return '<a class="classhelp" href="javascript:help_window(\'%s?'\
541             '@startwith=0&amp;@template=help&amp;properties=%s%s\', \'%s\', \
542             \'%s\')">%s</a>'%(self.classname, properties, property, width,
543             height, label)
545     def submit(self, label="Submit New Entry"):
546         ''' Generate a submit button (and action hidden element)
547         '''
548         self.view_check()
549         if self.is_edit_ok():
550             return self.input(type="hidden",name="@action",value="new") + \
551                    '\n' + self.input(type="submit",name="submit",value=label)
552         return ''
554     def history(self):
555         self.view_check()
556         return 'New node - no history'
558     def renderWith(self, name, **kwargs):
559         ''' Render this class with the given template.
560         '''
561         # create a new request and override the specified args
562         req = HTMLRequest(self._client)
563         req.classname = self.classname
564         req.update(kwargs)
566         # new template, using the specified classname and request
567         pt = Templates(self._db.config.TEMPLATES).get(self.classname, name)
569         # use our fabricated request
570         args = {
571             'ok_message': self._client.ok_message,
572             'error_message': self._client.error_message
573         }
574         return pt.render(self._client, self.classname, req, **args)
576 class HTMLItem(HTMLInputMixin, HTMLPermissions):
577     ''' Accesses through an *item*
578     '''
579     def __init__(self, client, classname, nodeid, anonymous=0):
580         self._client = client
581         self._db = client.db
582         self._classname = classname
583         self._nodeid = nodeid
584         self._klass = self._db.getclass(classname)
585         self._props = self._klass.getprops()
587         # do we prefix the form items with the item's identification?
588         self._anonymous = anonymous
590         HTMLInputMixin.__init__(self)
592     def __repr__(self):
593         return '<HTMLItem(0x%x) %s %s>'%(id(self), self._classname,
594             self._nodeid)
596     def __getitem__(self, item):
597         ''' return an HTMLProperty instance
598         '''
599         #print 'HTMLItem.getitem', (self, item)
600         if item == 'id':
601             return self._nodeid
603         # get the property
604         prop = self._props[item]
606         # get the value, handling missing values
607         value = None
608         if int(self._nodeid) > 0:
609             value = self._klass.get(self._nodeid, item, None)
610         if value is None:
611             if isinstance(self._props[item], hyperdb.Multilink):
612                 value = []
614         # look up the correct HTMLProperty class
615         for klass, htmlklass in propclasses:
616             if isinstance(prop, klass):
617                 return htmlklass(self._client, self._classname,
618                     self._nodeid, prop, item, value, self._anonymous)
620         raise KeyError, item
622     def __getattr__(self, attr):
623         ''' convenience access to properties '''
624         try:
625             return self[attr]
626         except KeyError:
627             raise AttributeError, attr
629     def designator(self):
630         """Return this item's designator (classname + id)."""
631         return '%s%s'%(self._classname, self._nodeid)
632     
633     def submit(self, label="Submit Changes"):
634         """Generate a submit button.
636         Also sneak in the lastactivity and action hidden elements.
637         """
638         return self.input(type="hidden", name="@lastactivity", value=date.Date('.')) + '\n' + \
639                self.input(type="hidden", name="@action", value="edit") + '\n' + \
640                self.input(type="submit", name="submit", value=label)
642     def journal(self, direction='descending'):
643         ''' Return a list of HTMLJournalEntry instances.
644         '''
645         # XXX do this
646         return []
648     def history(self, direction='descending', dre=re.compile('\d+')):
649         self.view_check()
651         l = ['<table class="history">'
652              '<tr><th colspan="4" class="header">',
653              _('History'),
654              '</th></tr><tr>',
655              _('<th>Date</th>'),
656              _('<th>User</th>'),
657              _('<th>Action</th>'),
658              _('<th>Args</th>'),
659             '</tr>']
660         current = {}
661         comments = {}
662         history = self._klass.history(self._nodeid)
663         history.sort()
664         timezone = self._db.getUserTimezone()
665         if direction == 'descending':
666             history.reverse()
667             for prop_n in self._props.keys():
668                 prop = self[prop_n]
669                 if isinstance(prop, HTMLProperty):
670                     current[prop_n] = prop.plain()
671                     # make link if hrefable
672                     if (self._props.has_key(prop_n) and
673                             isinstance(self._props[prop_n], hyperdb.Link)):
674                         classname = self._props[prop_n].classname
675                         try:
676                             template = find_template(self._db.config.TEMPLATES,
677                                 classname, 'item')
678                             if template[1].startswith('_generic'):
679                                 raise NoTemplate, 'not really...'
680                         except NoTemplate:
681                             pass
682                         else:
683                             id = self._klass.get(self._nodeid, prop_n, None)
684                             current[prop_n] = '<a href="%s%s">%s</a>'%(
685                                 classname, id, current[prop_n])
686  
687         for id, evt_date, user, action, args in history:
688             date_s = str(evt_date.local(timezone)).replace("."," ")
689             arg_s = ''
690             if action == 'link' and type(args) == type(()):
691                 if len(args) == 3:
692                     linkcl, linkid, key = args
693                     arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
694                         linkcl, linkid, key)
695                 else:
696                     arg_s = str(args)
698             elif action == 'unlink' and type(args) == type(()):
699                 if len(args) == 3:
700                     linkcl, linkid, key = args
701                     arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
702                         linkcl, linkid, key)
703                 else:
704                     arg_s = str(args)
706             elif type(args) == type({}):
707                 cell = []
708                 for k in args.keys():
709                     # try to get the relevant property and treat it
710                     # specially
711                     try:
712                         prop = self._props[k]
713                     except KeyError:
714                         prop = None
715                     if prop is None:
716                         # property no longer exists
717                         comments['no_exist'] = _('''<em>The indicated property
718                             no longer exists</em>''')
719                         cell.append('<em>%s: %s</em>\n'%(k, str(args[k])))
720                         continue
722                     if args[k] and (isinstance(prop, hyperdb.Multilink) or
723                             isinstance(prop, hyperdb.Link)):
724                         # figure what the link class is
725                         classname = prop.classname
726                         try:
727                             linkcl = self._db.getclass(classname)
728                         except KeyError:
729                             labelprop = None
730                             comments[classname] = _('''The linked class
731                                 %(classname)s no longer exists''')%locals()
732                         labelprop = linkcl.labelprop(1)
733                         try:
734                             template = find_template(self._db.config.TEMPLATES,
735                                 classname, 'item')
736                             if template[1].startswith('_generic'):
737                                 raise NoTemplate, 'not really...'
738                             hrefable = 1
739                         except NoTemplate:
740                             hrefable = 0
742                     if isinstance(prop, hyperdb.Multilink) and args[k]:
743                         ml = []
744                         for linkid in args[k]:
745                             if isinstance(linkid, type(())):
746                                 sublabel = linkid[0] + ' '
747                                 linkids = linkid[1]
748                             else:
749                                 sublabel = ''
750                                 linkids = [linkid]
751                             subml = []
752                             for linkid in linkids:
753                                 label = classname + linkid
754                                 # if we have a label property, try to use it
755                                 # TODO: test for node existence even when
756                                 # there's no labelprop!
757                                 try:
758                                     if labelprop is not None and \
759                                             labelprop != 'id':
760                                         label = linkcl.get(linkid, labelprop)
761                                 except IndexError:
762                                     comments['no_link'] = _('''<strike>The
763                                         linked node no longer
764                                         exists</strike>''')
765                                     subml.append('<strike>%s</strike>'%label)
766                                 else:
767                                     if hrefable:
768                                         subml.append('<a href="%s%s">%s</a>'%(
769                                             classname, linkid, label))
770                                     else:
771                                         subml.append(label)
772                             ml.append(sublabel + ', '.join(subml))
773                         cell.append('%s:\n  %s'%(k, ', '.join(ml)))
774                     elif isinstance(prop, hyperdb.Link) and args[k]:
775                         label = classname + args[k]
776                         # if we have a label property, try to use it
777                         # TODO: test for node existence even when
778                         # there's no labelprop!
779                         if labelprop is not None and labelprop != 'id':
780                             try:
781                                 label = linkcl.get(args[k], labelprop)
782                             except IndexError:
783                                 comments['no_link'] = _('''<strike>The
784                                     linked node no longer
785                                     exists</strike>''')
786                                 cell.append(' <strike>%s</strike>,\n'%label)
787                                 # "flag" this is done .... euwww
788                                 label = None
789                         if label is not None:
790                             if hrefable:
791                                 old = '<a href="%s%s">%s</a>'%(classname, args[k], label)
792                             else:
793                                 old = label;
794                             cell.append('%s: %s' % (k,old))
795                             if current.has_key(k):
796                                 cell[-1] += ' -> %s'%current[k]
797                                 current[k] = old
799                     elif isinstance(prop, hyperdb.Date) and args[k]:
800                         d = date.Date(args[k]).local(timezone)
801                         cell.append('%s: %s'%(k, str(d)))
802                         if current.has_key(k):
803                             cell[-1] += ' -> %s' % current[k]
804                             current[k] = str(d)
806                     elif isinstance(prop, hyperdb.Interval) and args[k]:
807                         d = date.Interval(args[k])
808                         cell.append('%s: %s'%(k, str(d)))
809                         if current.has_key(k):
810                             cell[-1] += ' -> %s'%current[k]
811                             current[k] = str(d)
813                     elif isinstance(prop, hyperdb.String) and args[k]:
814                         cell.append('%s: %s'%(k, cgi.escape(args[k])))
815                         if current.has_key(k):
816                             cell[-1] += ' -> %s'%current[k]
817                             current[k] = cgi.escape(args[k])
819                     elif not args[k]:
820                         if current.has_key(k):
821                             cell.append('%s: %s'%(k, current[k]))
822                             current[k] = '(no value)'
823                         else:
824                             cell.append('%s: (no value)'%k)
826                     else:
827                         cell.append('%s: %s'%(k, str(args[k])))
828                         if current.has_key(k):
829                             cell[-1] += ' -> %s'%current[k]
830                             current[k] = str(args[k])
832                 arg_s = '<br />'.join(cell)
833             else:
834                 # unkown event!!
835                 comments['unknown'] = _('''<strong><em>This event is not
836                     handled by the history display!</em></strong>''')
837                 arg_s = '<strong><em>' + str(args) + '</em></strong>'
838             date_s = date_s.replace(' ', '&nbsp;')
839             # if the user's an itemid, figure the username (older journals
840             # have the username)
841             if dre.match(user):
842                 user = self._db.user.get(user, 'username')
843             l.append('<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>'%(
844                 date_s, user, action, arg_s))
845         if comments:
846             l.append(_('<tr><td colspan=4><strong>Note:</strong></td></tr>'))
847         for entry in comments.values():
848             l.append('<tr><td colspan=4>%s</td></tr>'%entry)
849         l.append('</table>')
850         return '\n'.join(l)
852     def renderQueryForm(self):
853         ''' Render this item, which is a query, as a search form.
854         '''
855         # create a new request and override the specified args
856         req = HTMLRequest(self._client)
857         req.classname = self._klass.get(self._nodeid, 'klass')
858         name = self._klass.get(self._nodeid, 'name')
859         req.updateFromURL(self._klass.get(self._nodeid, 'url') +
860             '&@queryname=%s'%urllib.quote(name))
862         # new template, using the specified classname and request
863         pt = Templates(self._db.config.TEMPLATES).get(req.classname, 'search')
865         # use our fabricated request
866         return pt.render(self._client, req.classname, req)
868 class HTMLUserPermission:
870     def is_edit_ok(self):
871         ''' Is the user allowed to Edit the current class?
872             Also check whether this is the current user's info.
873         '''
874         return self._user_perm_check('Edit')
876     def is_view_ok(self):
877         ''' Is the user allowed to View the current class?
878             Also check whether this is the current user's info.
879         '''
880         return self._user_perm_check('View')
882     def _user_perm_check(self, type):
883         # some users may view / edit all users
884         s = self._db.security
885         userid = self._client.userid
886         if s.hasPermission(type, userid, self._classname):
887             return 1
889         # users may view their own info
890         is_anonymous = self._db.user.get(userid, 'username') == 'anonymous'
891         if getattr(self, '_nodeid', None) == userid and not is_anonymous:
892             return 1
894         # may anonymous users register?
895         if (is_anonymous and s.hasPermission('Web Registration', userid,
896                 self._classname)):
897             return 1
899         # nope, no access here
900         return 0
902 class HTMLUserClass(HTMLUserPermission, HTMLClass):
903     pass
905 class HTMLUser(HTMLUserPermission, HTMLItem):
906     ''' Accesses through the *user* (a special case of item)
907     '''
908     def __init__(self, client, classname, nodeid, anonymous=0):
909         HTMLItem.__init__(self, client, 'user', nodeid, anonymous)
910         self._default_classname = client.classname
912         # used for security checks
913         self._security = client.db.security
915     _marker = []
916     def hasPermission(self, permission, classname=_marker):
917         ''' Determine if the user has the Permission.
919             The class being tested defaults to the template's class, but may
920             be overidden for this test by suppling an alternate classname.
921         '''
922         if classname is self._marker:
923             classname = self._default_classname
924         return self._security.hasPermission(permission, self._nodeid, classname)
926 class HTMLProperty(HTMLInputMixin, HTMLPermissions):
927     ''' String, Number, Date, Interval HTMLProperty
929         Has useful attributes:
931          _name  the name of the property
932          _value the value of the property if any
934         A wrapper object which may be stringified for the plain() behaviour.
935     '''
936     def __init__(self, client, classname, nodeid, prop, name, value,
937             anonymous=0):
938         self._client = client
939         self._db = client.db
940         self._classname = classname
941         self._nodeid = nodeid
942         self._prop = prop
943         self._value = value
944         self._anonymous = anonymous
945         self._name = name
946         if not anonymous:
947             self._formname = '%s%s@%s'%(classname, nodeid, name)
948         else:
949             self._formname = name
951         HTMLInputMixin.__init__(self)
953     def __repr__(self):
954         return '<HTMLProperty(0x%x) %s %r %r>'%(id(self), self._formname,
955             self._prop, self._value)
956     def __str__(self):
957         return self.plain()
958     def __cmp__(self, other):
959         if isinstance(other, HTMLProperty):
960             return cmp(self._value, other._value)
961         return cmp(self._value, other)
963     def is_edit_ok(self):
964         ''' Is the user allowed to Edit the current class?
965         '''
966         thing = HTMLDatabase(self._client)[self._classname]
967         if self._nodeid:
968             # this is a special-case for the User class where permission's
969             # on a per-item basis :(
970             thing = thing.getItem(self._nodeid)
971         return thing.is_edit_ok()
973     def is_view_ok(self):
974         ''' Is the user allowed to View the current class?
975         '''
976         thing = HTMLDatabase(self._client)[self._classname]
977         if self._nodeid:
978             # this is a special-case for the User class where permission's
979             # on a per-item basis :(
980             thing = thing.getItem(self._nodeid)
981         return thing.is_view_ok()
983 class StringHTMLProperty(HTMLProperty):
984     hyper_re = re.compile(r'((?P<url>\w{3,6}://\S+)|'
985                           r'(?P<email>[-+=%/\w\.]+@[\w\.\-]+)|'
986                           r'(?P<item>(?P<class>[a-z_]+)(?P<id>\d+)))')
987     def _hyper_repl(self, match):
988         if match.group('url'):
989             s = match.group('url')
990             return '<a href="%s">%s</a>'%(s, s)
991         elif match.group('email'):
992             s = match.group('email')
993             return '<a href="mailto:%s">%s</a>'%(s, s)
994         else:
995             s = match.group('item')
996             s1 = match.group('class')
997             s2 = match.group('id')
998             try:
999                 # make sure s1 is a valid tracker classname
1000                 cl = self._db.getclass(s1)
1001                 if not cl.hasnode(s2):
1002                     raise KeyError, 'oops'
1003                 return '<a href="%s">%s%s</a>'%(s, s1, s2)
1004             except KeyError:
1005                 return '%s%s'%(s1, s2)
1007     def hyperlinked(self):
1008         ''' Render a "hyperlinked" version of the text '''
1009         return self.plain(hyperlink=1)
1011     def plain(self, escape=0, hyperlink=0):
1012         '''Render a "plain" representation of the property
1013             
1014         - "escape" turns on/off HTML quoting
1015         - "hyperlink" turns on/off in-text hyperlinking of URLs, email
1016           addresses and designators
1017         '''
1018         self.view_check()
1020         if self._value is None:
1021             return ''
1022         if escape:
1023             s = cgi.escape(str(self._value))
1024         else:
1025             s = str(self._value)
1026         if hyperlink:
1027             # no, we *must* escape this text
1028             if not escape:
1029                 s = cgi.escape(s)
1030             s = self.hyper_re.sub(self._hyper_repl, s)
1031         return s
1033     def stext(self, escape=0):
1034         ''' Render the value of the property as StructuredText.
1036             This requires the StructureText module to be installed separately.
1037         '''
1038         self.view_check()
1040         s = self.plain(escape=escape)
1041         if not StructuredText:
1042             return s
1043         return StructuredText(s,level=1,header=0)
1045     def field(self, size = 30):
1046         ''' Render the property as a field in HTML.
1048             If not editable, just display the value via plain().
1049         '''
1050         self.view_check()
1052         if self._value is None:
1053             value = ''
1054         else:
1055             value = cgi.escape(str(self._value))
1057         if self.is_edit_ok():
1058             value = '&quot;'.join(value.split('"'))
1059             return self.input(name=self._formname,value=value,size=size)
1061         return self.plain()
1063     def multiline(self, escape=0, rows=5, cols=40):
1064         ''' Render a multiline form edit field for the property.
1066             If not editable, just display the plain() value in a <pre> tag.
1067         '''
1068         self.view_check()
1070         if self._value is None:
1071             value = ''
1072         else:
1073             value = cgi.escape(str(self._value))
1075         if self.is_edit_ok():
1076             value = '&quot;'.join(value.split('"'))
1077             return '<textarea name="%s" rows="%s" cols="%s">%s</textarea>'%(
1078                 self._formname, rows, cols, value)
1080         return '<pre>%s</pre>'%self.plain()
1082     def email(self, escape=1):
1083         ''' Render the value of the property as an obscured email address
1084         '''
1085         self.view_check()
1087         if self._value is None:
1088             value = ''
1089         else:
1090             value = str(self._value)
1091         if value.find('@') != -1:
1092             name, domain = value.split('@')
1093             domain = ' '.join(domain.split('.')[:-1])
1094             name = name.replace('.', ' ')
1095             value = '%s at %s ...'%(name, domain)
1096         else:
1097             value = value.replace('.', ' ')
1098         if escape:
1099             value = cgi.escape(value)
1100         return value
1102 class PasswordHTMLProperty(HTMLProperty):
1103     def plain(self):
1104         ''' Render a "plain" representation of the property
1105         '''
1106         self.view_check()
1108         if self._value is None:
1109             return ''
1110         return _('*encrypted*')
1112     def field(self, size = 30):
1113         ''' Render a form edit field for the property.
1115             If not editable, just display the value via plain().
1116         '''
1117         self.view_check()
1119         if self.is_edit_ok():
1120             return self.input(type="password", name=self._formname, size=size)
1122         return self.plain()
1124     def confirm(self, size = 30):
1125         ''' Render a second form edit field for the property, used for 
1126             confirmation that the user typed the password correctly. Generates
1127             a field with name "@confirm@name".
1129             If not editable, display nothing.
1130         '''
1131         self.view_check()
1133         if self.is_edit_ok():
1134             return self.input(type="password",
1135                 name="@confirm@%s"%self._formname, size=size)
1137         return ''
1139 class NumberHTMLProperty(HTMLProperty):
1140     def plain(self):
1141         ''' Render a "plain" representation of the property
1142         '''
1143         self.view_check()
1145         return str(self._value)
1147     def field(self, size = 30):
1148         ''' Render a form edit field for the property.
1150             If not editable, just display the value via plain().
1151         '''
1152         self.view_check()
1154         if self._value is None:
1155             value = ''
1156         else:
1157             value = cgi.escape(str(self._value))
1159         if self.is_edit_ok():
1160             value = '&quot;'.join(value.split('"'))
1161             return self.input(name=self._formname,value=value,size=size)
1163         return self.plain()
1165     def __int__(self):
1166         ''' Return an int of me
1167         '''
1168         return int(self._value)
1170     def __float__(self):
1171         ''' Return a float of me
1172         '''
1173         return float(self._value)
1176 class BooleanHTMLProperty(HTMLProperty):
1177     def plain(self):
1178         ''' Render a "plain" representation of the property
1179         '''
1180         self.view_check()
1182         if self._value is None:
1183             return ''
1184         return self._value and "Yes" or "No"
1186     def field(self):
1187         ''' Render a form edit field for the property
1189             If not editable, just display the value via plain().
1190         '''
1191         self.view_check()
1193         if not is_edit_ok():
1194             return self.plain()
1196         checked = self._value and "checked" or ""
1197         if self._value:
1198             s = self.input(type="radio", name=self._formname, value="yes",
1199                 checked="checked")
1200             s += 'Yes'
1201             s +=self.input(type="radio", name=self._formname, value="no")
1202             s += 'No'
1203         else:
1204             s = self.input(type="radio", name=self._formname, value="yes")
1205             s += 'Yes'
1206             s +=self.input(type="radio", name=self._formname, value="no",
1207                 checked="checked")
1208             s += 'No'
1209         return s
1211 class DateHTMLProperty(HTMLProperty):
1212     def plain(self):
1213         ''' Render a "plain" representation of the property
1214         '''
1215         self.view_check()
1217         if self._value is None:
1218             return ''
1219         return str(self._value.local(self._db.getUserTimezone()))
1221     def now(self):
1222         ''' Return the current time.
1224             This is useful for defaulting a new value. Returns a
1225             DateHTMLProperty.
1226         '''
1227         self.view_check()
1229         return DateHTMLProperty(self._client, self._classname, self._nodeid,
1230             self._prop, self._formname, date.Date('.'))
1232     def field(self, size = 30):
1233         ''' Render a form edit field for the property
1235             If not editable, just display the value via plain().
1236         '''
1237         self.view_check()
1239         if self._value is None:
1240             value = ''
1241         else:
1242             tz = self._db.getUserTimezone()
1243             value = cgi.escape(str(self._value.local(tz)))
1245         if is_edit_ok():
1246             value = '&quot;'.join(value.split('"'))
1247             return self.input(name=self._formname,value=value,size=size)
1248         
1249         return self.plain()
1251     def reldate(self, pretty=1):
1252         ''' Render the interval between the date and now.
1254             If the "pretty" flag is true, then make the display pretty.
1255         '''
1256         self.view_check()
1258         if not self._value:
1259             return ''
1261         # figure the interval
1262         interval = self._value - date.Date('.')
1263         if pretty:
1264             return interval.pretty()
1265         return str(interval)
1267     _marker = []
1268     def pretty(self, format=_marker):
1269         ''' Render the date in a pretty format (eg. month names, spaces).
1271             The format string is a standard python strftime format string.
1272             Note that if the day is zero, and appears at the start of the
1273             string, then it'll be stripped from the output. This is handy
1274             for the situatin when a date only specifies a month and a year.
1275         '''
1276         self.view_check()
1278         if format is not self._marker:
1279             return self._value.pretty(format)
1280         else:
1281             return self._value.pretty()
1283     def local(self, offset):
1284         ''' Return the date/time as a local (timezone offset) date/time.
1285         '''
1286         self.view_check()
1288         return DateHTMLProperty(self._client, self._classname, self._nodeid,
1289             self._prop, self._formname, self._value.local(offset))
1291 class IntervalHTMLProperty(HTMLProperty):
1292     def plain(self):
1293         ''' Render a "plain" representation of the property
1294         '''
1295         self.view_check()
1297         if self._value is None:
1298             return ''
1299         return str(self._value)
1301     def pretty(self):
1302         ''' Render the interval in a pretty format (eg. "yesterday")
1303         '''
1304         self.view_check()
1306         return self._value.pretty()
1308     def field(self, size = 30):
1309         ''' Render a form edit field for the property
1311             If not editable, just display the value via plain().
1312         '''
1313         self.view_check()
1315         if self._value is None:
1316             value = ''
1317         else:
1318             value = cgi.escape(str(self._value))
1320         if is_edit_ok():
1321             value = '&quot;'.join(value.split('"'))
1322             return self.input(name=self._formname,value=value,size=size)
1324         return self.plain()
1326 class LinkHTMLProperty(HTMLProperty):
1327     ''' Link HTMLProperty
1328         Include the above as well as being able to access the class
1329         information. Stringifying the object itself results in the value
1330         from the item being displayed. Accessing attributes of this object
1331         result in the appropriate entry from the class being queried for the
1332         property accessed (so item/assignedto/name would look up the user
1333         entry identified by the assignedto property on item, and then the
1334         name property of that user)
1335     '''
1336     def __init__(self, *args, **kw):
1337         HTMLProperty.__init__(self, *args, **kw)
1338         # if we're representing a form value, then the -1 from the form really
1339         # should be a None
1340         if str(self._value) == '-1':
1341             self._value = None
1343     def __getattr__(self, attr):
1344         ''' return a new HTMLItem '''
1345        #print 'Link.getattr', (self, attr, self._value)
1346         if not self._value:
1347             raise AttributeError, "Can't access missing value"
1348         if self._prop.classname == 'user':
1349             klass = HTMLUser
1350         else:
1351             klass = HTMLItem
1352         i = klass(self._client, self._prop.classname, self._value)
1353         return getattr(i, attr)
1355     def plain(self, escape=0):
1356         ''' Render a "plain" representation of the property
1357         '''
1358         self.view_check()
1360         if self._value is None:
1361             return ''
1362         linkcl = self._db.classes[self._prop.classname]
1363         k = linkcl.labelprop(1)
1364         value = str(linkcl.get(self._value, k))
1365         if escape:
1366             value = cgi.escape(value)
1367         return value
1369     def field(self, showid=0, size=None):
1370         ''' Render a form edit field for the property
1372             If not editable, just display the value via plain().
1373         '''
1374         self.view_check()
1376         if not self.is_edit_ok():
1377             return self.plain()
1379         # edit field
1380         linkcl = self._db.getclass(self._prop.classname)
1381         if self._value is None:
1382             value = ''
1383         else:
1384             k = linkcl.getkey()
1385             if k:
1386                 label = linkcl.get(self._value, k)
1387             else:
1388                 label = self._value
1389             value = cgi.escape(str(self._value))
1390             value = '&quot;'.join(value.split('"'))
1391         return '<input name="%s" value="%s" size="%s">'%(self._formname,
1392             label, size)
1394     def menu(self, size=None, height=None, showid=0, additional=[],
1395             sort_on=None, **conditions):
1396         ''' Render a form select list for this property
1398             If not editable, just display the value via plain().
1399         '''
1400         self.view_check()
1402         if not self.is_edit_ok():
1403             return self.plain()
1405         value = self._value
1407         linkcl = self._db.getclass(self._prop.classname)
1408         l = ['<select name="%s">'%self._formname]
1409         k = linkcl.labelprop(1)
1410         s = ''
1411         if value is None:
1412             s = 'selected="selected" '
1413         l.append(_('<option %svalue="-1">- no selection -</option>')%s)
1414         if linkcl.getprops().has_key('order'):  
1415             sort_on = ('+', 'order')
1416         else:  
1417             if sort_on is None:
1418                 sort_on = ('+', linkcl.labelprop())
1419             else:
1420                 sort_on = ('+', sort_on)
1421         options = linkcl.filter(None, conditions, sort_on, (None, None))
1423         # make sure we list the current value if it's retired
1424         if self._value and self._value not in options:
1425             options.insert(0, self._value)
1427         for optionid in options:
1428             # get the option value, and if it's None use an empty string
1429             option = linkcl.get(optionid, k) or ''
1431             # figure if this option is selected
1432             s = ''
1433             if value in [optionid, option]:
1434                 s = 'selected="selected" '
1436             # figure the label
1437             if showid:
1438                 lab = '%s%s: %s'%(self._prop.classname, optionid, option)
1439             else:
1440                 lab = option
1442             # truncate if it's too long
1443             if size is not None and len(lab) > size:
1444                 lab = lab[:size-3] + '...'
1445             if additional:
1446                 m = []
1447                 for propname in additional:
1448                     m.append(linkcl.get(optionid, propname))
1449                 lab = lab + ' (%s)'%', '.join(map(str, m))
1451             # and generate
1452             lab = cgi.escape(lab)
1453             l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
1454         l.append('</select>')
1455         return '\n'.join(l)
1456 #    def checklist(self, ...)
1458 class MultilinkHTMLProperty(HTMLProperty):
1459     ''' Multilink HTMLProperty
1461         Also be iterable, returning a wrapper object like the Link case for
1462         each entry in the multilink.
1463     '''
1464     def __init__(self, *args, **kwargs):
1465         HTMLProperty.__init__(self, *args, **kwargs)
1466         if self._value:
1467             sortfun = make_sort_function(self._db, self._prop.classname)
1468             self._value.sort(sortfun)
1469     
1470     def __len__(self):
1471         ''' length of the multilink '''
1472         return len(self._value)
1474     def __getattr__(self, attr):
1475         ''' no extended attribute accesses make sense here '''
1476         raise AttributeError, attr
1478     def __getitem__(self, num):
1479         ''' iterate and return a new HTMLItem
1480         '''
1481        #print 'Multi.getitem', (self, num)
1482         value = self._value[num]
1483         if self._prop.classname == 'user':
1484             klass = HTMLUser
1485         else:
1486             klass = HTMLItem
1487         return klass(self._client, self._prop.classname, value)
1489     def __contains__(self, value):
1490         ''' Support the "in" operator. We have to make sure the passed-in
1491             value is a string first, not a HTMLProperty.
1492         '''
1493         return str(value) in self._value
1495     def reverse(self):
1496         ''' return the list in reverse order
1497         '''
1498         l = self._value[:]
1499         l.reverse()
1500         if self._prop.classname == 'user':
1501             klass = HTMLUser
1502         else:
1503             klass = HTMLItem
1504         return [klass(self._client, self._prop.classname, value) for value in l]
1506     def plain(self, escape=0):
1507         ''' Render a "plain" representation of the property
1508         '''
1509         self.view_check()
1511         linkcl = self._db.classes[self._prop.classname]
1512         k = linkcl.labelprop(1)
1513         labels = []
1514         for v in self._value:
1515             labels.append(linkcl.get(v, k))
1516         value = ', '.join(labels)
1517         if escape:
1518             value = cgi.escape(value)
1519         return value
1521     def field(self, size=30, showid=0):
1522         ''' Render a form edit field for the property
1524             If not editable, just display the value via plain().
1525         '''
1526         self.view_check()
1528         if not self.is_edit_ok():
1529             return self.plain()
1531         linkcl = self._db.getclass(self._prop.classname)
1532         value = self._value[:]
1533         # map the id to the label property
1534         if not linkcl.getkey():
1535             showid=1
1536         if not showid:
1537             k = linkcl.labelprop(1)
1538             value = [linkcl.get(v, k) for v in value]
1539         value = cgi.escape(','.join(value))
1540         return self.input(name=self._formname,size=size,value=value)
1542     def menu(self, size=None, height=None, showid=0, additional=[],
1543             sort_on=None, **conditions):
1544         ''' Render a form select list for this property
1546             If not editable, just display the value via plain().
1547         '''
1548         self.view_check()
1550         if not self.is_edit_ok():
1551             return self.plain()
1553         value = self._value
1555         linkcl = self._db.getclass(self._prop.classname)
1556         if sort_on is None:
1557             sort_on = ('+', find_sort_key(linkcl))
1558         else:
1559             sort_on = ('+', sort_on)
1560         options = linkcl.filter(None, conditions, sort_on)
1561         height = height or min(len(options), 7)
1562         l = ['<select multiple name="%s" size="%s">'%(self._formname, height)]
1563         k = linkcl.labelprop(1)
1565         # make sure we list the current values if they're retired
1566         for val in value:
1567             if val not in options:
1568                 options.insert(0, val)
1570         for optionid in options:
1571             # get the option value, and if it's None use an empty string
1572             option = linkcl.get(optionid, k) or ''
1574             # figure if this option is selected
1575             s = ''
1576             if optionid in value or option in value:
1577                 s = 'selected="selected" '
1579             # figure the label
1580             if showid:
1581                 lab = '%s%s: %s'%(self._prop.classname, optionid, option)
1582             else:
1583                 lab = option
1584             # truncate if it's too long
1585             if size is not None and len(lab) > size:
1586                 lab = lab[:size-3] + '...'
1587             if additional:
1588                 m = []
1589                 for propname in additional:
1590                     m.append(linkcl.get(optionid, propname))
1591                 lab = lab + ' (%s)'%', '.join(m)
1593             # and generate
1594             lab = cgi.escape(lab)
1595             l.append('<option %svalue="%s">%s</option>'%(s, optionid,
1596                 lab))
1597         l.append('</select>')
1598         return '\n'.join(l)
1600 # set the propclasses for HTMLItem
1601 propclasses = (
1602     (hyperdb.String, StringHTMLProperty),
1603     (hyperdb.Number, NumberHTMLProperty),
1604     (hyperdb.Boolean, BooleanHTMLProperty),
1605     (hyperdb.Date, DateHTMLProperty),
1606     (hyperdb.Interval, IntervalHTMLProperty),
1607     (hyperdb.Password, PasswordHTMLProperty),
1608     (hyperdb.Link, LinkHTMLProperty),
1609     (hyperdb.Multilink, MultilinkHTMLProperty),
1612 def make_sort_function(db, classname, sort_on=None):
1613     '''Make a sort function for a given class
1614     '''
1615     linkcl = db.getclass(classname)
1616     if sort_on is None:
1617         sort_on = find_sort_key(linkcl)
1618     def sortfunc(a, b):
1619         return cmp(linkcl.get(a, sort_on), linkcl.get(b, sort_on))
1620     return sortfunc
1622 def find_sort_key(linkcl):
1623     if linkcl.getprops().has_key('order'):
1624         return 'order'
1625     else:
1626         return linkcl.labelprop()
1628 def handleListCGIValue(value):
1629     ''' Value is either a single item or a list of items. Each item has a
1630         .value that we're actually interested in.
1631     '''
1632     if isinstance(value, type([])):
1633         return [value.value for value in value]
1634     else:
1635         value = value.value.strip()
1636         if not value:
1637             return []
1638         return value.split(',')
1640 class ShowDict:
1641     ''' A convenience access to the :columns index parameters
1642     '''
1643     def __init__(self, columns):
1644         self.columns = {}
1645         for col in columns:
1646             self.columns[col] = 1
1647     def __getitem__(self, name):
1648         return self.columns.has_key(name)
1650 class HTMLRequest(HTMLInputMixin):
1651     '''The *request*, holding the CGI form and environment.
1653     - "form" the CGI form as a cgi.FieldStorage
1654     - "env" the CGI environment variables
1655     - "base" the base URL for this instance
1656     - "user" a HTMLUser instance for this user
1657     - "classname" the current classname (possibly None)
1658     - "template" the current template (suffix, also possibly None)
1660     Index args:
1662     - "columns" dictionary of the columns to display in an index page
1663     - "show" a convenience access to columns - request/show/colname will
1664       be true if the columns should be displayed, false otherwise
1665     - "sort" index sort column (direction, column name)
1666     - "group" index grouping property (direction, column name)
1667     - "filter" properties to filter the index on
1668     - "filterspec" values to filter the index on
1669     - "search_text" text to perform a full-text search on for an index
1670     '''
1671     def __init__(self, client):
1672         # _client is needed by HTMLInputMixin
1673         self._client = self.client = client
1675         # easier access vars
1676         self.form = client.form
1677         self.env = client.env
1678         self.base = client.base
1679         self.user = HTMLUser(client, 'user', client.userid)
1681         # store the current class name and action
1682         self.classname = client.classname
1683         self.template = client.template
1685         # the special char to use for special vars
1686         self.special_char = '@'
1688         HTMLInputMixin.__init__(self)
1690         self._post_init()
1692     def _post_init(self):
1693         ''' Set attributes based on self.form
1694         '''
1695         # extract the index display information from the form
1696         self.columns = []
1697         for name in ':columns @columns'.split():
1698             if self.form.has_key(name):
1699                 self.special_char = name[0]
1700                 self.columns = handleListCGIValue(self.form[name])
1701                 break
1702         self.show = ShowDict(self.columns)
1704         # sorting
1705         self.sort = (None, None)
1706         for name in ':sort @sort'.split():
1707             if self.form.has_key(name):
1708                 self.special_char = name[0]
1709                 sort = self.form[name].value
1710                 if sort.startswith('-'):
1711                     self.sort = ('-', sort[1:])
1712                 else:
1713                     self.sort = ('+', sort)
1714                 if self.form.has_key(self.special_char+'sortdir'):
1715                     self.sort = ('-', self.sort[1])
1717         # grouping
1718         self.group = (None, None)
1719         for name in ':group @group'.split():
1720             if self.form.has_key(name):
1721                 self.special_char = name[0]
1722                 group = self.form[name].value
1723                 if group.startswith('-'):
1724                     self.group = ('-', group[1:])
1725                 else:
1726                     self.group = ('+', group)
1727                 if self.form.has_key(self.special_char+'groupdir'):
1728                     self.group = ('-', self.group[1])
1730         # filtering
1731         self.filter = []
1732         for name in ':filter @filter'.split():
1733             if self.form.has_key(name):
1734                 self.special_char = name[0]
1735                 self.filter = handleListCGIValue(self.form[name])
1737         self.filterspec = {}
1738         db = self.client.db
1739         if self.classname is not None:
1740             props = db.getclass(self.classname).getprops()
1741             for name in self.filter:
1742                 if not self.form.has_key(name):
1743                     continue
1744                 prop = props[name]
1745                 fv = self.form[name]
1746                 if (isinstance(prop, hyperdb.Link) or
1747                         isinstance(prop, hyperdb.Multilink)):
1748                     self.filterspec[name] = lookupIds(db, prop,
1749                         handleListCGIValue(fv))
1750                 else:
1751                     if isinstance(fv, type([])):
1752                         self.filterspec[name] = [v.value for v in fv]
1753                     else:
1754                         self.filterspec[name] = fv.value
1756         # full-text search argument
1757         self.search_text = None
1758         for name in ':search_text @search_text'.split():
1759             if self.form.has_key(name):
1760                 self.special_char = name[0]
1761                 self.search_text = self.form[name].value
1763         # pagination - size and start index
1764         # figure batch args
1765         self.pagesize = 50
1766         for name in ':pagesize @pagesize'.split():
1767             if self.form.has_key(name):
1768                 self.special_char = name[0]
1769                 self.pagesize = int(self.form[name].value)
1771         self.startwith = 0
1772         for name in ':startwith @startwith'.split():
1773             if self.form.has_key(name):
1774                 self.special_char = name[0]
1775                 self.startwith = int(self.form[name].value)
1777     def updateFromURL(self, url):
1778         ''' Parse the URL for query args, and update my attributes using the
1779             values.
1780         ''' 
1781         env = {'QUERY_STRING': url}
1782         self.form = cgi.FieldStorage(environ=env)
1784         self._post_init()
1786     def update(self, kwargs):
1787         ''' Update my attributes using the keyword args
1788         '''
1789         self.__dict__.update(kwargs)
1790         if kwargs.has_key('columns'):
1791             self.show = ShowDict(self.columns)
1793     def description(self):
1794         ''' Return a description of the request - handle for the page title.
1795         '''
1796         s = [self.client.db.config.TRACKER_NAME]
1797         if self.classname:
1798             if self.client.nodeid:
1799                 s.append('- %s%s'%(self.classname, self.client.nodeid))
1800             else:
1801                 if self.template == 'item':
1802                     s.append('- new %s'%self.classname)
1803                 elif self.template == 'index':
1804                     s.append('- %s index'%self.classname)
1805                 else:
1806                     s.append('- %s %s'%(self.classname, self.template))
1807         else:
1808             s.append('- home')
1809         return ' '.join(s)
1811     def __str__(self):
1812         d = {}
1813         d.update(self.__dict__)
1814         f = ''
1815         for k in self.form.keys():
1816             f += '\n      %r=%r'%(k,handleListCGIValue(self.form[k]))
1817         d['form'] = f
1818         e = ''
1819         for k,v in self.env.items():
1820             e += '\n     %r=%r'%(k, v)
1821         d['env'] = e
1822         return '''
1823 form: %(form)s
1824 base: %(base)r
1825 classname: %(classname)r
1826 template: %(template)r
1827 columns: %(columns)r
1828 sort: %(sort)r
1829 group: %(group)r
1830 filter: %(filter)r
1831 search_text: %(search_text)r
1832 pagesize: %(pagesize)r
1833 startwith: %(startwith)r
1834 env: %(env)s
1835 '''%d
1837     def indexargs_form(self, columns=1, sort=1, group=1, filter=1,
1838             filterspec=1):
1839         ''' return the current index args as form elements '''
1840         l = []
1841         sc = self.special_char
1842         s = self.input(type="hidden",name="%s",value="%s")
1843         if columns and self.columns:
1844             l.append(s%(sc+'columns', ','.join(self.columns)))
1845         if sort and self.sort[1] is not None:
1846             if self.sort[0] == '-':
1847                 val = '-'+self.sort[1]
1848             else:
1849                 val = self.sort[1]
1850             l.append(s%(sc+'sort', val))
1851         if group and self.group[1] is not None:
1852             if self.group[0] == '-':
1853                 val = '-'+self.group[1]
1854             else:
1855                 val = self.group[1]
1856             l.append(s%(sc+'group', val))
1857         if filter and self.filter:
1858             l.append(s%(sc+'filter', ','.join(self.filter)))
1859         if filterspec:
1860             for k,v in self.filterspec.items():
1861                 if type(v) == type([]):
1862                     l.append(s%(k, ','.join(v)))
1863                 else:
1864                     l.append(s%(k, v))
1865         if self.search_text:
1866             l.append(s%(sc+'search_text', self.search_text))
1867         l.append(s%(sc+'pagesize', self.pagesize))
1868         l.append(s%(sc+'startwith', self.startwith))
1869         return '\n'.join(l)
1871     def indexargs_url(self, url, args):
1872         ''' Embed the current index args in a URL
1873         '''
1874         sc = self.special_char
1875         l = ['%s=%s'%(k,v) for k,v in args.items()]
1877         # pull out the special values (prefixed by @ or :)
1878         specials = {}
1879         for key in args.keys():
1880             if key[0] in '@:':
1881                 specials[key[1:]] = args[key]
1883         # ok, now handle the specials we received in the request
1884         if self.columns and not specials.has_key('columns'):
1885             l.append(sc+'columns=%s'%(','.join(self.columns)))
1886         if self.sort[1] is not None and not specials.has_key('sort'):
1887             if self.sort[0] == '-':
1888                 val = '-'+self.sort[1]
1889             else:
1890                 val = self.sort[1]
1891             l.append(sc+'sort=%s'%val)
1892         if self.group[1] is not None and not specials.has_key('group'):
1893             if self.group[0] == '-':
1894                 val = '-'+self.group[1]
1895             else:
1896                 val = self.group[1]
1897             l.append(sc+'group=%s'%val)
1898         if self.filter and not specials.has_key('filter'):
1899             l.append(sc+'filter=%s'%(','.join(self.filter)))
1900         if self.search_text and not specials.has_key('search_text'):
1901             l.append(sc+'search_text=%s'%self.search_text)
1902         if not specials.has_key('pagesize'):
1903             l.append(sc+'pagesize=%s'%self.pagesize)
1904         if not specials.has_key('startwith'):
1905             l.append(sc+'startwith=%s'%self.startwith)
1907         # finally, the remainder of the filter args in the request
1908         for k,v in self.filterspec.items():
1909             if not args.has_key(k):
1910                 if type(v) == type([]):
1911                     l.append('%s=%s'%(k, ','.join(v)))
1912                 else:
1913                     l.append('%s=%s'%(k, v))
1914         return '%s?%s'%(url, '&'.join(l))
1915     indexargs_href = indexargs_url
1917     def base_javascript(self):
1918         return '''
1919 <script type="text/javascript">
1920 submitted = false;
1921 function submit_once() {
1922     if (submitted) {
1923         alert("Your request is being processed.\\nPlease be patient.");
1924         event.returnValue = 0;    // work-around for IE
1925         return 0;
1926     }
1927     submitted = true;
1928     return 1;
1931 function help_window(helpurl, width, height) {
1932     HelpWin = window.open('%s' + helpurl, 'RoundupHelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
1934 </script>
1935 '''%self.base
1937     def batch(self):
1938         ''' Return a batch object for results from the "current search"
1939         '''
1940         filterspec = self.filterspec
1941         sort = self.sort
1942         group = self.group
1944         # get the list of ids we're batching over
1945         klass = self.client.db.getclass(self.classname)
1946         if self.search_text:
1947             matches = self.client.db.indexer.search(
1948                 re.findall(r'\b\w{2,25}\b', self.search_text), klass)
1949         else:
1950             matches = None
1951         l = klass.filter(matches, filterspec, sort, group)
1953         # return the batch object, using IDs only
1954         return Batch(self.client, l, self.pagesize, self.startwith,
1955             classname=self.classname)
1957 # extend the standard ZTUtils Batch object to remove dependency on
1958 # Acquisition and add a couple of useful methods
1959 class Batch(ZTUtils.Batch):
1960     ''' Use me to turn a list of items, or item ids of a given class, into a
1961         series of batches.
1963         ========= ========================================================
1964         Parameter  Usage
1965         ========= ========================================================
1966         sequence  a list of HTMLItems or item ids
1967         classname if sequence is a list of ids, this is the class of item
1968         size      how big to make the sequence.
1969         start     where to start (0-indexed) in the sequence.
1970         end       where to end (0-indexed) in the sequence.
1971         orphan    if the next batch would contain less items than this
1972                   value, then it is combined with this batch
1973         overlap   the number of items shared between adjacent batches
1974         ========= ========================================================
1976         Attributes: Note that the "start" attribute, unlike the
1977         argument, is a 1-based index (I know, lame).  "first" is the
1978         0-based index.  "length" is the actual number of elements in
1979         the batch.
1981         "sequence_length" is the length of the original, unbatched, sequence.
1982     '''
1983     def __init__(self, client, sequence, size, start, end=0, orphan=0,
1984             overlap=0, classname=None):
1985         self.client = client
1986         self.last_index = self.last_item = None
1987         self.current_item = None
1988         self.classname = classname
1989         self.sequence_length = len(sequence)
1990         ZTUtils.Batch.__init__(self, sequence, size, start, end, orphan,
1991             overlap)
1993     # overwrite so we can late-instantiate the HTMLItem instance
1994     def __getitem__(self, index):
1995         if index < 0:
1996             if index + self.end < self.first: raise IndexError, index
1997             return self._sequence[index + self.end]
1998         
1999         if index >= self.length:
2000             raise IndexError, index
2002         # move the last_item along - but only if the fetched index changes
2003         # (for some reason, index 0 is fetched twice)
2004         if index != self.last_index:
2005             self.last_item = self.current_item
2006             self.last_index = index
2008         item = self._sequence[index + self.first]
2009         if self.classname:
2010             # map the item ids to instances
2011             if self.classname == 'user':
2012                 item = HTMLUser(self.client, self.classname, item)
2013             else:
2014                 item = HTMLItem(self.client, self.classname, item)
2015         self.current_item = item
2016         return item
2018     def propchanged(self, property):
2019         ''' Detect if the property marked as being the group property
2020             changed in the last iteration fetch
2021         '''
2022         if (self.last_item is None or
2023                 self.last_item[property] != self.current_item[property]):
2024             return 1
2025         return 0
2027     # override these 'cos we don't have access to acquisition
2028     def previous(self):
2029         if self.start == 1:
2030             return None
2031         return Batch(self.client, self._sequence, self._size,
2032             self.first - self._size + self.overlap, 0, self.orphan,
2033             self.overlap)
2035     def next(self):
2036         try:
2037             self._sequence[self.end]
2038         except IndexError:
2039             return None
2040         return Batch(self.client, self._sequence, self._size,
2041             self.end - self.overlap, 0, self.orphan, self.overlap)
2043 class TemplatingUtils:
2044     ''' Utilities for templating
2045     '''
2046     def __init__(self, client):
2047         self.client = client
2048     def Batch(self, sequence, size, start, end=0, orphan=0, overlap=0):
2049         return Batch(self.client, sequence, size, start, end, orphan,
2050             overlap)