Code

. Implemented security assertion idea punted to mailing list (pretty easy to
authorrichard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2>
Sun, 1 Sep 2002 12:18:41 +0000 (12:18 +0000)
committerrichard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2>
Sun, 1 Sep 2002 12:18:41 +0000 (12:18 +0000)
  back out if someone comes up with a better idea) so editing "my details"
  works again. Rationalised and cleaned up the actions in any case.
. fixed some more display issues (stuff appearing when it should and shouldn't)
. trying a nicer colouring scheme for the top level page
. handle no grouping being specified
. fixed journaltag so the logged-in user is journalled, not admin!

git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@1020 57a73879-2fb5-44c3-a270-3262357dd7e2

TODO.txt
doc/customizing.txt
roundup/cgi/client.py
roundup/cgi/templating.py
roundup/templates/classic/html/issue.index
roundup/templates/classic/html/page
roundup/templates/classic/html/style.css
roundup/templates/classic/html/user.item

index 4e48e0ec77167889d3d62c072a104bfe666e10a4..30cad613f19656f42f3e74cfa10be77d178c6c4f 100644 (file)
--- a/TODO.txt
+++ b/TODO.txt
@@ -49,7 +49,7 @@ New templating TODO:
 . query saving
 . search "refinement" (pre-fill the search page with the current search
   parameters)
-. security on actions (only allows/enforces generic Edit perm on the class :()
+. web registration of new users by anonymous
 
 ongoing: any bugs
 
index d2e466b5a2e6d41c24fc750b412bf8cafc62c0d1..7957eec2b44b80b407c8c6d889b1fc5236d7680d 100644 (file)
@@ -2,7 +2,7 @@
 Customising Roundup
 ===================
 
-:Version: $Revision: 1.15 $
+:Version: $Revision: 1.16 $
 
 .. contents::
 
@@ -926,12 +926,13 @@ The default interfaces define:
 
 - Web Registration
 - Web Access
+- Web Roles
 - Email Registration
 - Email Access
 
 These are hooked into the default Roles:
 
-- Admin (Edit everything, View everything)
+- Admin (Edit everything, View everything, Web Roles)
 - User (Web Access, Email Access)
 - Anonymous (Web Registration, Email Registration)
 
@@ -957,6 +958,19 @@ they register through email.
 You may use the ``roundup-admin`` "``security``" command to display the
 current Role and Permission configuration in your instance.
 
+Adding a new Permission
+~~~~~~~~~~~~~~~~~~~~~~~
+
+When adding a new Permission, you will need to:
+
+1. add it to your instance's dbinit so it is created
+2. enable it for the Roles that should have it (verify with
+   "``roundup-admin security``")
+3. add it to the relevant HTML interface templates
+4. add it to the appropriate xxxPermission methods on in your instance
+   interfaces module
+
+
 
 -----------------
 
index 81695009a719c6a80a4aa59eb6077e4a4d51882d..3c2a87e062a15f94453dc5893601f8f796e26b15 100644 (file)
@@ -1,4 +1,4 @@
-# $Id: client.py,v 1.2 2002-09-01 04:32:30 richard Exp $
+# $Id: client.py,v 1.3 2002-09-01 12:18:40 richard Exp $
 
 __doc__ = """
 WWW request handler (also used in the stand-alone server).
@@ -171,6 +171,9 @@ class Client:
         else:
             self.user = user
 
+        # reopen the database as the correct user
+        self.opendb(self.user)
+
     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
         ''' Determine the context of this page:
 
@@ -291,12 +294,12 @@ class Client:
 
     # these are the actions that are available
     actions = {
-        'edit':     'edititem_action',
-        'new':      'newitem_action',
+        'edit':     'editItemAction',
+        'new':      'newItemAction',
         'login':    'login_action',
         'logout':   'logout_action',
         'register': 'register_action',
-        'search':   'search_action',
+        'search':   'searchAction',
     }
     def handle_action(self):
         ''' Determine whether there should be an _action called.
@@ -304,12 +307,12 @@ class Client:
             The action is defined by the form variable :action which
             identifies the method on this object to call. The four basic
             actions are defined in the "actions" dictionary on this class:
-             "edit"      -> self.edititem_action
-             "new"       -> self.newitem_action
+             "edit"      -> self.editItemAction
+             "new"       -> self.newItemAction
              "login"     -> self.login_action
              "logout"    -> self.logout_action
              "register"  -> self.register_action
-             "search"    -> self.search_action
+             "search"    -> self.searchAction
 
         '''
         if not self.form.has_key(':action'):
@@ -454,12 +457,10 @@ class Client:
         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
             ''))
         self.header(headers={'Set-Cookie':
-            'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
-#            'Location': self.db.config.DEFAULT_VIEW}, response=301)
+          'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
 
-        # suboptimal, but will do for now
+        # Let the user know what's going on
         self.ok_message.append(_('You are logged out'))
-        #raise Redirect, None
 
     def register_action(self):
         '''Attempt to create a new user based on the contents of the form
@@ -497,7 +498,7 @@ class Client:
         # nice message
         self.ok_message.append(_('You are now registered, welcome!'))
 
-    def edititem_action(self):
+    def editItemAction(self):
         ''' Perform an edit of an item in the database.
 
             Some special form elements:
@@ -516,24 +517,26 @@ class Client:
         '''
         cl = self.db.classes[self.classname]
 
+        # parse the props from the form
+        try:
+            props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
+        except (ValueError, KeyError), message:
+            self.error_message.append(_('Error: ') + str(message))
+            return
+
         # check permission
-        userid = self.db.user.lookup(self.user)
-        if not self.db.security.hasPermission('Edit', userid, self.classname):
+        if not self.editItemPermission(props):
             self.error_message.append(
-                _('You do not have permission to edit %(classname)s' %
+                _('You do not have permission to edit %(classname)s'%
                 self.__dict__))
             return
 
         # perform the edit
         try:
-            props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
-
             # make changes to the node
             props = self._changenode(props)
-
             # handle linked nodes 
             self._post_editnode(self.nodeid)
-
         except (ValueError, KeyError), message:
             self.error_message.append(_('Error: ') + str(message))
             return
@@ -556,14 +559,46 @@ class Client:
         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
             self.nodeid,  urllib.quote(message))
 
-    def newitem_action(self):
+    def editItemPermission(self, props):
+        ''' Determine whether the user has permission to edit this item.
+
+            Base behaviour is to check the user can edit this class. If we're
+            editing the "user" class, users are allowed to edit their own
+            details. Unless it's the "roles" property, which requires the
+            special Permission "Web Roles".
+        '''
+        # if this is a user node and the user is editing their own node, then
+        # we're OK
+        has = self.db.security.hasPermission
+        if self.classname == 'user':
+            # reject if someone's trying to edit "roles" and doesn't have the
+            # right permission.
+            if props.has_key('roles') and not has('Web Roles', self.userid,
+                    'user'):
+                return 0
+            # if the item being edited is the current user, we're ok
+            if self.nodeid == self.userid:
+                return 1
+        if not self.db.security.hasPermission('Edit', self.userid,
+                self.classname):
+            return 0
+        return 1
+
+    def newItemAction(self):
         ''' Add a new item to the database.
 
-            This follows the same form as the edititem_action
+            This follows the same form as the editItemAction
         '''
-        # check permission
-        userid = self.db.user.lookup(self.user)
-        if not self.db.security.hasPermission('Edit', userid, self.classname):
+        cl = self.db.classes[self.classname]
+
+        # parse the props from the form
+        try:
+            props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
+        except (ValueError, KeyError), message:
+            self.error_message.append(_('Error: ') + str(message))
+            return
+
+        if not self.newItemPermission(props):
             self.error_message.append(
                 _('You do not have permission to create %s' %self.classname))
 
@@ -578,7 +613,7 @@ class Client:
 
         try:
             # do the create
-            nid = self._createnode()
+            nid = self._createnode(props)
 
             # handle linked nodes 
             self._post_editnode(nid)
@@ -606,20 +641,33 @@ class Client:
         raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
             nid,  urllib.quote(message))
 
-    def genericedit_action(self):
+    def newItemPermission(self, props):
+        ''' Determine whether the user has permission to create (edit) this
+            item.
+
+            Base behaviour is to check the user can edit this class. No
+            additional property checks are made. Additionally, new user items
+            may be created if the user has the "Web Registration" Permission.
+        '''
+        has = self.db.security.hasPermission
+        if self.classname == 'user' and has('Web Registration', self.userid,
+                'user'):
+            return 1
+        if not has('Edit', self.userid, self.classname):
+            return 0
+        return 1
+
+    def genericEditAction(self):
         ''' Performs an edit of all of a class' items in one go.
 
             The "rows" CGI var defines the CSV-formatted entries for the
             class. New nodes are identified by the ID 'X' (or any other
             non-existent ID) and removed lines are retired.
         '''
-        userid = self.db.user.lookup(self.user)
-        if not self.db.security.hasPermission('Edit', userid, self.classname):
-            raise Unauthorised, _("You do not have permission to access"\
-                        " %(action)s.")%{'action': self.classname}
-        cl = self.db.classes[self.classname]
-        idlessprops = cl.getprops(protected=0).keys()
-        props = ['id'] + idlessprops
+        # generic edit is per-class only
+        if not self.genericEditPermission():
+            self.error_message.append(
+                _('You do not have permission to edit %s' %self.classname))
 
         # get the CSV module
         try:
@@ -630,6 +678,10 @@ class Client:
                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
             return
 
+        cl = self.db.classes[self.classname]
+        idlessprops = cl.getprops(protected=0).keys()
+        props = ['id'] + idlessprops
+
         # do the edit
         rows = self.form['rows'].value.splitlines()
         p = csv.parser()
@@ -680,6 +732,77 @@ class Client:
         raise Redirect, '%s/%s?:ok_message=%s'%(self.base, self.classname, 
             urllib.quote(message))
 
+    def genericEditPermission(self):
+        ''' Determine whether the user has permission to edit this class.
+
+            Base behaviour is to check the user can edit this class.
+        ''' 
+        if not self.db.security.hasPermission('Edit', self.userid,
+                self.classname):
+            return 0
+        return 1
+
+    def searchAction(self):
+        ''' Mangle some of the form variables.
+
+            Set the form ":filter" variable based on the values of the
+            filter variables - if they're set to anything other than
+            "dontcare" then add them to :filter.
+        '''
+        # generic edit is per-class only
+        if not self.searchPermission():
+            self.error_message.append(
+                _('You do not have permission to search %s' %self.classname))
+
+        # add a faked :filter form variable for each filtering prop
+        props = self.db.classes[self.classname].getprops()
+        for key in self.form.keys():
+            if not props.has_key(key): continue
+            if not self.form[key].value: continue
+            self.form.value.append(cgi.MiniFieldStorage(':filter', key))
+
+    def searchPermission(self):
+        ''' Determine whether the user has permission to search this class.
+
+            Base behaviour is to check the user can view this class.
+        ''' 
+        if not self.db.security.hasPermission('View', self.userid,
+                self.classname):
+            return 0
+        return 1
+
+    def XXXremove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
+        # XXX I believe this could be handled by a regular edit action that
+        # just sets the multilink...
+        # XXX handle this !
+        target = self.index_arg(':target')[0]
+        m = dre.match(target)
+        if m:
+            classname = m.group(1)
+            nodeid = m.group(2)
+            cl = self.db.getclass(classname)
+            cl.retire(nodeid)
+            # now take care of the reference
+            parentref =  self.index_arg(':multilink')[0]
+            parent, prop = parentref.split(':')
+            m = dre.match(parent)
+            if m:
+                self.classname = m.group(1)
+                self.nodeid = m.group(2)
+                cl = self.db.getclass(self.classname)
+                value = cl.get(self.nodeid, prop)
+                value.remove(nodeid)
+                cl.set(self.nodeid, **{prop:value})
+                func = getattr(self, 'show%s'%self.classname)
+                return func()
+            else:
+                raise NotFound, parent
+        else:
+            raise NotFound, target
+
+    #
+    #  Utility methods for editing
+    #
     def _changenode(self, props):
         ''' change the node based on the contents of the form
         '''
@@ -695,11 +818,10 @@ class Client:
         # make the changes
         return cl.set(self.nodeid, **props)
 
-    def _createnode(self):
+    def _createnode(self, props):
         ''' create a node based on the contents of the form
         '''
         cl = self.db.classes[self.classname]
-        props = parsePropsFromForm(self.db, cl, self.form)
 
         # check for messages and files
         message, files = self._handle_message()
@@ -806,47 +928,6 @@ class Client:
                     link = self.db.classes[link]
                     link.set(nodeid, **{property: nid})
 
-    def search_action(self):
-        ''' Mangle some of the form variables.
-
-            Set the form ":filter" variable based on the values of the
-            filter variables - if they're set to anything other than
-            "dontcare" then add them to :filter.
-        '''
-        # add a faked :filter form variable for each filtering prop
-        props = self.db.classes[self.classname].getprops()
-        for key in self.form.keys():
-            if not props.has_key(key): continue
-            if not self.form[key].value: continue
-            self.form.value.append(cgi.MiniFieldStorage(':filter', key))
-
-    def remove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
-        # XXX handle this !
-        target = self.index_arg(':target')[0]
-        m = dre.match(target)
-        if m:
-            classname = m.group(1)
-            nodeid = m.group(2)
-            cl = self.db.getclass(classname)
-            cl.retire(nodeid)
-            # now take care of the reference
-            parentref =  self.index_arg(':multilink')[0]
-            parent, prop = parentref.split(':')
-            m = dre.match(parent)
-            if m:
-                self.classname = m.group(1)
-                self.nodeid = m.group(2)
-                cl = self.db.getclass(self.classname)
-                value = cl.get(self.nodeid, prop)
-                value.remove(nodeid)
-                cl.set(self.nodeid, **{prop:value})
-                func = getattr(self, 'show%s'%self.classname)
-                return func()
-            else:
-                raise NotFound, parent
-        else:
-            raise NotFound, target
-
 
 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
     '''Pull properties for the given class out of the form.
index 2a04f5ab44ea1eba69522dccd351f10bd4587ae7..93051a0605226f7b29f59b178ccb236933c70703 100644 (file)
@@ -903,15 +903,17 @@ class HTMLRequest:
         if self.form.has_key(':filter'):
             self.filter = handleListCGIValue(self.form[':filter'])
         self.filterspec = {}
-        props = self.client.db.getclass(self.classname).getprops()
-        for name in self.filter:
-            if self.form.has_key(name):
-                prop = props[name]
-                if (isinstance(prop, hyperdb.Link) or
-                        isinstance(prop, hyperdb.Multilink)):
-                    self.filterspec[name] = handleListCGIValue(self.form[name])
-                else:
-                    self.filterspec[name] = self.form[name].value
+        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
@@ -950,9 +952,17 @@ env: %(env)s
         if columns and self.columns:
             l.append(s%(':columns', ','.join(self.columns.keys())))
         if sort and self.sort is not None:
-            l.append(s%(':sort', self.sort))
+            if self.sort[0] == '-':
+                val = '-'+self.sort[1]
+            else:
+                val = self.sort[1]
+            l.append(s%(':sort', val))
         if group and self.group is not None:
-            l.append(s%(':group', self.group))
+            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)))
         if filterspec:
@@ -965,9 +975,17 @@ env: %(env)s
         if self.columns:
             l.append(':columns=%s'%(','.join(self.columns.keys())))
         if self.sort is not None:
-            l.append(':sort=%s'%self.sort)
+            if self.sort[0] == '-':
+                val = '-'+self.sort[1]
+            else:
+                val = self.sort[1]
+            l.append(':sort=%s'%val)
         if self.group is not None:
-            l.append(':group=%s'%self.group)
+            if self.group[0] == '-':
+                val = '-'+self.group[1]
+            else:
+                val = self.group[1]
+            l.append(':group=%s'%val)
         if self.filter:
             l.append(':filter=%s'%(','.join(self.filter)))
         for k,v in self.filterspec.items():
index 13b316fbb6377ae919e563dad4fb1a398bfeae06..fc47be7ef458aa6728cec87bf018f38e7e90cfa2 100644 (file)
@@ -11,7 +11,8 @@
    <th tal:condition="exists:request/columns/assignedto">Assigned&nbsp;To</th>
   </tr>
  <tal:block tal:repeat="i batch">
-  <tr tal:condition="python:batch.propchanged(request.group[1])">
+  <tr tal:condition="python:request.group[1] and
+                            batch.propchanged(request.group[1])">
    <th tal:attributes="colspan python:len(request.columns)"
        tal:content="python:i[request.group[1]]" class="group">
    </th>
  <tr>
   <td style="padding: 0" tal:attributes="colspan python:len(request.columns)">
    <table class="list">
-    <tr><th style="text-align: left">
+    <tr><th style="text-align: left; border: 0">
      <a tal:define="prev batch/previous" tal:condition="prev"
         tal:attributes="href python:request.indexargs_href(request.classname,
         {':startwith':prev.start, ':pagesize':prev.size})">&lt;&lt; previous</a>
      &nbsp;
     </th>
-    <th style="text-align: right">
+    <th style="text-align: right; border: 0">
      <a tal:define="next batch/next" tal:condition="next"
         tal:attributes="href python:request.indexargs_href(request.classname,
          {':startwith':next.start, ':pagesize':next.size})">next &gt;&gt;</a>
index c6d6f98fb5e45786ea7a2cdaa4e8f1196c296c74..5194258473e212bd71e905a4d9c43f1b7e26b4b5 100644 (file)
 <tr>
  <td rowspan="2" valign="top" nowrap class="sidebar">
   <p class="classblock"
-       tal:condition="python:request.user.hasPermission('Edit', 'issue')">
+       tal:condition="python:request.user.hasPermission('View', 'issue')">
    <b>Issues</b><br>
+   <a tal:condition="python:request.user.hasPermission('Edit', 'issue')"
+      href="issue?:template=item">New Issue<br></a>
    <a href="issue?:sort=-activity&:group=priority&:filter=status,assignedto&:columns=id,activity,title,creator,priority&status=-1,1,2,3,4,5,6,7&assignedto=-1">Unassigned Issues</a><br>
    <a href="issue?:sort=-activity&:group=priority&:filter=status&:columns=id,activity,title,creator,assignedto,priority&status=-1,1,2,3,4,5,6,7">All Issues</a><br>
-   <a href="issue?:template=search">Search Issues</a><br>
-   <a href="issue?:template=item">New Issue</a>
+   <a href="issue?:template=search">Search Issues</a>
   </p>
 
   <p class="classblock"
@@ -39,7 +40,7 @@
   </p>
 
   <p class="userblock">
-   <b>Logged in as</b><br><b tal:content="request/user/username">username</b><br>
+   <b>Hello,</b><br><b tal:content="request/user/username">username</b><br>
    <form method="POST" action=''
          tal:condition="python:request.user.username=='anonymous'">
     <input size="10" name="__login_name"><br>
index c8bccc06b59eca6babcd7628b3682f80f94c201a..a3004278cdaf120b106fd657d6e2ae5b6b8e307f 100644 (file)
@@ -8,32 +8,34 @@ a:link { text-decoration: none; }
 a { text-decoration: none; }
 
 .page-header-left {
-  background-color: #ffffee;
+  background-color: #cccc88;
   padding: 5px;
 }
 
 .page-header-top {
-  background-color: #ffffee;
-  border-bottom: 1px solid #ffffbb;
+  background-color: #cccc88;
+  border-bottom: 1px solid #dddd99;
   padding: 5px;
 }
 
 td.sidebar {
-  background-color: #ffffee;
-  border-right: 1px solid #ffffbb;
-  border-bottom: 1px solid #ffffbb;
-  padding: 5px;
+  background-color: #cccc88;
+  border-right: 1px solid #dddd99;
+  border-bottom: 1px solid #dddd99;
+  padding: 0px;
 }
 
 td.sidebar p.classblock {
-  border-top: 1px solid #ffffbb;
-  border-bottom: 1px solid #ffffbb;
+  padding: 0 5 0 5;
+  border-top: 1px solid #dddd99;
+  border-bottom: 1px solid #dddd99;
 }
 
 td.sidebar p.userblock {
-  background-color: #eeffff;
-  border-top: 1px solid #bbffff;
-  border-bottom: 1px solid #bbffff;
+  padding: 0 5 0 5;
+  background-color: #dddd99;
+  border-top: 1px solid #ffffbb;
+  border-bottom: 1px solid #ffffbb;
 }
 
 td.content {
@@ -58,7 +60,6 @@ p.error-message {
 table.form {
   border-spacing: 0px;
   border-collapse: separate;
-  /* width: 100%; */
 }
 
 .form th {
@@ -99,14 +100,12 @@ table.list th {
   padding: 0 4 0 4;
   color: #404070;
   background-color: #eeeeff;
-/*
   border-right: 1px solid #404070;
-*/
   vertical-align: top;
 }
-table.list th a:hover { color: white }
-table.list th a:link { color: white }
-table.list th a { color: white }
+table.list th a:hover { color: #404070 }
+table.list th a:link { color: #404070 }
+table.list th a { color: #404070 }
 table.list th.group {
   text-align: center;
 }
@@ -131,12 +130,12 @@ table.list td:first-child {
   border-left: 1px solid #404070;
   border-right: 1px solid #404070;
 }
-/*
+
 table.list th:first-child {
   border-left: 1px solid #404070;
   border-right: 1px solid #404070;
 }
-*/
+
 
 /* style for message displays */
 table.messages {
index f44c23392cce2763a32e48be9096df3ee7196771..28fff4485af5f281ff1fc3f4379ea72e2a46330a 100644 (file)
@@ -25,7 +25,11 @@ You are not allowed to view this page.
  </tr>
  <tr tal:condition="python:request.user.hasPermission('Web Roles')">
   <th>Roles</th>
-  <td tal:content="structure user/roles/field">roles</td>
+  <td tal:condition="exists:item"
+      tal:content="structure user/roles/field">roles</td>
+  <td tal:condition="not:exists:item">
+   <input name="roles" tal:attributes="value db/config/NEW_WEB_USER_ROLES">
+  </td>
  </tr>
  <tr>
   <th>Phone</th>
@@ -51,19 +55,22 @@ You are not allowed to view this page.
 </table>
 </form>
 
-<table class="otherinfo" tal:condition="user/queries">
- <tr><th class="header">Queries</th></tr>
- <tr tal:repeat="query user/queries">
-  <td tal:content="query">query</td>
- </tr>
-</table>
+<tal:block tal:condition="exists:item">
+ <table class="otherinfo" tal:condition="user/queries">
+  <tr><th class="header">Queries</th></tr>
+  <tr tal:repeat="query user/queries">
+   <td tal:content="query">query</td>
+  </tr>
+ </table>
+
+ <table class="otherinfo">
+  <tr><th class="header">History</th></tr>
+  <tr>
+   <td tal:content="structure user/history">history</td>
+  </tr>
+ </table>
+</tal:block>
 
-<table class="otherinfo">
- <tr><th class="header">History</th></tr>
- <tr>
-  <td tal:content="structure user/history">history</td>
- </tr>
-</table>
 </tal:block>
 
 <table class="form" tal:condition="python:viewok and not editok">