Code

much nicer error messages when there's a templating error
[roundup.git] / roundup / cgi / templating.py
index a01fa7c768b2740d13e176a2e2eb12b0e22989f2..d29f31d6600aac87eae1d8eae92f55a511c90a2b 100644 (file)
-import sys, cgi, urllib, os
+import sys, cgi, urllib, os, re, os.path, time, errno
 
 from roundup import hyperdb, date
 from roundup.i18n import _
 
-
+try:
+    import cPickle as pickle
+except ImportError:
+    import pickle
+try:
+    import cStringIO as StringIO
+except ImportError:
+    import StringIO
 try:
     import StructuredText
 except ImportError:
     StructuredText = None
 
-# Make sure these modules are loaded
-# I need these to run PageTemplates outside of Zope :(
-# If we're running in a Zope environment, these modules will be loaded
-# already...
-if not sys.modules.has_key('zLOG'):
-    import zLOG
-    sys.modules['zLOG'] = zLOG
-if not sys.modules.has_key('MultiMapping'):
-    import MultiMapping
-    sys.modules['MultiMapping'] = MultiMapping
-if not sys.modules.has_key('ComputedAttribute'):
-    import ComputedAttribute
-    sys.modules['ComputedAttribute'] = ComputedAttribute
-if not sys.modules.has_key('ExtensionClass'):
-    import ExtensionClass
-    sys.modules['ExtensionClass'] = ExtensionClass
-if not sys.modules.has_key('Acquisition'):
-    import Acquisition
-    sys.modules['Acquisition'] = Acquisition
-
-# now it's safe to import PageTemplates and ZTUtils
-from PageTemplates import PageTemplate
-import ZTUtils
+# bring in the templating support
+from roundup.cgi.PageTemplates import PageTemplate
+from roundup.cgi.PageTemplates.Expressions import getEngine
+from roundup.cgi.TAL.TALInterpreter import TALInterpreter
+from roundup.cgi import ZTUtils
+
+# XXX WAH pagetemplates aren't pickleable :(
+#def getTemplate(dir, name, classname=None, request=None):
+#    ''' Interface to get a template, possibly loading a compiled template.
+#    '''
+#    # source
+#    src = os.path.join(dir, name)
+#
+#    # see if we can get a compile from the template"c" directory (most
+#    # likely is "htmlc"
+#    split = list(os.path.split(dir))
+#    split[-1] = split[-1] + 'c'
+#    cdir = os.path.join(*split)
+#    split.append(name)
+#    cpl = os.path.join(*split)
+#
+#    # ok, now see if the source is newer than the compiled (or if the
+#    # compiled even exists)
+#    MTIME = os.path.stat.ST_MTIME
+#    if (not os.path.exists(cpl) or os.stat(cpl)[MTIME] < os.stat(src)[MTIME]):
+#        # nope, we need to compile
+#        pt = RoundupPageTemplate()
+#        pt.write(open(src).read())
+#        pt.id = name
+#
+#        # save off the compiled template
+#        if not os.path.exists(cdir):
+#            os.makedirs(cdir)
+#        f = open(cpl, 'wb')
+#        pickle.dump(pt, f)
+#        f.close()
+#    else:
+#        # yay, use the compiled template
+#        f = open(cpl, 'rb')
+#        pt = pickle.load(f)
+#    return pt
+
+templates = {}
+
+def getTemplate(dir, name, extension, classname=None, request=None):
+    ''' Interface to get a template, possibly loading a compiled template.
+
+        "name" and "extension" indicate the template we're after, which in
+        most cases will be "name.extension". If "extension" is None, then
+        we look for a template just called "name" with no extension.
+
+        If the file "name.extension" doesn't exist, we look for
+        "_generic.extension" as a fallback.
+    '''
+    # default the name to "home"
+    if name is None:
+        name = 'home'
+
+    # find the source, figure the time it was last modified
+    if extension:
+        filename = '%s.%s'%(name, extension)
+    else:
+        filename = name
+    src = os.path.join(dir, filename)
+    try:
+        stime = os.stat(src)[os.path.stat.ST_MTIME]
+    except os.error, error:
+        if error.errno != errno.ENOENT or not extension:
+            raise
+        # try for a generic template
+        filename = '_generic.%s'%extension
+        src = os.path.join(dir, filename)
+        stime = os.stat(src)[os.path.stat.ST_MTIME]
+
+    key = (dir, filename)
+    if templates.has_key(key) and stime < templates[key].mtime:
+        # compiled template is up to date
+        return templates[key]
+
+    # compile the template
+    templates[key] = pt = RoundupPageTemplate()
+    pt.write(open(src).read())
+    pt.id = filename
+    pt.mtime = time.time()
+    return pt
 
 class RoundupPageTemplate(PageTemplate.PageTemplate):
     ''' A Roundup-specific PageTemplate.
@@ -77,36 +146,46 @@ class RoundupPageTemplate(PageTemplate.PageTemplate):
           python modules made available (XXX: not sure what's actually in
           there tho)
     '''
