Code

expose the Date.pretty method to templating
[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 class NoTemplate(Exception):
26     pass
28 class Templates:
29     templates = {}
31     def __init__(self, dir):
32         self.dir = dir
34     def precompileTemplates(self):
35         ''' Go through a directory and precompile all the templates therein
36         '''
37         for filename in os.listdir(self.dir):
38             if os.path.isdir(filename): continue
39             if '.' in filename:
40                 name, extension = filename.split('.')
41                 self.getTemplate(name, extension)
42             else:
43                 self.getTemplate(filename, None)
45     def get(self, name, extension):
46         ''' Interface to get a template, possibly loading a compiled template.
48             "name" and "extension" indicate the template we're after, which in
49             most cases will be "name.extension". If "extension" is None, then
50             we look for a template just called "name" with no extension.
52             If the file "name.extension" doesn't exist, we look for
53             "_generic.extension" as a fallback.
54         '''
55         # default the name to "home"
56         if name is None:
57             name = 'home'
59         # find the source, figure the time it was last modified
60         if extension:
61             filename = '%s.%s'%(name, extension)
62         else:
63             filename = name
64         src = os.path.join(self.dir, filename)
65         try:
66             stime = os.stat(src)[os.path.stat.ST_MTIME]
67         except os.error, error:
68             if error.errno != errno.ENOENT:
69                 raise
70             if not extension:
71                 raise NoTemplate, 'Template file "%s" doesn\'t exist'%name
73             # try for a generic template
74             generic = '_generic.%s'%extension
75             src = os.path.join(self.dir, generic)
76             try:
77                 stime = os.stat(src)[os.path.stat.ST_MTIME]
78             except os.error, error:
79                 if error.errno != errno.ENOENT:
80                     raise
81                 # nicer error
82                 raise NoTemplate, 'No template file exists for templating '\
83                     '"%s" with template "%s" (neither "%s" nor "%s")'%(name,
84                     extension, filename, generic)
85             filename = generic
87         if self.templates.has_key(filename) and \
88                 stime < self.templates[filename].mtime:
89             # compiled template is up to date
90             return self.templates[filename]
92         # compile the template
93         self.templates[filename] = pt = RoundupPageTemplate()
94         pt.write(open(src).read())
95         pt.id = filename
96         pt.mtime = time.time()
97         return pt
99     def __getitem__(self, name):
100         name, extension = os.path.splitext(name)
101         if extension:
102             extension = extension[1:]
103         try:
104             return self.get(name, extension)
105         except NoTemplate, message:
106             raise KeyError, message
108 class RoundupPageTemplate(PageTemplate.PageTemplate):
109     ''' A Roundup-specific PageTemplate.
111         Interrogate the client to set up the various template variables to
112         be available:
114         *context*
115          this is one of three things:
116          1. None - we're viewing a "home" page
117          2. The current class of item being displayed. This is an HTMLClass
118             instance.
119          3. The current item from the database, if we're viewing a specific
120             item, as an HTMLItem instance.
121         *request*
122           Includes information about the current request, including:
123            - the url
124            - the current index information (``filterspec``, ``filter`` args,
125              ``properties``, etc) parsed out of the form. 
126            - methods for easy filterspec link generation
127            - *user*, the current user node as an HTMLItem instance
128            - *form*, the current CGI form information as a FieldStorage
129         *config*
130           The current tracker config.
131         *db*
132           The current database, used to access arbitrary database items.
133         *utils*
134           This is a special class that has its base in the TemplatingUtils
135           class in this file. If the tracker interfaces module defines a
136           TemplatingUtils class then it is mixed in, overriding the methods
137           in the base class.
138     '''
139     def getContext(self, client, classname, request):
140         # construct the TemplatingUtils class
141         utils = TemplatingUtils
142         if hasattr(client.instance.interfaces, 'TemplatingUtils'):
143             class utils(client.instance.interfaces.TemplatingUtils, utils):
144                 pass
146         c = {
147              'options': {},
148              'nothing': None,
149              'request': request,
150              'db': HTMLDatabase(client),
151              'config': client.instance.config,
152              'tracker': client.instance,
153              'utils': utils(client),
154              'templates': Templates(client.instance.config.TEMPLATES),
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         elif client.db.classes.has_key(classname):
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.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             try:
222                 l.append(cl.lookup(entry))
223             except KeyError:
224                 # ignore invalid keys
225                 pass
226     return l
228 class HTMLPermissions:
229     ''' Helpers that provide answers to commonly asked Permission questions.
230     '''
231     def is_edit_ok(self):
232         ''' Is the user allowed to Edit the current class?
233         '''
234         return self._db.security.hasPermission('Edit', self._client.userid,
235             self._classname)
236     def is_view_ok(self):
237         ''' Is the user allowed to View the current class?
238         '''
239         return self._db.security.hasPermission('View', self._client.userid,
240             self._classname)
241     def is_only_view_ok(self):
242         ''' Is the user only allowed to View (ie. not Edit) the current class?
243         '''
244         return self.is_view_ok() and not self.is_edit_ok()
246 class HTMLClass(HTMLPermissions):
247     ''' Accesses through a class (either through *class* or *db.<classname>*)
248     '''
249     def __init__(self, client, classname):
250         self._client = client
251         self._db = client.db
253         # we want classname to be exposed, but _classname gives a
254         # consistent API for extending Class/Item
255         self._classname = self.classname = classname
256         self._klass = self._db.getclass(self.classname)
257         self._props = self._klass.getprops()
259     def __repr__(self):
260         return '<HTMLClass(0x%x) %s>'%(id(self), self.classname)
262     def __getitem__(self, item):
263         ''' return an HTMLProperty instance
264         '''
265        #print 'HTMLClass.getitem', (self, item)
267         # we don't exist
268         if item == 'id':
269             return None
271         # get the property
272         prop = self._props[item]
274         # look up the correct HTMLProperty class
275         form = self._client.form
276         for klass, htmlklass in propclasses:
277             if not isinstance(prop, klass):
278                 continue
279             if form.has_key(item):
280                 if isinstance(prop, hyperdb.Multilink):
281                     value = lookupIds(self._db, prop,
282                         handleListCGIValue(form[item]))
283                 elif isinstance(prop, hyperdb.Link):
284                     value = form[item].value.strip()
285                     if value:
286                         value = lookupIds(self._db, prop, [value])[0]
287                     else:
288                         value = None
289                 else:
290                     value = form[item].value.strip() or None
291             else:
292                 if isinstance(prop, hyperdb.Multilink):
293                     value = []
294                 else:
295                     value = None
296             return htmlklass(self._client, '', prop, item, value)
298         # no good
299         raise KeyError, item
301     def __getattr__(self, attr):
302         ''' convenience access '''
303         try:
304             return self[attr]
305         except KeyError:
306             raise AttributeError, attr
308     def getItem(self, itemid, num_re=re.compile('\d+')):
309         ''' Get an item of this class by its item id.
310         '''
311         # make sure we're looking at an itemid
312         if not num_re.match(itemid):
313             itemid = self._klass.lookup(itemid)
315         if self.classname == 'user':
316             klass = HTMLUser
317         else:
318             klass = HTMLItem
320         return klass(self._client, self.classname, itemid)
322     def properties(self):
323         ''' Return HTMLProperty for all of this class' properties.
324         '''
325         l = []
326         for name, prop in self._props.items():
327             for klass, htmlklass in propclasses:
328                 if isinstance(prop, hyperdb.Multilink):
329                     value = []
330                 else:
331                     value = None
332                 if isinstance(prop, klass):
333                     l.append(htmlklass(self._client, '', prop, name, value))
334         return l
336     def list(self):
337         ''' List all items in this class.
338         '''
339         if self.classname == 'user':
340             klass = HTMLUser
341         else:
342             klass = HTMLItem
344         # get the list and sort it nicely
345         l = self._klass.list()
346         sortfunc = make_sort_function(self._db, self.classname)
347         l.sort(sortfunc)
349         l = [klass(self._client, self.classname, x) for x in l]
350         return l
352     def csv(self):
353         ''' Return the items of this class as a chunk of CSV text.
354         '''
355         # get the CSV module
356         try:
357             import csv
358         except ImportError:
359             return 'Sorry, you need the csv module to use this function.\n'\
360                 'Get it from: http://www.object-craft.com.au/projects/csv/'
362         props = self.propnames()
363         p = csv.parser()
364         s = StringIO.StringIO()
365         s.write(p.join(props) + '\n')
366         for nodeid in self._klass.list():
367             l = []
368             for name in props:
369                 value = self._klass.get(nodeid, name)
370                 if value is None:
371                     l.append('')
372                 elif isinstance(value, type([])):
373                     l.append(':'.join(map(str, value)))
374                 else:
375                     l.append(str(self._klass.get(nodeid, name)))
376             s.write(p.join(l) + '\n')
377         return s.getvalue()
379     def propnames(self):
380         ''' Return the list of the names of the properties of this class.
381         '''
382         idlessprops = self._klass.getprops(protected=0).keys()
383         idlessprops.sort()
384         return ['id'] + idlessprops
386     def filter(self, request=None):
387         ''' Return a list of items from this class, filtered and sorted
388             by the current requested filterspec/filter/sort/group args
389         '''
390         if request is not None:
391             filterspec = request.filterspec
392             sort = request.sort
393             group = request.group
394         if self.classname == 'user':
395             klass = HTMLUser
396         else:
397             klass = HTMLItem
398         l = [klass(self._client, self.classname, x)
399              for x in self._klass.filter(None, filterspec, sort, group)]
400         return l
402     def classhelp(self, properties=None, label='list', width='500',
403             height='400'):
404         ''' Pop up a javascript window with class help
406             This generates a link to a popup window which displays the 
407             properties indicated by "properties" of the class named by
408             "classname". The "properties" should be a comma-separated list
409             (eg. 'id,name,description'). Properties defaults to all the
410             properties of a class (excluding id, creator, created and
411             activity).
413             You may optionally override the label displayed, the width and
414             height. The popup window will be resizable and scrollable.
415         '''
416         if properties is None:
417             properties = self._klass.getprops(protected=0).keys()
418             properties.sort()
419             properties = ','.join(properties)
420         return '<a href="javascript:help_window(\'%s?:template=help&' \
421             'properties=%s\', \'%s\', \'%s\')"><b>(%s)</b></a>'%(
422             self.classname, properties, width, height, label)
424     def submit(self, label="Submit New Entry"):
425         ''' Generate a submit button (and action hidden element)
426         '''
427         return '  <input type="hidden" name=":action" value="new">\n'\
428         '  <input type="submit" name="submit" value="%s">'%label
430     def history(self):
431         return 'New node - no history'
433     def renderWith(self, name, **kwargs):
434         ''' Render this class with the given template.
435         '''
436         # create a new request and override the specified args
437         req = HTMLRequest(self._client)
438         req.classname = self.classname
439         req.update(kwargs)
441         # new template, using the specified classname and request
442         pt = Templates(self._db.config.TEMPLATES).get(self.classname, name)
444         # use our fabricated request
445         return pt.render(self._client, self.classname, req)
447 class HTMLItem(HTMLPermissions):
448     ''' Accesses through an *item*
449     '''
450     def __init__(self, client, classname, nodeid):
451         self._client = client
452         self._db = client.db
453         self._classname = classname
454         self._nodeid = nodeid
455         self._klass = self._db.getclass(classname)
456         self._props = self._klass.getprops()
458     def __repr__(self):
459         return '<HTMLItem(0x%x) %s %s>'%(id(self), self._classname,
460             self._nodeid)
462     def __getitem__(self, item):
463         ''' return an HTMLProperty instance
464         '''
465         #print 'HTMLItem.getitem', (self, item)
466         if item == 'id':
467             return self._nodeid
469         # get the property
470         prop = self._props[item]
472         # get the value, handling missing values
473         value = self._klass.get(self._nodeid, item, None)
474         if value is None:
475             if isinstance(self._props[item], hyperdb.Multilink):
476                 value = []
478         # look up the correct HTMLProperty class
479         for klass, htmlklass in propclasses:
480             if isinstance(prop, klass):
481                 return htmlklass(self._client, self._nodeid, prop, item, value)
483         raise KeyErorr, item
485     def __getattr__(self, attr):
486         ''' convenience access to properties '''
487         try:
488             return self[attr]
489         except KeyError:
490             raise AttributeError, attr
491     
492     def submit(self, label="Submit Changes"):
493         ''' Generate a submit button (and action hidden element)
494         '''
495         return '  <input type="hidden" name=":action" value="edit">\n'\
496         '  <input type="submit" name="submit" value="%s">'%label
498     def journal(self, direction='descending'):
499         ''' Return a list of HTMLJournalEntry instances.
500         '''
501         # XXX do this
502         return []
504     def history(self, direction='descending', dre=re.compile('\d+')):
505         l = ['<table class="history">'
506              '<tr><th colspan="4" class="header">',
507              _('History'),
508              '</th></tr><tr>',
509              _('<th>Date</th>'),
510              _('<th>User</th>'),
511              _('<th>Action</th>'),
512              _('<th>Args</th>'),
513             '</tr>']
514         comments = {}
515         history = self._klass.history(self._nodeid)
516         history.sort()
517         if direction == 'descending':
518             history.reverse()
519         for id, evt_date, user, action, args in history:
520             date_s = str(evt_date).replace("."," ")
521             arg_s = ''
522             if action == 'link' and type(args) == type(()):
523                 if len(args) == 3:
524                     linkcl, linkid, key = args
525                     arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
526                         linkcl, linkid, key)
527                 else:
528                     arg_s = str(args)
530             elif action == 'unlink' and type(args) == type(()):
531                 if len(args) == 3:
532                     linkcl, linkid, key = args
533                     arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
534                         linkcl, linkid, key)
535                 else:
536                     arg_s = str(args)
538             elif type(args) == type({}):
539                 cell = []
540                 for k in args.keys():
541                     # try to get the relevant property and treat it
542                     # specially
543                     try:
544                         prop = self._props[k]
545                     except KeyError:
546                         prop = None
547                     if prop is not None:
548                         if args[k] and (isinstance(prop, hyperdb.Multilink) or
549                                 isinstance(prop, hyperdb.Link)):
550                             # figure what the link class is
551                             classname = prop.classname
552                             try:
553                                 linkcl = self._db.getclass(classname)
554                             except KeyError:
555                                 labelprop = None
556                                 comments[classname] = _('''The linked class
557                                     %(classname)s no longer exists''')%locals()
558                             labelprop = linkcl.labelprop(1)
559                             hrefable = os.path.exists(
560                                 os.path.join(self._db.config.TEMPLATES,
561                                 classname+'.item'))
563                         if isinstance(prop, hyperdb.Multilink) and \
564                                 len(args[k]) > 0:
565                             ml = []
566                             for linkid in args[k]:
567                                 if isinstance(linkid, type(())):
568                                     sublabel = linkid[0] + ' '
569                                     linkids = linkid[1]
570                                 else:
571                                     sublabel = ''
572                                     linkids = [linkid]
573                                 subml = []
574                                 for linkid in linkids:
575                                     label = classname + linkid
576                                     # if we have a label property, try to use it
577                                     # TODO: test for node existence even when
578                                     # there's no labelprop!
579                                     try:
580                                         if labelprop is not None and \
581                                                 labelprop != 'id':
582                                             label = linkcl.get(linkid, labelprop)
583                                     except IndexError:
584                                         comments['no_link'] = _('''<strike>The
585                                             linked node no longer
586                                             exists</strike>''')
587                                         subml.append('<strike>%s</strike>'%label)
588                                     else:
589                                         if hrefable:
590                                             subml.append('<a href="%s%s">%s</a>'%(
591                                                 classname, linkid, label))
592                                         else:
593                                             subml.append(label)
594                                 ml.append(sublabel + ', '.join(subml))
595                             cell.append('%s:\n  %s'%(k, ', '.join(ml)))
596                         elif isinstance(prop, hyperdb.Link) and args[k]:
597                             label = classname + args[k]
598                             # if we have a label property, try to use it
599                             # TODO: test for node existence even when
600                             # there's no labelprop!
601                             if labelprop is not None and labelprop != 'id':
602                                 try:
603                                     label = linkcl.get(args[k], labelprop)
604                                 except IndexError:
605                                     comments['no_link'] = _('''<strike>The
606                                         linked node no longer
607                                         exists</strike>''')
608                                     cell.append(' <strike>%s</strike>,\n'%label)
609                                     # "flag" this is done .... euwww
610                                     label = None
611                             if label is not None:
612                                 if hrefable:
613                                     cell.append('%s: <a href="%s%s">%s</a>\n'%(k,
614                                         classname, args[k], label))
615                                 else:
616                                     cell.append('%s: %s' % (k,label))
618                         elif isinstance(prop, hyperdb.Date) and args[k]:
619                             d = date.Date(args[k])
620                             cell.append('%s: %s'%(k, str(d)))
622                         elif isinstance(prop, hyperdb.Interval) and args[k]:
623                             d = date.Interval(args[k])
624                             cell.append('%s: %s'%(k, str(d)))
626                         elif isinstance(prop, hyperdb.String) and args[k]:
627                             cell.append('%s: %s'%(k, cgi.escape(args[k])))
629                         elif not args[k]:
630                             cell.append('%s: (no value)\n'%k)
632                         else:
633                             cell.append('%s: %s\n'%(k, str(args[k])))
634                     else:
635                         # property no longer exists
636                         comments['no_exist'] = _('''<em>The indicated property
637                             no longer exists</em>''')
638                         cell.append('<em>%s: %s</em>\n'%(k, str(args[k])))
639                 arg_s = '<br />'.join(cell)
640             else:
641                 # unkown event!!
642                 comments['unknown'] = _('''<strong><em>This event is not
643                     handled by the history display!</em></strong>''')
644                 arg_s = '<strong><em>' + str(args) + '</em></strong>'
645             date_s = date_s.replace(' ', '&nbsp;')
646             # if the user's an itemid, figure the username (older journals
647             # have the username)
648             if dre.match(user):
649                 user = self._db.user.get(user, 'username')
650             l.append('<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>'%(
651                 date_s, user, action, arg_s))
652         if comments:
653             l.append(_('<tr><td colspan=4><strong>Note:</strong></td></tr>'))
654         for entry in comments.values():
655             l.append('<tr><td colspan=4>%s</td></tr>'%entry)
656         l.append('</table>')
657         return '\n'.join(l)
659     def renderQueryForm(self):
660         ''' Render this item, which is a query, as a search form.
661         '''
662         # create a new request and override the specified args
663         req = HTMLRequest(self._client)
664         req.classname = self._klass.get(self._nodeid, 'klass')
665         req.updateFromURL(self._klass.get(self._nodeid, 'url'))
667         # new template, using the specified classname and request
668         pt = Templates(self._db.config.TEMPLATES).get(req.classname, 'search')
670         # use our fabricated request
671         return pt.render(self._client, req.classname, req)
673 class HTMLUser(HTMLItem):
674     ''' Accesses through the *user* (a special case of item)
675     '''
676     def __init__(self, client, classname, nodeid):
677         HTMLItem.__init__(self, client, 'user', nodeid)
678         self._default_classname = client.classname
680         # used for security checks
681         self._security = client.db.security
683     _marker = []
684     def hasPermission(self, role, classname=_marker):
685         ''' Determine if the user has the Role.
687             The class being tested defaults to the template's class, but may
688             be overidden for this test by suppling an alternate classname.
689         '''
690         if classname is self._marker:
691             classname = self._default_classname
692         return self._security.hasPermission(role, self._nodeid, classname)
694     def is_edit_ok(self):
695         ''' Is the user allowed to Edit the current class?
696             Also check whether this is the current user's info.
697         '''
698         return self._db.security.hasPermission('Edit', self._client.userid,
699             self._classname) or self._nodeid == self._client.userid
701     def is_view_ok(self):
702         ''' Is the user allowed to View the current class?
703             Also check whether this is the current user's info.
704         '''
705         return self._db.security.hasPermission('Edit', self._client.userid,
706             self._classname) or self._nodeid == self._client.userid
708 class HTMLProperty:
709     ''' String, Number, Date, Interval HTMLProperty
711         Has useful attributes:
713          _name  the name of the property
714          _value the value of the property if any
716         A wrapper object which may be stringified for the plain() behaviour.
717     '''
718     def __init__(self, client, nodeid, prop, name, value):
719         self._client = client
720         self._db = client.db
721         self._nodeid = nodeid
722         self._prop = prop
723         self._name = name
724         self._value = value
725     def __repr__(self):
726         return '<HTMLProperty(0x%x) %s %r %r>'%(id(self), self._name, self._prop, self._value)
727     def __str__(self):
728         return self.plain()
729     def __cmp__(self, other):
730         if isinstance(other, HTMLProperty):
731             return cmp(self._value, other._value)
732         return cmp(self._value, other)
734 class StringHTMLProperty(HTMLProperty):
735     def plain(self, escape=0):
736         ''' Render a "plain" representation of the property
737         '''
738         if self._value is None:
739             return ''
740         if escape:
741             return cgi.escape(str(self._value))
742         return str(self._value)
744     def stext(self, escape=0):
745         ''' Render the value of the property as StructuredText.
747             This requires the StructureText module to be installed separately.
748         '''
749         s = self.plain(escape=escape)
750         if not StructuredText:
751             return s
752         return StructuredText(s,level=1,header=0)
754     def field(self, size = 30):
755         ''' Render a form edit field for the property
756         '''
757         if self._value is None:
758             value = ''
759         else:
760             value = cgi.escape(str(self._value))
761             value = '&quot;'.join(value.split('"'))
762         return '<input name="%s" value="%s" size="%s">'%(self._name, value, size)
764     def multiline(self, escape=0, rows=5, cols=40):
765         ''' Render a multiline form edit field for the property
766         '''
767         if self._value is None:
768             value = ''
769         else:
770             value = cgi.escape(str(self._value))
771             value = '&quot;'.join(value.split('"'))
772         return '<textarea name="%s" rows="%s" cols="%s">%s</textarea>'%(
773             self._name, rows, cols, value)
775     def email(self, escape=1):
776         ''' Render the value of the property as an obscured email address
777         '''
778         if self._value is None: value = ''
779         else: value = str(self._value)
780         if value.find('@') != -1:
781             name, domain = value.split('@')
782             domain = ' '.join(domain.split('.')[:-1])
783             name = name.replace('.', ' ')
784             value = '%s at %s ...'%(name, domain)
785         else:
786             value = value.replace('.', ' ')
787         if escape:
788             value = cgi.escape(value)
789         return value
791 class PasswordHTMLProperty(HTMLProperty):
792     def plain(self):
793         ''' Render a "plain" representation of the property
794         '''
795         if self._value is None:
796             return ''
797         return _('*encrypted*')
799     def field(self, size = 30):
800         ''' Render a form edit field for the property.
801         '''
802         return '<input type="password" name="%s" size="%s">'%(self._name, size)
804     def confirm(self, size = 30):
805         ''' Render a second form edit field for the property, used for 
806             confirmation that the user typed the password correctly. Generates
807             a field with name "name:confirm".
808         '''
809         return '<input type="password" name="%s:confirm" size="%s">'%(
810             self._name, size)
812 class NumberHTMLProperty(HTMLProperty):
813     def plain(self):
814         ''' Render a "plain" representation of the property
815         '''
816         return str(self._value)
818     def field(self, size = 30):
819         ''' Render a form edit field for the property
820         '''
821         if self._value is None:
822             value = ''
823         else:
824             value = cgi.escape(str(self._value))
825             value = '&quot;'.join(value.split('"'))
826         return '<input name="%s" value="%s" size="%s">'%(self._name, value, size)
828 class BooleanHTMLProperty(HTMLProperty):
829     def plain(self):
830         ''' Render a "plain" representation of the property
831         '''
832         if self.value is None:
833             return ''
834         return self._value and "Yes" or "No"
836     def field(self):
837         ''' Render a form edit field for the property
838         '''
839         checked = self._value and "checked" or ""
840         s = '<input type="radio" name="%s" value="yes" %s>Yes'%(self._name,
841             checked)
842         if checked:
843             checked = ""
844         else:
845             checked = "checked"
846         s += '<input type="radio" name="%s" value="no" %s>No'%(self._name,
847             checked)
848         return s
850 class DateHTMLProperty(HTMLProperty):
851     def plain(self):
852         ''' Render a "plain" representation of the property
853         '''
854         if self._value is None:
855             return ''
856         return str(self._value)
858     def field(self, size = 30):
859         ''' Render a form edit field for the property
860         '''
861         if self._value is None:
862             value = ''
863         else:
864             value = cgi.escape(str(self._value))
865             value = '&quot;'.join(value.split('"'))
866         return '<input name="%s" value="%s" size="%s">'%(self._name, value, size)
868     def reldate(self, pretty=1):
869         ''' Render the interval between the date and now.
871             If the "pretty" flag is true, then make the display pretty.
872         '''
873         if not self._value:
874             return ''
876         # figure the interval
877         interval = date.Date('.') - self._value
878         if pretty:
879             return interval.pretty()
880         return str(interval)
882     def pretty(self, format='%d %B %Y'):
883         ''' Render the date in a pretty format (eg. month names, spaces).
885             The format string is a standard python strftime format string.
886             Note that if the day is zero, and appears at the start of the
887             string, then it'll be stripped from the output. This is handy
888             for the situatin when a date only specifies a month and a year.
889         '''
890         return self._value.pretty()
892 class IntervalHTMLProperty(HTMLProperty):
893     def plain(self):
894         ''' Render a "plain" representation of the property
895         '''
896         if self._value is None:
897             return ''
898         return str(self._value)
900     def pretty(self):
901         ''' Render the interval in a pretty format (eg. "yesterday")
902         '''
903         return self._value.pretty()
905     def field(self, size = 30):
906         ''' Render a form edit field for the property
907         '''
908         if self._value is None:
909             value = ''
910         else:
911             value = cgi.escape(str(self._value))
912             value = '&quot;'.join(value.split('"'))
913         return '<input name="%s" value="%s" size="%s">'%(self._name, value, size)
915 class LinkHTMLProperty(HTMLProperty):
916     ''' Link HTMLProperty
917         Include the above as well as being able to access the class
918         information. Stringifying the object itself results in the value
919         from the item being displayed. Accessing attributes of this object
920         result in the appropriate entry from the class being queried for the
921         property accessed (so item/assignedto/name would look up the user
922         entry identified by the assignedto property on item, and then the
923         name property of that user)
924     '''
925     def __getattr__(self, attr):
926         ''' return a new HTMLItem '''
927        #print 'Link.getattr', (self, attr, self._value)
928         if not self._value:
929             raise AttributeError, "Can't access missing value"
930         if self._prop.classname == 'user':
931             klass = HTMLUser
932         else:
933             klass = HTMLItem
934         i = klass(self._client, self._prop.classname, self._value)
935         return getattr(i, attr)
937     def plain(self, escape=0):
938         ''' Render a "plain" representation of the property
939         '''
940         if self._value is None:
941             return ''
942         linkcl = self._db.classes[self._prop.classname]
943         k = linkcl.labelprop(1)
944         value = str(linkcl.get(self._value, k))
945         if escape:
946             value = cgi.escape(value)
947         return value
949     def field(self, showid=0, size=None):
950         ''' Render a form edit field for the property
951         '''
952         linkcl = self._db.getclass(self._prop.classname)
953         if linkcl.getprops().has_key('order'):  
954             sort_on = 'order'  
955         else:  
956             sort_on = linkcl.labelprop()  
957         options = linkcl.filter(None, {}, ('+', sort_on), (None, None))
958         # TODO: make this a field display, not a menu one!
959         l = ['<select name="%s">'%self._name]
960         k = linkcl.labelprop(1)
961         if self._value is None:
962             s = 'selected '
963         else:
964             s = ''
965         l.append(_('<option %svalue="-1">- no selection -</option>')%s)
966         for optionid in options:
967             # get the option value, and if it's None use an empty string
968             option = linkcl.get(optionid, k) or ''
970             # figure if this option is selected
971             s = ''
972             if optionid == self._value:
973                 s = 'selected '
975             # figure the label
976             if showid:
977                 lab = '%s%s: %s'%(self._prop.classname, optionid, option)
978             else:
979                 lab = option
981             # truncate if it's too long
982             if size is not None and len(lab) > size:
983                 lab = lab[:size-3] + '...'
985             # and generate
986             lab = cgi.escape(lab)
987             l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
988         l.append('</select>')
989         return '\n'.join(l)
991     def menu(self, size=None, height=None, showid=0, additional=[],
992             **conditions):
993         ''' Render a form select list for this property
994         '''
995         value = self._value
997         # sort function
998         sortfunc = make_sort_function(self._db, self._prop.classname)
1000         linkcl = self._db.getclass(self._prop.classname)
1001         l = ['<select name="%s">'%self._name]
1002         k = linkcl.labelprop(1)
1003         s = ''
1004         if value is None:
1005             s = 'selected '
1006         l.append(_('<option %svalue="-1">- no selection -</option>')%s)
1007         if linkcl.getprops().has_key('order'):  
1008             sort_on = ('+', 'order')
1009         else:  
1010             sort_on = ('+', linkcl.labelprop())
1011         options = linkcl.filter(None, conditions, sort_on, (None, None))
1012         for optionid in options:
1013             # get the option value, and if it's None use an empty string
1014             option = linkcl.get(optionid, k) or ''
1016             # figure if this option is selected
1017             s = ''
1018             if value in [optionid, option]:
1019                 s = 'selected '
1021             # figure the label
1022             if showid:
1023                 lab = '%s%s: %s'%(self._prop.classname, optionid, option)
1024             else:
1025                 lab = option
1027             # truncate if it's too long
1028             if size is not None and len(lab) > size:
1029                 lab = lab[:size-3] + '...'
1030             if additional:
1031                 m = []
1032                 for propname in additional:
1033                     m.append(linkcl.get(optionid, propname))
1034                 lab = lab + ' (%s)'%', '.join(map(str, m))
1036             # and generate
1037             lab = cgi.escape(lab)
1038             l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
1039         l.append('</select>')
1040         return '\n'.join(l)
1041 #    def checklist(self, ...)
1043 class MultilinkHTMLProperty(HTMLProperty):
1044     ''' Multilink HTMLProperty
1046         Also be iterable, returning a wrapper object like the Link case for
1047         each entry in the multilink.
1048     '''
1049     def __len__(self):
1050         ''' length of the multilink '''
1051         return len(self._value)
1053     def __getattr__(self, attr):
1054         ''' no extended attribute accesses make sense here '''
1055         raise AttributeError, attr
1057     def __getitem__(self, num):
1058         ''' iterate and return a new HTMLItem
1059         '''
1060        #print 'Multi.getitem', (self, num)
1061         value = self._value[num]
1062         if self._prop.classname == 'user':
1063             klass = HTMLUser
1064         else:
1065             klass = HTMLItem
1066         return klass(self._client, self._prop.classname, value)
1068     def __contains__(self, value):
1069         ''' Support the "in" operator
1070         '''
1071         return value in self._value
1073     def reverse(self):
1074         ''' return the list in reverse order
1075         '''
1076         l = self._value[:]
1077         l.reverse()
1078         if self._prop.classname == 'user':
1079             klass = HTMLUser
1080         else:
1081             klass = HTMLItem
1082         return [klass(self._client, self._prop.classname, value) for value in l]
1084     def plain(self, escape=0):
1085         ''' Render a "plain" representation of the property
1086         '''
1087         linkcl = self._db.classes[self._prop.classname]
1088         k = linkcl.labelprop(1)
1089         labels = []
1090         for v in self._value:
1091             labels.append(linkcl.get(v, k))
1092         value = ', '.join(labels)
1093         if escape:
1094             value = cgi.escape(value)
1095         return value
1097     def field(self, size=30, showid=0):
1098         ''' Render a form edit field for the property
1099         '''
1100         sortfunc = make_sort_function(self._db, self._prop.classname)
1101         linkcl = self._db.getclass(self._prop.classname)
1102         value = self._value[:]
1103         if value:
1104             value.sort(sortfunc)
1105         # map the id to the label property
1106         if not linkcl.getkey():
1107             showid=1
1108         if not showid:
1109             k = linkcl.labelprop(1)
1110             value = [linkcl.get(v, k) for v in value]
1111         value = cgi.escape(','.join(value))
1112         return '<input name="%s" size="%s" value="%s">'%(self._name, size, value)
1114     def menu(self, size=None, height=None, showid=0, additional=[],
1115             **conditions):
1116         ''' Render a form select list for this property
1117         '''
1118         value = self._value
1120         # sort function
1121         sortfunc = make_sort_function(self._db, self._prop.classname)
1123         linkcl = self._db.getclass(self._prop.classname)
1124         if linkcl.getprops().has_key('order'):  
1125             sort_on = ('+', 'order')
1126         else:  
1127             sort_on = ('+', linkcl.labelprop())
1128         options = linkcl.filter(None, conditions, sort_on, (None,None)) 
1129         height = height or min(len(options), 7)
1130         l = ['<select multiple name="%s" size="%s">'%(self._name, height)]
1131         k = linkcl.labelprop(1)
1132         for optionid in options:
1133             # get the option value, and if it's None use an empty string
1134             option = linkcl.get(optionid, k) or ''
1136             # figure if this option is selected
1137             s = ''
1138             if optionid in value or option in value:
1139                 s = 'selected '
1141             # figure the label
1142             if showid:
1143                 lab = '%s%s: %s'%(self._prop.classname, optionid, option)
1144             else:
1145                 lab = option
1146             # truncate if it's too long
1147             if size is not None and len(lab) > size:
1148                 lab = lab[:size-3] + '...'
1149             if additional:
1150                 m = []
1151                 for propname in additional:
1152                     m.append(linkcl.get(optionid, propname))
1153                 lab = lab + ' (%s)'%', '.join(m)
1155             # and generate
1156             lab = cgi.escape(lab)
1157             l.append('<option %svalue="%s">%s</option>'%(s, optionid,
1158                 lab))
1159         l.append('</select>')
1160         return '\n'.join(l)
1162 # set the propclasses for HTMLItem
1163 propclasses = (
1164     (hyperdb.String, StringHTMLProperty),
1165     (hyperdb.Number, NumberHTMLProperty),
1166     (hyperdb.Boolean, BooleanHTMLProperty),
1167     (hyperdb.Date, DateHTMLProperty),
1168     (hyperdb.Interval, IntervalHTMLProperty),
1169     (hyperdb.Password, PasswordHTMLProperty),
1170     (hyperdb.Link, LinkHTMLProperty),
1171     (hyperdb.Multilink, MultilinkHTMLProperty),
1174 def make_sort_function(db, classname):
1175     '''Make a sort function for a given class
1176     '''
1177     linkcl = db.getclass(classname)
1178     if linkcl.getprops().has_key('order'):
1179         sort_on = 'order'
1180     else:
1181         sort_on = linkcl.labelprop()
1182     def sortfunc(a, b, linkcl=linkcl, sort_on=sort_on):
1183         return cmp(linkcl.get(a, sort_on), linkcl.get(b, sort_on))
1184     return sortfunc
1186 def handleListCGIValue(value):
1187     ''' Value is either a single item or a list of items. Each item has a
1188         .value that we're actually interested in.
1189     '''
1190     if isinstance(value, type([])):
1191         return [value.value for value in value]
1192     else:
1193         value = value.value.strip()
1194         if not value:
1195             return []
1196         return value.split(',')
1198 class ShowDict:
1199     ''' A convenience access to the :columns index parameters
1200     '''
1201     def __init__(self, columns):
1202         self.columns = {}
1203         for col in columns:
1204             self.columns[col] = 1
1205     def __getitem__(self, name):
1206         return self.columns.has_key(name)
1208 class HTMLRequest:
1209     ''' The *request*, holding the CGI form and environment.
1211         "form" the CGI form as a cgi.FieldStorage
1212         "env" the CGI environment variables
1213         "base" the base URL for this instance
1214         "user" a HTMLUser instance for this user
1215         "classname" the current classname (possibly None)
1216         "template" the current template (suffix, also possibly None)
1218         Index args:
1219         "columns" dictionary of the columns to display in an index page
1220         "show" a convenience access to columns - request/show/colname will
1221                be true if the columns should be displayed, false otherwise
1222         "sort" index sort column (direction, column name)
1223         "group" index grouping property (direction, column name)
1224         "filter" properties to filter the index on
1225         "filterspec" values to filter the index on
1226         "search_text" text to perform a full-text search on for an index
1228     '''
1229     def __init__(self, client):
1230         self.client = client
1232         # easier access vars
1233         self.form = client.form
1234         self.env = client.env
1235         self.base = client.base
1236         self.user = HTMLUser(client, 'user', client.userid)
1238         # store the current class name and action
1239         self.classname = client.classname
1240         self.template = client.template
1242         self._post_init()
1244     def _post_init(self):
1245         ''' Set attributes based on self.form
1246         '''
1247         # extract the index display information from the form
1248         self.columns = []
1249         if self.form.has_key(':columns'):
1250             self.columns = handleListCGIValue(self.form[':columns'])
1251         self.show = ShowDict(self.columns)
1253         # sorting
1254         self.sort = (None, None)
1255         if self.form.has_key(':sort'):
1256             sort = self.form[':sort'].value
1257             if sort.startswith('-'):
1258                 self.sort = ('-', sort[1:])
1259             else:
1260                 self.sort = ('+', sort)
1261         if self.form.has_key(':sortdir'):
1262             self.sort = ('-', self.sort[1])
1264         # grouping
1265         self.group = (None, None)
1266         if self.form.has_key(':group'):
1267             group = self.form[':group'].value
1268             if group.startswith('-'):
1269                 self.group = ('-', group[1:])
1270             else:
1271                 self.group = ('+', group)
1272         if self.form.has_key(':groupdir'):
1273             self.group = ('-', self.group[1])
1275         # filtering
1276         self.filter = []
1277         if self.form.has_key(':filter'):
1278             self.filter = handleListCGIValue(self.form[':filter'])
1279         self.filterspec = {}
1280         db = self.client.db
1281         if self.classname is not None:
1282             props = db.getclass(self.classname).getprops()
1283             for name in self.filter:
1284                 if self.form.has_key(name):
1285                     prop = props[name]
1286                     fv = self.form[name]
1287                     if (isinstance(prop, hyperdb.Link) or
1288                             isinstance(prop, hyperdb.Multilink)):
1289                         self.filterspec[name] = lookupIds(db, prop,
1290                             handleListCGIValue(fv))
1291                     else:
1292                         self.filterspec[name] = fv.value
1294         # full-text search argument
1295         self.search_text = None
1296         if self.form.has_key(':search_text'):
1297             self.search_text = self.form[':search_text'].value
1299         # pagination - size and start index
1300         # figure batch args
1301         if self.form.has_key(':pagesize'):
1302             self.pagesize = int(self.form[':pagesize'].value)
1303         else:
1304             self.pagesize = 50
1305         if self.form.has_key(':startwith'):
1306             self.startwith = int(self.form[':startwith'].value)
1307         else:
1308             self.startwith = 0
1310     def updateFromURL(self, url):
1311         ''' Parse the URL for query args, and update my attributes using the
1312             values.
1313         ''' 
1314         self.form = {}
1315         for name, value in cgi.parse_qsl(url):
1316             if self.form.has_key(name):
1317                 if isinstance(self.form[name], type([])):
1318                     self.form[name].append(cgi.MiniFieldStorage(name, value))
1319                 else:
1320                     self.form[name] = [self.form[name],
1321                         cgi.MiniFieldStorage(name, value)]
1322             else:
1323                 self.form[name] = cgi.MiniFieldStorage(name, value)
1324         self._post_init()
1326     def update(self, kwargs):
1327         ''' Update my attributes using the keyword args
1328         '''
1329         self.__dict__.update(kwargs)
1330         if kwargs.has_key('columns'):
1331             self.show = ShowDict(self.columns)
1333     def description(self):
1334         ''' Return a description of the request - handle for the page title.
1335         '''
1336         s = [self.client.db.config.TRACKER_NAME]
1337         if self.classname:
1338             if self.client.nodeid:
1339                 s.append('- %s%s'%(self.classname, self.client.nodeid))
1340             else:
1341                 if self.template == 'item':
1342                     s.append('- new %s'%self.classname)
1343                 elif self.template == 'index':
1344                     s.append('- %s index'%self.classname)
1345                 else:
1346                     s.append('- %s %s'%(self.classname, self.template))
1347         else:
1348             s.append('- home')
1349         return ' '.join(s)
1351     def __str__(self):
1352         d = {}
1353         d.update(self.__dict__)
1354         f = ''
1355         for k in self.form.keys():
1356             f += '\n      %r=%r'%(k,handleListCGIValue(self.form[k]))
1357         d['form'] = f
1358         e = ''
1359         for k,v in self.env.items():
1360             e += '\n     %r=%r'%(k, v)
1361         d['env'] = e
1362         return '''
1363 form: %(form)s
1364 base: %(base)r
1365 classname: %(classname)r
1366 template: %(template)r
1367 columns: %(columns)r
1368 sort: %(sort)r
1369 group: %(group)r
1370 filter: %(filter)r
1371 search_text: %(search_text)r
1372 pagesize: %(pagesize)r
1373 startwith: %(startwith)r
1374 env: %(env)s
1375 '''%d
1377     def indexargs_form(self, columns=1, sort=1, group=1, filter=1,
1378             filterspec=1):
1379         ''' return the current index args as form elements '''
1380         l = []
1381         s = '<input type="hidden" name="%s" value="%s">'
1382         if columns and self.columns:
1383             l.append(s%(':columns', ','.join(self.columns)))
1384         if sort and self.sort[1] is not None:
1385             if self.sort[0] == '-':
1386                 val = '-'+self.sort[1]
1387             else:
1388                 val = self.sort[1]
1389             l.append(s%(':sort', val))
1390         if group and self.group[1] is not None:
1391             if self.group[0] == '-':
1392                 val = '-'+self.group[1]
1393             else:
1394                 val = self.group[1]
1395             l.append(s%(':group', val))
1396         if filter and self.filter:
1397             l.append(s%(':filter', ','.join(self.filter)))
1398         if filterspec:
1399             for k,v in self.filterspec.items():
1400                 l.append(s%(k, ','.join(v)))
1401         if self.search_text:
1402             l.append(s%(':search_text', self.search_text))
1403         l.append(s%(':pagesize', self.pagesize))
1404         l.append(s%(':startwith', self.startwith))
1405         return '\n'.join(l)
1407     def indexargs_url(self, url, args):
1408         ''' embed the current index args in a URL '''
1409         l = ['%s=%s'%(k,v) for k,v in args.items()]
1410         if self.columns and not args.has_key(':columns'):
1411             l.append(':columns=%s'%(','.join(self.columns)))
1412         if self.sort[1] is not None and not args.has_key(':sort'):
1413             if self.sort[0] == '-':
1414                 val = '-'+self.sort[1]
1415             else:
1416                 val = self.sort[1]
1417             l.append(':sort=%s'%val)
1418         if self.group[1] is not None and not args.has_key(':group'):
1419             if self.group[0] == '-':
1420                 val = '-'+self.group[1]
1421             else:
1422                 val = self.group[1]
1423             l.append(':group=%s'%val)
1424         if self.filter and not args.has_key(':columns'):
1425             l.append(':filter=%s'%(','.join(self.filter)))
1426         for k,v in self.filterspec.items():
1427             if not args.has_key(k):
1428                 l.append('%s=%s'%(k, ','.join(v)))
1429         if self.search_text and not args.has_key(':search_text'):
1430             l.append(':search_text=%s'%self.search_text)
1431         if not args.has_key(':pagesize'):
1432             l.append(':pagesize=%s'%self.pagesize)
1433         if not args.has_key(':startwith'):
1434             l.append(':startwith=%s'%self.startwith)
1435         return '%s?%s'%(url, '&'.join(l))
1436     indexargs_href = indexargs_url
1438     def base_javascript(self):
1439         return '''
1440 <script language="javascript">
1441 submitted = false;
1442 function submit_once() {
1443     if (submitted) {
1444         alert("Your request is being processed.\\nPlease be patient.");
1445         return 0;
1446     }
1447     submitted = true;
1448     return 1;
1451 function help_window(helpurl, width, height) {
1452     HelpWin = window.open('%s' + helpurl, 'RoundupHelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
1454 </script>
1455 '''%self.base
1457     def batch(self):
1458         ''' Return a batch object for results from the "current search"
1459         '''
1460         filterspec = self.filterspec
1461         sort = self.sort
1462         group = self.group
1464         # get the list of ids we're batching over
1465         klass = self.client.db.getclass(self.classname)
1466         if self.search_text:
1467             matches = self.client.db.indexer.search(
1468                 re.findall(r'\b\w{2,25}\b', self.search_text), klass)
1469         else:
1470             matches = None
1471         l = klass.filter(matches, filterspec, sort, group)
1473         # return the batch object, using IDs only
1474         return Batch(self.client, l, self.pagesize, self.startwith,
1475             classname=self.classname)
1477 # extend the standard ZTUtils Batch object to remove dependency on
1478 # Acquisition and add a couple of useful methods
1479 class Batch(ZTUtils.Batch):
1480     ''' Use me to turn a list of items, or item ids of a given class, into a
1481         series of batches.
1483         ========= ========================================================
1484         Parameter  Usage
1485         ========= ========================================================
1486         sequence  a list of HTMLItems or item ids
1487         classname if sequence is a list of ids, this is the class of item
1488         size      how big to make the sequence.
1489         start     where to start (0-indexed) in the sequence.
1490         end       where to end (0-indexed) in the sequence.
1491         orphan    if the next batch would contain less items than this
1492                   value, then it is combined with this batch
1493         overlap   the number of items shared between adjacent batches
1494         ========= ========================================================
1496         Attributes: Note that the "start" attribute, unlike the
1497         argument, is a 1-based index (I know, lame).  "first" is the
1498         0-based index.  "length" is the actual number of elements in
1499         the batch.
1501         "sequence_length" is the length of the original, unbatched, sequence.
1502     '''
1503     def __init__(self, client, sequence, size, start, end=0, orphan=0,
1504             overlap=0, classname=None):
1505         self.client = client
1506         self.last_index = self.last_item = None
1507         self.current_item = None
1508         self.classname = classname
1509         self.sequence_length = len(sequence)
1510         ZTUtils.Batch.__init__(self, sequence, size, start, end, orphan,
1511             overlap)
1513     # overwrite so we can late-instantiate the HTMLItem instance
1514     def __getitem__(self, index):
1515         if index < 0:
1516             if index + self.end < self.first: raise IndexError, index
1517             return self._sequence[index + self.end]
1518         
1519         if index >= self.length:
1520             raise IndexError, index
1522         # move the last_item along - but only if the fetched index changes
1523         # (for some reason, index 0 is fetched twice)
1524         if index != self.last_index:
1525             self.last_item = self.current_item
1526             self.last_index = index
1528         item = self._sequence[index + self.first]
1529         if self.classname:
1530             # map the item ids to instances
1531             if self.classname == 'user':
1532                 item = HTMLUser(self.client, self.classname, item)
1533             else:
1534                 item = HTMLItem(self.client, self.classname, item)
1535         self.current_item = item
1536         return item
1538     def propchanged(self, property):
1539         ''' Detect if the property marked as being the group property
1540             changed in the last iteration fetch
1541         '''
1542         if (self.last_item is None or
1543                 self.last_item[property] != self.current_item[property]):
1544             return 1
1545         return 0
1547     # override these 'cos we don't have access to acquisition
1548     def previous(self):
1549         if self.start == 1:
1550             return None
1551         return Batch(self.client, self._sequence, self._size,
1552             self.first - self._size + self.overlap, 0, self.orphan,
1553             self.overlap)
1555     def next(self):
1556         try:
1557             self._sequence[self.end]
1558         except IndexError:
1559             return None
1560         return Batch(self.client, self._sequence, self._size,
1561             self.end - self.overlap, 0, self.orphan, self.overlap)
1563 class TemplatingUtils:
1564     ''' Utilities for templating
1565     '''
1566     def __init__(self, client):
1567         self.client = client
1568     def Batch(self, sequence, size, start, end=0, orphan=0, overlap=0):
1569         return Batch(self.client, sequence, size, start, end, orphan,
1570             overlap)