[Erp5-report] r34434 ivan - in /erp5/trunk/products/ERP5Security: ./ tests/ www/
nobody at svn.erp5.org
nobody at svn.erp5.org
Fri Apr 9 16:31:01 CEST 2010
Author: ivan
Date: Fri Apr 9 16:31:00 2010
New Revision: 34434
URL: http://svn.erp5.org?rev=34434&view=rev
Log:
Add key authentication plugin (preliminary work of FX) and extend tests to basic cover it. This is a work in progress and needs improvements (see XXX) and better test coverage.
Added:
erp5/trunk/products/ERP5Security/ERP5KeyAuthPlugin.py
erp5/trunk/products/ERP5Security/www/ERP5Security_addERP5KeyAuthPlugin.zpt
erp5/trunk/products/ERP5Security/www/ERP5Security_editERP5KeyAuthPlugin.zpt
Modified:
erp5/trunk/products/ERP5Security/__init__.py
erp5/trunk/products/ERP5Security/tests/testERP5Security.py
Added: erp5/trunk/products/ERP5Security/ERP5KeyAuthPlugin.py
URL: http://svn.erp5.org/erp5/trunk/products/ERP5Security/ERP5KeyAuthPlugin.py?rev=34434&view=auto
==============================================================================
--- erp5/trunk/products/ERP5Security/ERP5KeyAuthPlugin.py (added)
+++ erp5/trunk/products/ERP5Security/ERP5KeyAuthPlugin.py [utf8] Fri Apr 9 16:31:00 2010
@@ -1,0 +1,410 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# Copyright (c) 2002 Nexedi SARL and Contributors. All Rights Reserved.
+# Francois-Xavier Algrain <fxalgrain at tiolive.com>
+#
+# WARNING: This program as such is intended to be used by professional
+# programmers who take the whole responsability of assessing all potential
+# consequences resulting from its eventual inadequacies and bugs
+# End users who are looking for a ready-to-use solution with commercial
+# garantees and support are strongly adviced to contract a Free Software
+# Service Company
+#
+# This program is Free Software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+#
+##############################################################################
+
+from base64 import encodestring, decodestring
+from urllib import quote, unquote
+from DateTime import DateTime
+from zLOG import LOG, PROBLEM
+from Globals import InitializeClass
+try:
+ from zope.interface import Interface
+except ImportError:
+ from Products.PluggableAuthService.utils import Interface
+
+from AccessControl import ClassSecurityInfo
+from AccessControl.SecurityManagement import getSecurityManager,\
+ newSecurityManager,\
+ setSecurityManager
+
+from Products.PageTemplates.PageTemplateFile import PageTemplateFile
+
+from Products.PluggableAuthService.interfaces import plugins
+from Products.PluggableAuthService.utils import classImplements
+from Products.PluggableAuthService.permissions import ManageUsers
+from Products.PluggableAuthService.plugins.BasePlugin import BasePlugin
+from Products.PluggableAuthService.plugins.CookieAuthHelper import CookieAuthHelper
+
+from Products.ERP5Type.Cache import CachingMethod
+from Products.ERP5Security.ERP5UserManager import ERP5UserManager,\
+ SUPER_USER,\
+ _AuthenticationFailure
+
+class ILoginEncryptionPlugin(Interface):
+ """Contract for possible ERP5 Key Auth Plugin"""
+
+ def encrypt(self,login):
+ """Encrypt the login"""
+
+ def decrypt(self,crypted_login):
+ """Decrypt string and return the login"""
+
+
+#Form for new plugin in ZMI
+manage_addERP5KeyAuthPluginForm = PageTemplateFile(
+ 'www/ERP5Security_addERP5KeyAuthPlugin', globals(),
+ __name__='manage_addERP5KeyAuthPluginForm')
+
+def addERP5KeyAuthPlugin(dispatcher, id,title=None,\
+ encryption_key='',cookie_name='',\
+ default_cookie_name='',REQUEST=None):
+ """ Add a ERP5KeyAuthPlugin to a Pluggable Auth Service. """
+
+ plugin = ERP5KeyAuthPlugin( id, title,encryption_key,cookie_name,default_cookie_name)
+ dispatcher._setObject(plugin.getId(), plugin)
+
+ if REQUEST is not None:
+ REQUEST['RESPONSE'].redirect(
+ '%s/manage_workspace'
+ '?manage_tabs_message='
+ 'ERP5KeyAuthPlugin+added.'
+ % dispatcher.absolute_url())
+
+class ERP5KeyAuthPlugin(ERP5UserManager, CookieAuthHelper):
+ """
+ Key authentification PAS plugin which support key authentication in URL.
+
+ <ERP5_Root>/web_page_module/1/view?__ac_key=207221200213146153166
+
+ where value of __ac_key contains (encrypted):
+ - proxied (i.e. granting user) username
+ - PAS plugin encryption key
+
+ XXX: improve encrypt & decrypt part to use PAS encryption_key with a true
+ python encryption library (reuse of public / private key architecture)!
+
+ """
+
+ meta_type = "ERP5 Key Authentication"
+ login_path = 'login_form'
+ security = ClassSecurityInfo()
+ block_length = 3
+ cookie_name = "__ac_key"
+ default_cookie_name = "__ac"
+ encryption_key = ''
+ encrypted_key = ''
+
+ manage_options = ( ( { 'label': 'Edit',
+ 'action': 'manage_editERP5KeyAuthPluginForm', }
+ ,
+ )
+ + BasePlugin.manage_options[:]
+ #+ CookieAuthHelper.manage_options[:] //don't need folder option today
+ )
+
+ _properties = ( ( { 'id':'default_cookie_name',
+ 'type':'string',
+ 'mode':'w',
+ 'label':'Default Cookie Name'
+ },
+ )
+ + BasePlugin._properties[:]
+ + CookieAuthHelper._properties[:]
+ )
+
+ def __init__(self, id, title=None, encryption_key='', cookie_name='', default_cookie_name=''):
+ #Check parameters
+ if cookie_name is None or cookie_name == '':
+ cookie_name = id
+ if encryption_key is None or encryption_key == '':
+ encryption_key = id
+ if "__ac_key" in [cookie_name, default_cookie_name]:
+ raise ValueError, "Cookie name must be different of __ac_key"
+
+ #Register value
+ self._setId(id)
+ self.title = title
+ self.cookie_name = cookie_name
+ self.default_cookie_name = default_cookie_name
+ self.encryption_key = encryption_key
+ self.encrypted_key = self.transformKey(self.encryption_key);
+
+
+ ################################
+ # ILoginEncryptionPlugin #
+ ################################
+ security.declarePrivate('transformKey')
+ def transformKey(self,key):
+ """Transform the key to number for encryption"""
+ encrypt_key = []
+ for letter in key:
+ encrypt_key.append(ord(letter))
+ return encrypt_key
+
+ security.declarePublic('encrypt')
+ def encrypt(self,login):
+ """Encrypt the login"""
+ crypted_login = ''
+ key_length = len(self.encrypted_key)
+ for i in range(0,len(login)):
+ delta = i % key_length
+ crypted_letter = str(ord(login[i]) + self.encrypted_key[delta])
+ #ord is the inverse of chr() for 8-bit (1111 1111 = 256)
+ #so crypted_letter max id 512
+ #we ajust lenght to be able to decrypt by block
+ crypted_letter = crypted_letter.rjust(self.block_length, '0')
+ crypted_login += crypted_letter
+
+ return crypted_login
+
+ security.declarePrivate('decrypt')
+ def decrypt(self, crypted_login):
+ """Decrypt string and return the login"""
+ login = ''
+ #check lenght of the string
+ clogin_length = len(crypted_login)
+ if clogin_length % self.block_length != 0:
+ raise ValueError, "Lenght is not good"
+
+ #decrypt block per block
+ position = 0
+ key_length = len(self.encrypted_key)
+ for block in range(0, clogin_length, self.block_length):
+ delta = position % key_length
+ crypted_letter = crypted_login[block:block + self.block_length]
+ crypted_letter = int(crypted_letter) - self.encrypted_key[delta]
+ letter = chr(crypted_letter)
+ login += letter
+ position += 1
+ return login
+
+ ####################################
+ #ILoginPasswordHostExtractionPlugin#
+ ####################################
+ security.declarePrivate('extractCredentials')
+ def extractCredentials(self, request):
+ """ Extract credentials from cookie or 'request'. """
+ try:
+ creds = {}
+ #Search __ac_key
+ key = request.get('__ac_key', None)
+ if key is not None:
+ creds['key'] = key
+ #Save this in cookie
+ self.updateCredentials(request,request["RESPONSE"],None,None)
+ else:
+ # Look in the request for the names coming from the login form
+ #It's default method
+ login_pw = request._authUserPW()
+
+ if login_pw is not None:
+ name, password = login_pw
+ creds[ 'login' ] = name
+ creds[ 'password' ] = password
+ #Save this in cookie
+ self.updateCredentials(request,request["RESPONSE"],name,password)
+
+ else:
+ #search in cookies
+ cookie = request.get(self.cookie_name, None)
+ if cookie is not None:
+ #Cookie is found
+ cookie_val = unquote(cookie)
+ creds['key'] = cookie_val
+ else:
+ #Default cookie if needed
+ default_cookie = request.get(self.default_cookie_name, None)
+ if default_cookie is not None:
+ #Cookie is found
+ cookie_val = decodestring(unquote(default_cookie))
+ if cookie_val is not None:
+ login, password = cookie_val.split(':')
+ creds['login'] = login
+ creds['password'] = password
+
+ #Complete credential with some informations
+ if creds:
+ creds['remote_host'] = request.get('REMOTE_HOST', '')
+ try:
+ creds['remote_address'] = request.getClientAddr()
+ except AttributeError:
+ creds['remote_address'] = request.get('REMOTE_ADDR', '')
+ except StandardError,e:
+ #Log standard error to check error
+ LOG('ERP5KeyAuthPlugin.extractCredentials', PROBLEM, str(e))
+
+ return creds
+
+ ################################
+ # ICredentialsUpdatePlugin #
+ ################################
+ security.declarePrivate('updateCredentials')
+ def updateCredentials(self, request, response, login, new_password):
+ """ Respond to change of credentials"""
+
+ #Update credential for key auth or standard of.
+ #Remove conflict between both systems
+ cookie_val = request.get("__ac_key",None)
+ if cookie_val is not None:
+ #expires = (DateTime() + 365).toZone('GMT').rfc822()
+ cookie_val = cookie_val.rstrip()
+ response.setCookie(self.cookie_name, quote(cookie_val), path='/')#,expires=expires)
+ response.expireCookie(self.default_cookie_name, path='/')
+ elif login is not None and new_password is not None:
+ cookie_val = encodestring('%s:%s' % (login, new_password))
+ cookie_val = cookie_val.rstrip()
+ response.setCookie(self.default_cookie_name, quote(cookie_val), path='/')
+ response.expireCookie(self.cookie_name, path='/')
+
+
+ ################################
+ # ICredentialsResetPlugin #
+ ################################
+ security.declarePrivate('resetCredentials')
+ def resetCredentials(self, request, response):
+ """Expire cookies of authentification """
+ response.expireCookie(self.cookie_name, path='/')
+ response.expireCookie(self.default_cookie_name, path='/')
+
+
+ ################################
+ # IAuthenticationPlugin #
+ ################################
+ security.declarePrivate('authenticateCredentials')
+ def authenticateCredentials( self, credentials ):
+ """Authentificate with credentials"""
+ key = credentials.get('key', None)
+ if key != None:
+ login = self.decrypt(key)
+ # Forbidden the usage of the super user.
+ if login == SUPER_USER:
+ return None
+
+ #Function to allow cache
+ def _authenticateCredentials(login):
+ if not login:
+ return None
+
+ #Search the user by his login
+ user_list = self.getUserByLogin(login)
+ if len(user_list) != 1:
+ raise _AuthenticationFailure()
+ user = user_list[0]
+
+ #We need to be super_user
+ sm = getSecurityManager()
+ if sm.getUser().getId() != SUPER_USER:
+ newSecurityManager(self, self.getUser(SUPER_USER))
+ try:
+ portal = self.getPortalObject()
+ # get assignment list
+ assignment_list = [x for x in user.contentValues(portal_type="Assignment") \
+ if x.getValidationState() == "open"]
+ valid_assignment_list = []
+ # check dates if exist
+ login_date = DateTime()
+ for assignment in assignment_list:
+ if assignment.getStartDate() is not None and \
+ assignment.getStartDate() > login_date:
+ continue
+ if assignment.getStopDate() is not None and \
+ assignment.getStopDate() < login_date:
+ continue
+ valid_assignment_list.append(assignment)
+
+ # validate
+ if len(valid_assignment_list) > 0:
+ return (login,login)
+ finally:
+ setSecurityManager(sm)
+
+ raise _AuthenticationFailure()
+
+ #Cache Method for best performance
+ _authenticateCredentials = CachingMethod(_authenticateCredentials,
+ id='ERP5KeyAuthPlugin_authenticateCredentials',
+ cache_factory='erp5_content_short')
+ try:
+ return _authenticateCredentials(
+ login=login)
+ except _AuthenticationFailure:
+ return None
+ except StandardError,e:
+ #Log standard error
+ LOG('ERP5KeyAuthPlugin.authenticateCredentials', PROBLEM, str(e))
+ return None
+
+ ################################
+ # Properties for ZMI managment #
+ ################################
+
+ #'Edit' option form
+ manage_editERP5KeyAuthPluginForm = PageTemplateFile(
+ 'www/ERP5Security_editERP5KeyAuthPlugin',
+ globals(),
+ __name__='manage_editERP5KeyAuthPluginForm' )
+
+ security.declareProtected( ManageUsers, 'manage_editKeyAuthPlugin' )
+ def manage_editKeyAuthPlugin(self, encryption_key,cookie_name,default_cookie_name, RESPONSE=None):
+ """Edit the object"""
+ error_message = ''
+
+ #Test paramaeters
+ if "__ac_key" in [cookie_name, default_cookie_name]:
+ raise ValueError, "Cookie name must be different of __ac_key"
+
+ #Save key
+ if encryption_key == '' or encryption_key is None:
+ error_message += 'Invalid key value '
+ else:
+ self.encryption_key = encryption_key
+ self.encrypted_key = self.transformKey(self.encryption_key);
+
+ #Save cookie name
+ if cookie_name == '' or cookie_name is None:
+ error_message += 'Invalid cookie name '
+ else:
+ self.cookie_name = cookie_name
+
+ #Save default_cookie_name
+ if default_cookie_name == '' or default_cookie_name is None:
+ error_message += 'Invalid default cookie name '
+ else:
+ self.default_cookie_name = default_cookie_name
+
+ #Redirect
+ if RESPONSE is not None:
+ if error_message != '':
+ self.REQUEST.form['manage_tabs_message'] = error_message
+ return self.manage_editERP5KeyAuthPluginForm(RESPONSE)
+ else:
+ message = "Updated"
+ RESPONSE.redirect( '%s/manage_editERP5KeyAuthPluginForm'
+ '?manage_tabs_message=%s'
+ % ( self.absolute_url(), message )
+ )
+
+#List implementation of class
+classImplements(ERP5KeyAuthPlugin,
+ ILoginEncryptionPlugin,
+ plugins.IAuthenticationPlugin,
+ plugins.ILoginPasswordHostExtractionPlugin,
+ plugins.ICredentialsResetPlugin,
+ plugins.ICredentialsUpdatePlugin)
+
+InitializeClass(ERP5KeyAuthPlugin)
Modified: erp5/trunk/products/ERP5Security/__init__.py
URL: http://svn.erp5.org/erp5/trunk/products/ERP5Security/__init__.py?rev=34434&r1=34433&r2=34434&view=diff
==============================================================================
--- erp5/trunk/products/ERP5Security/__init__.py [utf8] (original)
+++ erp5/trunk/products/ERP5Security/__init__.py [utf8] Fri Apr 9 16:31:00 2010
@@ -25,6 +25,7 @@
import ERP5GroupManager
import ERP5RoleManager
import ERP5UserFactory
+import ERP5KeyAuthPlugin
def mergedLocalRoles(object):
"""Returns a merging of object and its ancestors'
@@ -58,6 +59,7 @@
registerMultiPlugin(ERP5GroupManager.ERP5GroupManager.meta_type)
registerMultiPlugin(ERP5RoleManager.ERP5RoleManager.meta_type)
registerMultiPlugin(ERP5UserFactory.ERP5UserFactory.meta_type)
+registerMultiPlugin(ERP5KeyAuthPlugin.ERP5KeyAuthPlugin.meta_type)
def initialize(context):
@@ -97,3 +99,12 @@
, icon='www/portal.gif'
)
+ context.registerClass( ERP5KeyAuthPlugin.ERP5KeyAuthPlugin
+ , permission=ManageUsers
+ , constructors=(
+ ERP5KeyAuthPlugin.manage_addERP5KeyAuthPluginForm,
+ ERP5KeyAuthPlugin.addERP5KeyAuthPlugin, )
+ , visibility=None
+ , icon='www/portal.gif'
+ )
+
Modified: erp5/trunk/products/ERP5Security/tests/testERP5Security.py
URL: http://svn.erp5.org/erp5/trunk/products/ERP5Security/tests/testERP5Security.py?rev=34434&r1=34433&r2=34434&view=diff
==============================================================================
--- erp5/trunk/products/ERP5Security/tests/testERP5Security.py [utf8] (original)
+++ erp5/trunk/products/ERP5Security/tests/testERP5Security.py [utf8] Fri Apr 9 16:31:00 2010
@@ -619,6 +619,56 @@
basic='guest:guest')
self.assertEqual(response.getStatus(), 401)
+ def testKeyAuthentication(self):
+ """
+ Make sure that we can grant security using a key.
+ """
+ # add key authentication PAS plugin
+ portal = self.portal
+ uf = portal.acl_users
+ uf.manage_addProduct['ERP5Security'].addERP5KeyAuthPlugin(
+ id="erp5_auth_key", \
+ title="ERP5 Auth key",\
+ encryption_key='fdgfhkfjhltylutyu',
+ cookie_name='__key',\
+ default_cookie_name='__ac')
+
+ erp5_auth_key_plugin = getattr(uf, "erp5_auth_key")
+ erp5_auth_key_plugin.manage_activateInterfaces(
+ interfaces=['IExtractionPlugin',
+ 'IAuthenticationPlugin',
+ 'ICredentialsUpdatePlugin',
+ 'ICredentialsResetPlugin'])
+ self.stepTic()
+
+ reference = 'UserReferenceTextWhichShouldBeHardToGeneratedInAnyHumanOrComputerLanguage'
+ loginable_person = self.getPersonModule().newContent(portal_type='Person',
+ reference=reference,
+ password='guest')
+ assignment = loginable_person.newContent(portal_type='Assignment',
+ function='another_subcat')
+ assignment.open()
+ self.stepTic()
+
+ # encrypt & decrypt works
+ key = erp5_auth_key_plugin.encrypt(reference)
+ self.assertNotEquals(reference, key)
+ self.assertEquals(reference, erp5_auth_key_plugin.decrypt(key))
+ base_url = '%s/view' %portal.absolute_url(relative=1)
+
+ # without key we are Anonymous User so we should be redirected with proper HTML
+ # status code to login_form
+ response = self.publish(base_url)
+ self.assertEqual(response.getStatus(), 302)
+ self.assertTrue('location' in response.headers.keys())
+ self.assertTrue(response.headers['location'].endswith('login_form'))
+
+ # view front page we should be logged in if we use authentication key
+ response = self.publish('%s?__ac_key=%s' %(base_url, key))
+ self.assertEqual(response.getStatus(), 200)
+ self.assertTrue(reference in response.getBody())
+
+
def test_suite():
suite = unittest.TestSuite()
suite.addTest(unittest.makeSuite(TestUserManagement))
Added: erp5/trunk/products/ERP5Security/www/ERP5Security_addERP5KeyAuthPlugin.zpt
URL: http://svn.erp5.org/erp5/trunk/products/ERP5Security/www/ERP5Security_addERP5KeyAuthPlugin.zpt?rev=34434&view=auto
==============================================================================
--- erp5/trunk/products/ERP5Security/www/ERP5Security_addERP5KeyAuthPlugin.zpt (added)
+++ erp5/trunk/products/ERP5Security/www/ERP5Security_addERP5KeyAuthPlugin.zpt [utf8] Fri Apr 9 16:31:00 2010
@@ -1,0 +1,68 @@
+<h1 tal:replace="structure context/manage_page_header">PAGE HEADER</h1>
+<h2 tal:define="form_title string:Add ERP5 Key Authentication PAS"
+ tal:replace="structure context/manage_form_title">FORM TITLE</h2>
+
+<p class="form-help">Please input the configuration</p>
+
+<form action="addERP5KeyAuthPlugin" method="POST">
+<table cellspacing="0" cellpadding="2" border="0">
+ <tr>
+ <td align="left" valign="top">
+ <div class="form-label">
+ Id
+ </div>
+ </td>
+ <td align="left" valign="top">
+ <input type="text" name="id" size="40" />
+ </td>
+ </tr>
+ <tr>
+ <td align="left" valign="top">
+ <div class="form-label">
+ Title
+ </div>
+ </td>
+ <td align="left" valign="top">
+ <input type="text" name="title" size="40" />
+ </td>
+ </tr>
+ <tr>
+ <td align="left" valign="top">
+ <div class="form-label">
+ Encryption Key
+ </div>
+ </td>
+ <td align="left" valign="top">
+ <input type="text" name="encryption_key" size="40" />
+ </td>
+ </tr>
+ <tr>
+ <td align="left" valign="top">
+ <div class="form-label">
+ Cookie Name
+ </div>
+ </td>
+ <td align="left" valign="top">
+ <input type="text" name="cookie_name"
+ value="__key" size="40" />
+ </td>
+ </tr>
+ <tr>
+ <td align="left" valign="top">
+ <div class="form-label">
+ Default Cookie Name
+ </div>
+ </td>
+ <td align="left" valign="top">
+ <input type="text" name="default_cookie_name"
+ value="__ac" size="40" />
+ </td>
+ </tr>
+ <tr>
+ <td colspan="2"> <input type="submit" value="add plugin"/>
+ </td>
+ </tr>
+</table>
+</form>
+
+<h1 tal:replace="structure context/manage_page_footer">PAGE FOOTER</h1>
Added: erp5/trunk/products/ERP5Security/www/ERP5Security_editERP5KeyAuthPlugin.zpt
URL: http://svn.erp5.org/erp5/trunk/products/ERP5Security/www/ERP5Security_editERP5KeyAuthPlugin.zpt?rev=34434&view=auto
==============================================================================
--- erp5/trunk/products/ERP5Security/www/ERP5Security_editERP5KeyAuthPlugin.zpt (added)
+++ erp5/trunk/products/ERP5Security/www/ERP5Security_editERP5KeyAuthPlugin.zpt [utf8] Fri Apr 9 16:31:00 2010
@@ -1,0 +1,45 @@
+<h1 tal:replace="structure context/manage_page_header">PAGE HEADER</h1>
+<h2 tal:replace="structure here/manage_tabs"> TABS </h2>
+<h2 tal:define="form_title string:Edit ERP5 Key Authentification Plugin"
+ tal:replace="structure context/manage_form_title">FORM TITLE</h2>
+
+<p class="form-help">Please input the configuration for the radius host</p>
+
+<form action="manage_editKeyAuthPlugin" method="POST">
+
+<table tal:define="encryption_key request/encryption_key|context/encryption_key|string:;
+ default_cookie_name request/default_cookie_name|context/default_cookie_name|string:;
+ cookie_name request/cookie_name|context/cookie_name|string:;">
+
+<tr>
+ <td> Encryption Key </td>
+ <td>
+ <input type="text" name="encryption_key" value=""
+ tal:attributes="value encryption_key;" />
+ </td>
+</tr>
+<tr>
+ <td> Cookie Name </td>
+ <td>
+ <input type="text" name="cookie_name" value=""
+ tal:attributes="value cookie_name;" />
+ </td>
+</tr>
+<tr>
+ <td> Default Cookie Name </td>
+ <td>
+ <input type="text" name="default_cookie_name" value=""
+ tal:attributes="value default_cookie_name;" />
+ </td>
+</tr>
+<tr>
+ <td colspan="2">
+ <input type="submit" value="save"/>
+ </td>
+</tr>
+
+</table>
+
+</form>
+
+<h1 tal:replace="structure context/manage_page_footer">PAGE FOOTER</h1>
More information about the Erp5-report
mailing list