-    def __init__(self, client, classname=None, request=None):
-        ''' Extract the vars from the client and install in the context.
-        '''
-        self.client = client
-        self.classname = classname or self.client.classname
-        self.request = request or HTMLRequest(self.client)
-
-    def pt_getContext(self):
+    def getContext(self, client, classname, request):
         c = {
-             'klass': HTMLClass(self.client, self.classname),
+             'klass': HTMLClass(client, classname),
              'options': {},
              'nothing': None,
-             'request': self.request,
-             'content': self.client.content,
-             'db': HTMLDatabase(self.client),
-             'instance': self.client.instance
+             'request': request,
+             'content': client.content,
+             'db': HTMLDatabase(client),
+             'instance': client.instance
         }
         # add in the item if there is one
-        if self.client.nodeid:
-            c['item'] = HTMLItem(self.client.db, self.classname,
-                self.client.nodeid)
-            c[self.classname] = c['item']
+        if client.nodeid:
+            c['item'] = HTMLItem(client.db, classname, client.nodeid)
+            c[classname] = c['item']
         else:
-            c[self.classname] = c['klass']
+            c[classname] = c['klass']
         return c
-   
-    def render(self, *args, **kwargs):
-        if not kwargs.has_key('args'):
-            kwargs['args'] = args
-        return self.pt_render(extra_context={'options': kwargs})
+
+    def render(self, client, classname, request, **options):
+        """Render this Page Template"""
+
+        if not self._v_cooked:
+            self._cook()
+
+        __traceback_supplement__ = (PageTemplate.PageTemplateTracebackSupplement, self)
+
+        if self._v_errors:
+            raise PTRuntimeError, 'Page Template %s has errors.' % self.id
+
+        # figure the context
+        classname = classname or client.classname
+        request = request or HTMLRequest(client)
+        c = self.getContext(client, classname, request)
+        c.update({'options': options})
+
+        # and go
+        output = StringIO.StringIO()
+        TALInterpreter(self._v_program, self._v_macros,
+            getEngine().getContext(c), output, tal=1, strictinsert=0)()
+        return output.getvalue()
 
 class HTMLDatabase:
     ''' Return HTMLClasses for valid class fetches
@@ -115,7 +194,10 @@ class HTMLDatabase:
         self.client = client
         self.config = client.db.config
     def __getattr__(self, attr):
-        self.client.db.getclass(attr)
+        try:
+            self.client.db.getclass(attr)
+        except KeyError:
+            raise AttributeError, attr
         return HTMLClass(self.client, attr)
     def classes(self):
         l = self.client.db.classes.keys()
@@ -136,15 +218,15 @@ class HTMLClass:
     def __repr__(self):
         return '<HTMLClass(0x%x) %s>'%(id(self), self.classname)
 
-    def __getattr__(self, attr):
+    def __getitem__(self, item):
         ''' return an HTMLItem instance'''
-        #print 'getattr', (self, attr)
-        if attr == 'creator':
+        #print 'getitem', (self, attr)
+        if item == 'creator':
             return HTMLUser(self.client)
 
-        if not self.props.has_key(attr):
-            raise AttributeError, attr
-        prop = self.props[attr]
+        if not self.props.has_key(item):
+            raise KeyError, item
+        prop = self.props[item]
 
         # look up the correct HTMLProperty class
         for klass, htmlklass in propclasses:
@@ -153,10 +235,17 @@ class HTMLClass:
             else:
                 value = None
             if isinstance(prop, klass):
-                return htmlklass(self.db, '', prop, attr, value)
+                return htmlklass(self.db, '', prop, item, value)
 
         # no good
-        raise AttributeError, attr
+        raise KeyError, item
+
+    def __getattr__(self, attr):
+        ''' convenience access '''
+        try:
+            return self[attr]
+        except KeyError:
+            raise AttributeError, attr
 
     def properties(self):
         ''' Return HTMLProperty for all props
@@ -176,6 +265,40 @@ class HTMLClass:
         l = [HTMLItem(self.db, self.classname, x) for x in self.klass.list()]
         return l
 
+    def csv(self):
+        ''' Return the items of this class as a chunk of CSV text.
+        '''
+        # get the CSV module
+        try:
+            import csv
+        except ImportError:
+            return 'Sorry, you need the csv module to use this function.\n'\
+                'Get it from: http://www.object-craft.com.au/projects/csv/'
+
+        props = self.propnames()
+        p = csv.parser()
+        s = StringIO.StringIO()
+        s.write(p.join(props) + '\n')
+        for nodeid in self.klass.list():
+            l = []
+            for name in props:
+                value = self.klass.get(nodeid, name)
+                if value is None:
+                    l.append('')
+                elif isinstance(value, type([])):
+                    l.append(':'.join(map(str, value)))
+                else:
+                    l.append(str(self.klass.get(nodeid, name)))
+            s.write(p.join(l) + '\n')
+        return s.getvalue()
+
+    def propnames(self):
+        ''' Return the list of the names of the properties of this class.
+        '''
+        idlessprops = self.klass.getprops(protected=0).keys()
+        idlessprops.sort()
+        return ['id'] + idlessprops
+
     def filter(self, request=None):
         ''' Return a list of items from this class, filtered and sorted
             by the current requested filterspec/filter/sort/group args
@@ -199,9 +322,9 @@ class HTMLClass:
            You may optionally override the label displayed, the width and
            height. The popup window will be resizable and scrollable.
         '''
-        return '<a href="javascript:help_window(\'classhelp?classname=%s&' \
-            'properties=%s\', \'%s\', \'%s\')"><b>(%s)</b></a>'%(self.classname,
-            properties, width, height, label)
+        return '<a href="javascript:help_window(\'%s?:template=help&' \
+            ':contentonly=1&properties=%s\', \'%s\', \'%s\')"><b>'\
+            '(%s)</b></a>'%(self.classname, properties, width, height, label)
 
     def submit(self, label="Submit New Entry"):
         ''' Generate a submit button (and action hidden element)
@@ -218,19 +341,15 @@ class HTMLClass:
         # create a new request and override the specified args
         req = HTMLRequest(self.client)
         req.classname = self.classname
-        req.__dict__.update(kwargs)
+        req.update(kwargs)
 
         # new template, using the specified classname and request
-        pt = RoundupPageTemplate(self.client, self.classname, req)
-
-        # use the specified template
-        name = self.classname + '.' + name
-        pt.write(open('/tmp/test/html/%s'%name).read())
-        pt.id = name
+        pt = getTemplate(self.db.config.TEMPLATES, self.classname, name)
 
         # XXX handle PT rendering errors here nicely
         try:
-            return pt.render()
+            # use our fabricated request
+            return pt.render(self.client, self.classname, req)
         except PageTemplate.PTRuntimeError, message:
             return '<strong>%s</strong><ol>%s</ol>'%(message,
                 cgi.escape('<li>'.join(pt._v_errors)))
@@ -248,29 +367,33 @@ class HTMLItem:
     def __repr__(self):
         return '<HTMLItem(0x%x) %s %s>'%(id(self), self.classname, self.nodeid)
 
-    def __getattr__(self, attr):
+    def __getitem__(self, item):
         ''' return an HTMLItem instance'''
-        #print 'getattr', (self, attr)
-        if attr == 'id':
+        if item == 'id':
             return self.nodeid
-
-        if not self.props.has_key(attr):
-            raise AttributeError, attr
-        prop = self.props[attr]
+        if not self.props.has_key(item):
+            raise KeyError, item
+        prop = self.props[item]
 
         # get the value, handling missing values
-        value = self.klass.get(self.nodeid, attr, None)
+        value = self.klass.get(self.nodeid, item, None)
         if value is None:
-            if isinstance(self.props[attr], hyperdb.Multilink):
+            if isinstance(self.props[item], hyperdb.Multilink):
                 value = []
 
         # look up the correct HTMLProperty class
         for klass, htmlklass in propclasses:
             if isinstance(prop, klass):
-                return htmlklass(self.db, self.nodeid, prop, attr, value)
+                return htmlklass(self.db, self.nodeid, prop, item, value)
 
-        # no good
-        raise AttributeError, attr
+        raise KeyErorr, item
+
+    def __getattr__(self, attr):
+        ''' convenience access to properties '''
+        try:
+            return self[attr]
+        except KeyError:
+            raise AttributeError, attr
     
     def submit(self, label="Submit Changes"):
         ''' Generate a submit button (and action hidden element)
@@ -613,7 +736,7 @@ class LinkHTMLProperty(HTMLProperty):
     def plain(self, escape=0):
         if self.value is None:
             return _('[unselected]')
-        linkcl = self.db.classes[self.klass.classname]
+        linkcl = self.db.classes[self.prop.classname]
         k = linkcl.labelprop(1)
         value = str(linkcl.get(self.value, k))
         if escape:
@@ -688,10 +811,10 @@ class LinkHTMLProperty(HTMLProperty):
             s = 'selected '
         l.append(_('<option %svalue="-1">- no selection -</option>')%s)
         if linkcl.getprops().has_key('order'):  
-            sort_on = 'order'  
+            sort_on = ('+', 'order')
         else:  
-            sort_on = linkcl.labelprop() 
-        options = linkcl.filter(None, conditions, [sort_on], []) 
+            sort_on = ('+', linkcl.labelprop())
+        options = linkcl.filter(None, conditions, sort_on, (None, None))
         for optionid in options:
             option = linkcl.get(optionid, k)
             s = ''
@@ -735,6 +858,12 @@ class MultilinkHTMLProperty(HTMLProperty):
         value = self.value[num]
         return HTMLItem(self.db, self.prop.classname, value)
 
+    def reverse(self):
+        ''' return the list in reverse order '''
+        l = self.value[:]
+        l.reverse()
+        return [HTMLItem(self.db, self.prop.classname, value) for value in l]
+
     def plain(self, escape=0):
         linkcl = self.db.classes[self.prop.classname]
         k = linkcl.labelprop(1)
@@ -772,10 +901,10 @@ class MultilinkHTMLProperty(HTMLProperty):
 
         linkcl = self.db.getclass(self.prop.classname)
         if linkcl.getprops().has_key('order'):  
-            sort_on = 'order'  
+            sort_on = ('+', 'order')
         else:  
-            sort_on = linkcl.labelprop()
-        options = linkcl.filter(None, conditions, [sort_on], []
+            sort_on = ('+', linkcl.labelprop())
+        options = linkcl.filter(None, conditions, sort_on, (None,None)
         height = height or min(len(options), 7)
         l = ['<select multiple name="%s" size="%s">'%(self.name, height)]
         k = linkcl.labelprop(1)
@@ -834,11 +963,37 @@ def handleListCGIValue(value):
     else:
         return value.value.split(',')
 
-# XXX This is starting to look a lot (in data terms) like the client object
-# itself!
+class ShowDict:
+    ''' A convenience access to the :columns index parameters
+    '''
+    def __init__(self, columns):
+        self.columns = {}
+        for col in columns:
+            self.columns[col] = 1
+    def __getitem__(self, name):
+        return self.columns.has_key(name)
+
 class HTMLRequest:
     ''' The *request*, holding the CGI form and environment.
 
+        "form" the CGI form as a cgi.FieldStorage
+        "env" the CGI environment variables
+        "url" the current URL path for this request
+        "base" the base URL for this instance
+        "user" a HTMLUser instance for this user
+        "classname" the current classname (possibly None)
+        "template" the current template (suffix, also possibly None)
+
+        Index args:
+        "columns" dictionary of the columns to display in an index page
+        "show" a convenience access to columns - request/show/colname will
+               be true if the columns should be displayed, false otherwise
+        "sort" index sort column (direction, column name)
+        "group" index grouping property (direction, column name)
+        "filter" properties to filter the index on
+        "filterspec" values to filter the index on
+        "search_text" text to perform a full-text search on for an index
+
     '''
     def __init__(self, client):
         self.client = client
@@ -847,30 +1002,91 @@ class HTMLRequest:
         self.form = client.form
         self.env = client.env
         self.base = client.base
+        self.url = client.url
         self.user = HTMLUser(client)
 
         # store the current class name and action
         self.classname = client.classname
-        self.template_type = client.template_type
+        self.template = client.template
 
         # extract the index display information from the form
-        self.columns = {}
+        self.columns = []
         if self.form.has_key(':columns'):
-            for entry in handleListCGIValue(self.form[':columns']):
-                self.columns[entry] = 1
-        self.sort = []
+            self.columns = handleListCGIValue(self.form[':columns'])
+        self.show = ShowDict(self.columns)
+
+        # sorting
+        self.sort = (None, None)
         if self.form.has_key(':sort'):
-            self.sort = handleListCGIValue(self.form[':sort'])
-        self.group = []
+            sort = self.form[':sort'].value
+            if sort.startswith('-'):
+                self.sort = ('-', sort[1:])
+            else:
+                self.sort = ('+', sort)
+        if self.form.has_key(':sortdir'):
+            self.sort = ('-', self.sort[1])
+
+        # grouping
+        self.group = (None, None)
         if self.form.has_key(':group'):
-            self.group = handleListCGIValue(self.form[':group'])
+            group = self.form[':group'].value
+            if group.startswith('-'):
+                self.group = ('-', group[1:])
+            else:
+                self.group = ('+', group)
+        if self.form.has_key(':groupdir'):
+            self.group = ('-', self.group[1])
+
+        # filtering
         self.filter = []
         if self.form.has_key(':filter'):
             self.filter = handleListCGIValue(self.form[':filter'])
         self.filterspec = {}
-        for name in self.filter:
-            if self.form.has_key(name):
-                self.filterspec[name]=handleListCGIValue(self.form[name])
+        if self.classname is not None:
+            props = self.client.db.getclass(self.classname).getprops()
+            for name in self.filter:
+                if self.form.has_key(name):
+                    prop = props[name]
+                    fv = self.form[name]
+                    if (isinstance(prop, hyperdb.Link) or
+                            isinstance(prop, hyperdb.Multilink)):
+                        self.filterspec[name] = handleListCGIValue(fv)
+                    else:
+                        self.filterspec[name] = fv.value
+
+        # full-text search argument
+        self.search_text = None
+        if self.form.has_key(':search_text'):
+            self.search_text = self.form[':search_text'].value
+
+        # pagination - size and start index
+        # figure batch args
+        if self.form.has_key(':pagesize'):
+            self.pagesize = int(self.form[':pagesize'].value)
+        else:
+            self.pagesize = 50
+        if self.form.has_key(':startwith'):
+            self.startwith = int(self.form[':startwith'].value)
+        else:
+            self.startwith = 0
+
+    def update(self, kwargs):
+        self.__dict__.update(kwargs)
+        if kwargs.has_key('columns'):
+            self.show = ShowDict(self.columns)
+
+    def description(self):
+        ''' Return a description of the request - handle for the page title.
+        '''
+        s = [self.client.db.config.INSTANCE_NAME]
+        if self.classname:
+            if self.client.nodeid:
+                s.append('- %s%s'%(self.classname, self.client.nodeid))
+            else:
+                s.append('- index of '+self.classname)
+        else:
+            s.append('- home')
+        return ' '.join(s)
 
     def __str__(self):
         d = {}
@@ -885,45 +1101,78 @@ class HTMLRequest:
         d['env'] = e
         return '''
 form: %(form)s
+url: %(url)r
 base: %(base)r
 classname: %(classname)r
-template_type: %(template_type)r
+template: %(template)r
 columns: %(columns)r
 sort: %(sort)r
 group: %(group)r
 filter: %(filter)r
-filterspec: %(filterspec)r
+search_text: %(search_text)r
+pagesize: %(pagesize)r
+startwith: %(startwith)r
 env: %(env)s
 '''%d
 
-    def indexargs_form(self):
+    def indexargs_form(self, columns=1, sort=1, group=1, filter=1,
+            filterspec=1):
         ''' return the current index args as form elements '''
         l = []
         s = '<input type="hidden" name="%s" value="%s">'
-        if self.columns:
-            l.append(s%(':columns', ','.join(self.columns.keys())))
-        if self.sort:
-            l.append(s%(':sort', ','.join(self.sort)))
-        if self.group:
-            l.append(s%(':group', ','.join(self.group)))
-        if self.filter:
+        if columns and self.columns:
+            l.append(s%(':columns', ','.join(self.columns)))
+        if sort and self.sort[1] is not None:
+            if self.sort[0] == '-':
+                val = '-'+self.sort[1]
+            else:
+                val = self.sort[1]
+            l.append(s%(':sort', val))
+        if group and self.group[1] is not None:
+            if self.group[0] == '-':
+                val = '-'+self.group[1]
+            else:
+                val = self.group[1]
+            l.append(s%(':group', val))
+        if filter and self.filter:
             l.append(s%(':filter', ','.join(self.filter)))
-        for k,v in self.filterspec.items():
-            l.append(s%(k, ','.join(v)))
+        if filterspec:
+            for k,v in self.filterspec.items():
+                l.append(s%(k, ','.join(v)))
+        if self.search_text:
+            l.append(s%(':search_text', self.search_text))
+        l.append(s%(':pagesize', self.pagesize))
+        l.append(s%(':startwith', self.startwith))
         return '\n'.join(l)
 
     def indexargs_href(self, url, args):
+        ''' embed the current index args in a URL '''
         l = ['%s=%s'%(k,v) for k,v in args.items()]
-        if self.columns:
-            l.append(':columns=%s'%(','.join(self.columns.keys())))
-        if self.sort:
-            l.append(':sort=%s'%(','.join(self.sort)))
-        if self.group:
-            l.append(':group=%s'%(','.join(self.group)))
-        if self.filter:
+        if self.columns and not args.has_key(':columns'):
+            l.append(':columns=%s'%(','.join(self.columns)))
+        if self.sort[1] is not None and not args.has_key(':sort'):
+            if self.sort[0] == '-':
+                val = '-'+self.sort[1]
+            else:
+                val = self.sort[1]
+            l.append(':sort=%s'%val)
+        if self.group[1] is not None and not args.has_key(':group'):
+            if self.group[0] == '-':
+                val = '-'+self.group[1]
+            else:
+                val = self.group[1]
+            l.append(':group=%s'%val)
+        if self.filter and not args.has_key(':columns'):
             l.append(':filter=%s'%(','.join(self.filter)))
         for k,v in self.filterspec.items():
-            l.append('%s=%s'%(k, ','.join(v)))
+            if not args.has_key(k):
+                l.append('%s=%s'%(k, ','.join(v)))
+        if self.search_text and not args.has_key(':search_text'):
+            l.append(':search_text=%s'%self.search_text)
+        if not args.has_key(':pagesize'):
+            l.append(':pagesize=%s'%self.pagesize)
+        if not args.has_key(':startwith'):
+            l.append(':startwith=%s'%self.startwith)
         return '%s?%s'%(url, '&'.join(l))
 
     def base_javascript(self):
@@ -954,25 +1203,26 @@ function help_window(helpurl, width, height) {
 
         # get the list of ids we're batching over
         klass = self.client.db.getclass(self.classname)
-        l = klass.filter(None, filterspec, sort, group)
-
-        # figure batch args
-        if self.form.has_key(':pagesize'):
-            size = int(self.form[':pagesize'].value)
-        else:
-            size = 50
-        if self.form.has_key(':startwith'):
-            start = int(self.form[':startwith'].value)
+        if self.search_text:
+            matches = self.client.db.indexer.search(
+                re.findall(r'\b\w{2,25}\b', self.search_text), klass)
         else:
-            start = 0
+            matches = None
+        l = klass.filter(matches, filterspec, sort, group)
 
         # return the batch object
-        return Batch(self.client, self.classname, l, size, start)
+        return Batch(self.client, self.classname, l, self.pagesize,
+            self.startwith)
+
 
+# extend the standard ZTUtils Batch object to remove dependency on
+# Acquisition and add a couple of useful methods
 class Batch(ZTUtils.Batch):
     def __init__(self, client, classname, l, size, start, end=0, orphan=0, overlap=0):
         self.client = client
         self.classname = classname
+        self.last_index = self.last_item = None
+        self.current_item = None
         ZTUtils.Batch.__init__(self, l, size, start, end, orphan, overlap)
 
     # overwrite so we can late-instantiate the HTMLItem instance
@@ -983,13 +1233,28 @@ class Batch(ZTUtils.Batch):
         
         if index >= self.length: raise IndexError, index
 
+        # move the last_item along - but only if the fetched index changes
+        # (for some reason, index 0 is fetched twice)
+        if index != self.last_index:
+            self.last_item = self.current_item
+            self.last_index = index
+
         # wrap the return in an HTMLItem
-        return HTMLItem(self.client.db, self.classname,
+        self.current_item = HTMLItem(self.client.db, self.classname,
             self._sequence[index+self.first])
+        return self.current_item
+
+    def propchanged(self, property):
+        ''' Detect if the property marked as being the group property
+            changed in the last iteration fetch
+        '''
+        if (self.last_item is None or
+                self.last_item[property] != self.current_item[property]):
+            return 1
+        return 0
 
     # override these 'cos we don't have access to acquisition
     def previous(self):
-        print self.start
         if self.start == 1:
             return None
         return Batch(self.client, self.classname, self._sequence, self._size,