Index: /waeup/trunk/src/waeup/browser.txt
===================================================================
--- /waeup/trunk/src/waeup/browser.txt	(revision 4857)
+++ /waeup/trunk/src/waeup/browser.txt	(revision 4858)
@@ -797,8 +797,8 @@
   >>> browser.getControl(name='SUBMIT').click()
 
-The file was indeed uploaded:
+The file was indeed uploaded, with the current userid inserted:
 
   >>> os.listdir(uploadpath)
-  ['myfaculties.csv']
+  ['myfaculties_zope.mgr.csv']
 
 We create and upload also a CSV file containing departments:
@@ -880,9 +880,9 @@
   <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"...
   ...The following files are available for import:...
-  ...mycertcourses.csv...Certificate Course Importer...
-  ...mycertificates.csv...Certificate Importer...
-  ...mycourses.csv...Course Importer...
-  ...mydepartments.csv...Department Importer...
-  ...myfaculties.csv...Faculty Importer...
+  ...mycertcourses_zope.mgr.csv...Certificate Course Importer...
+  ...mycertificates_zope.mgr.csv...Certificate Importer...
+  ...mycourses_zope.mgr.csv...Course Importer...
+  ...mydepartments_zope.mgr.csv...Department Importer...
+  ...myfaculties_zope.mgr.csv...Faculty Importer...
   ...
 
@@ -898,5 +898,5 @@
   >>> print browser.contents
   <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"...
-  ...Successfully imported: myfaculties.csv...
+  ...Successfully imported: myfaculties_zope.mgr.csv...
   ...
 
@@ -919,5 +919,5 @@
   >>> print browser.contents
   <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"...
-  ...Successfully imported: mydepartments.csv...
+  ...Successfully imported: mydepartments_zope.mgr.csv...
   ...
 
@@ -930,5 +930,5 @@
   >>> print browser.contents
   <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"...
-  ...Successfully imported: mycourses.csv...
+  ...Successfully imported: mycourses_zope.mgr.csv...
   ...
 
@@ -941,5 +941,5 @@
   >>> print browser.contents
   <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"...
-  ...Successfully imported: mycertificates.csv...
+  ...Successfully imported: mycertificates_zope.mgr.csv...
   ...
 
@@ -952,5 +952,5 @@
   >>> print browser.contents
   <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"...
-  ...Successfully imported: mycertcourses.csv...
+  ...Successfully imported: mycertcourses_zope.mgr.csv...
   ... 
 
Index: /waeup/trunk/src/waeup/browser/batchprocessing.txt
===================================================================
--- /waeup/trunk/src/waeup/browser/batchprocessing.txt	(revision 4858)
+++ /waeup/trunk/src/waeup/browser/batchprocessing.txt	(revision 4858)
@@ -0,0 +1,392 @@
+Batch processing via browser
+****************************
+
+:test-layer: functional
+
+Create a site:
+
+    >>> from waeup.app import University
+    >>> getRootFolder()['app'] = University()
+
+Create a datacenter storage path:
+
+    >>> import os
+    >>> import tempfile
+    >>> dc_path = tempfile.mkdtemp()
+
+Log in:
+
+    >>> from zope.testbrowser.testing import Browser
+    >>> browser = Browser()
+    >>> browser.addHeader('Authorization', 'Basic mgr:mgrpw')
+    >>> browser.handleErrors = False
+
+Set datacenter path and deselect moving old data:
+
+    >>> browser.open('http://localhost/app')
+    >>> browser.getLink('Data Center').click()
+    >>> browser.getLink('Edit settings').click()
+    >>> browser.getControl(name='newpath').value = dc_path
+    >>> browser.getControl(name='move').value = False
+    >>> browser.getControl(name='save').click()
+
+Batch processing faculties
+==========================
+
+Go to datacenter page:
+
+    >>> browser.open('http://localhost/app/datacenter')
+
+Prepare a CSV file for faculties:
+
+    >>> open('faculties.csv', 'wb').write(
+    ... """code,review_state,title,title_prefix
+    ... FAC1,static,Faculty 1,faculty
+    ... FAC2,static,Faculty 2,institute
+    ... FAC3,static,Faculty 3,school
+    ... """)
+
+Upload the file:
+
+    >>> import cStringIO
+    >>> browser.getLink('Upload data').click()
+    >>> filecontents = cStringIO.StringIO(
+    ...   open('faculties.csv', 'rb').read())
+    >>> filewidget = browser.getControl(name='uploadfile:file')
+    >>> filewidget.add_file(filecontents, 'text/plain', 'faculties.csv')
+    >>> browser.getControl(name='SUBMIT').click()
+
+Step 1: start batch processing:
+
+    >>> browser.getLink('Batch processing').click()
+    >>> browser.getControl(name='select', index=0).click()
+
+Step 2: select a processor and mode:
+
+    >>> importerselect = browser.getControl(name='importer')
+    >>> importerselect.displayOptions
+    ['CertificateCourse importer', 
+     'Certificate importer', 
+     'Course importer', 
+     'Department importer', 
+     'Faculty importer']
+
+    >>> importerselect.getControl('Faculty importer').selected = True
+
+    >>> modeselect = browser.getControl(name='mode')
+    >>> modeselect.options
+    ['create', 'update', 'remove']
+
+    >>> modeselect.getControl(value='create').selected = True
+    >>> browser.getControl('Proceed to step 3...').click()
+
+Step 3: Fix headerlines
+
+We get informed that there are no problems with the current header:
+
+    >>> print browser.contents
+    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+    ...
+    Header fields OK
+    ...
+
+The submit button is enabled:
+
+    >>> browser.getControl('Perform import...').disabled
+    False
+
+    >>> browser.getControl('Perform import...').click()
+
+Step 4: See import results
+
+The import was successful:
+
+    >>> print browser.contents
+    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+    ...Successfully processed 3 rows...
+    ...Batch processing finished...
+    ...File:...faculties_zope.mgr.csv...
+
+We can watch the generated logfile:
+
+    >>> browser.getControl('View processing log').click()
+    >>> print browser.contents
+    <!DOCTYPE ...
+    <pre>Source: /.../faculties_zope.mgr.csv
+    Mode: create
+    Date: ...
+    User: zope.mgr
+    Failed datasets: /.../faculties_zope.mgr.csv.create.pending
+    Processing time: ... s (... s/item)
+    Processed: 3 lines (3 successful/ 0 failed)
+    ----------------------------------------------------------------------
+    </pre>
+    ...
+
+We can remove the logfile:
+
+    >>> browser.getControl('Back', index=0).click()
+    >>> browser.getControl('Delete', index=0).click()
+    >>> print browser.contents
+    <!DOCTYPE ...
+    ...File faculties_zope.mgr.csv.create.msg deleted...
+    ...
+
+
+Batch processing departments
+============================
+
+    >>> browser.open('http://localhost/app/datacenter')
+
+Prepare a CSV file for departments:
+
+    >>> open('departments.csv', 'wb').write(
+    ... """code,faculty_code,review_state,title,title_prefix
+    ... DEP1,FAC1,static,Department 1,department
+    ... DEP2,FAC2,static,Department 2,centre
+    ... """)
+
+Upload the file:
+
+    >>> import cStringIO
+    >>> browser.getLink('Upload data').click()
+    >>> filecontents = cStringIO.StringIO(
+    ...   open('departments.csv', 'rb').read())
+    >>> filewidget = browser.getControl(name='uploadfile:file')
+    >>> filewidget.add_file(filecontents, 'text/plain', 'departments.csv')
+    >>> browser.getControl(name='SUBMIT').click()
+
+Step 1: start batch processing:
+
+    >>> browser.getLink('Batch processing').click()
+    >>> browser.getControl(name='select', index=0).click()
+
+Step 2: select a processor and mode:
+
+    >>> importerselect = browser.getControl(name='importer')
+    >>> importerselect.getControl('Department importer').selected = True
+    >>> modeselect = browser.getControl(name='mode')
+    >>> modeselect.getControl(value='create').selected = True
+    >>> browser.getControl('Proceed to step 3...').click()
+
+Step 3: Fix headerlines
+
+We get informed that there are no problems with the current header:
+
+    >>> print browser.contents
+    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+    ...
+    Header fields OK
+    ...
+
+The submit button is enabled:
+
+    >>> browser.getControl('Perform import...').disabled
+    False
+
+    >>> browser.getControl('Perform import...').click()
+
+Step 4: See import results
+
+The import was successful:
+
+    >>> print browser.contents
+    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+    ...Successfully processed 2 rows...
+    ...Batch processing finished...
+    ...File:...departments_zope.mgr.csv...
+
+Batch processing courses
+========================
+
+    >>> browser.open('http://localhost/app/datacenter')
+
+Prepare a CSV file for courses:
+
+    >>> open('courses.csv', 'wb').write(
+    ... """code,faculty_code,department_code,title,level,passmark,credits,semester
+    ... CRS1,FAC1,DEP1,Course 1,100,40,2,1
+    ... CRS2,FAC1,DEP1,Course 2,100,40,2,2
+    ... """)
+
+Upload the file:
+
+    >>> import cStringIO
+    >>> browser.getLink('Upload data').click()
+    >>> filecontents = cStringIO.StringIO(
+    ...   open('courses.csv', 'rb').read())
+    >>> filewidget = browser.getControl(name='uploadfile:file')
+    >>> filewidget.add_file(filecontents, 'text/plain', 'courses.csv')
+    >>> browser.getControl(name='SUBMIT').click()
+
+Step 1: start batch processing:
+
+    >>> browser.getLink('Batch processing').click()
+    >>> browser.getControl(name='select', index=0).click()
+
+Step 2: select a processor and mode:
+
+    >>> importerselect = browser.getControl(name='importer')
+    >>> importerselect.getControl('Course importer', index=1).selected = True
+    >>> modeselect = browser.getControl(name='mode')
+    >>> modeselect.getControl(value='create').selected = True
+    >>> browser.getControl('Proceed to step 3...').click()
+
+Step 3: Fix headerlines
+
+We get informed that there are no problems with the current header:
+
+    >>> print browser.contents
+    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+    ...
+    Header fields OK
+    ...
+
+The submit button is enabled:
+
+    >>> browser.getControl('Perform import...').disabled
+    False
+
+    >>> browser.getControl('Perform import...').click()
+
+Step 4: See import results
+
+The import was successful:
+
+    >>> print browser.contents
+    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+    ...Successfully processed 2 rows...
+    ...Batch processing finished...
+    ...File:...courses_zope.mgr.csv...
+
+Batch processing certificates
+=============================
+
+    >>> browser.open('http://localhost/app/datacenter')
+
+Prepare a CSV file for certificates:
+
+    >>> open('certificates.csv', 'wb').write(
+    ... """code,faculty_code,department_code,title,category,study_mode,start_level,end_level,m_prefix,max_pass,application_category
+    ... CERT1,FAC1,DEP1,Certificate 1,UG,uf_ft,100,500,DCS,30,basic
+    ... CERT2,FAC1,DEP1,Certificate 2,UG,uf_ft,200,400,DCS,30,cest
+    ... """)
+
+Upload the file:
+
+    >>> import cStringIO
+    >>> browser.getLink('Upload data').click()
+    >>> filecontents = cStringIO.StringIO(
+    ...   open('certificates.csv', 'rb').read())
+    >>> filewidget = browser.getControl(name='uploadfile:file')
+    >>> filewidget.add_file(filecontents, 'text/plain', 'certificates.csv')
+    >>> browser.getControl(name='SUBMIT').click()
+
+Step 1: start batch processing:
+
+    >>> browser.getLink('Batch processing').click()
+    >>> browser.getControl(name='select', index=0).click()
+
+Step 2: select a processor and mode:
+
+    >>> importerselect = browser.getControl(name='importer')
+    >>> importerselect.getControl('Certificate importer').selected = True
+    >>> modeselect = browser.getControl(name='mode')
+    >>> modeselect.getControl(value='create').selected = True
+    >>> browser.getControl('Proceed to step 3...').click()
+
+Step 3: Fix headerlines
+
+We get informed that there are no problems with the current header:
+
+    >>> print browser.contents
+    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+    ...
+    Header fields OK
+    ...
+
+The submit button is enabled:
+
+    >>> browser.getControl('Perform import...').disabled
+    False
+
+    >>> browser.getControl('Perform import...').click()
+
+Step 4: See import results
+
+The import was successful:
+
+    >>> print browser.contents
+    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+    ...Successfully processed 2 rows...
+    ...Batch processing finished...
+    ...File:...certificates_zope.mgr.csv...
+
+Batch processing certificate courses
+====================================
+
+    >>> browser.open('http://localhost/app/datacenter')
+
+Prepare a CSV file for certificate courses:
+
+    >>> open('mycertcourses.csv', 'wb').write(
+    ... """course,faculty_code,department_code,certificate_code,level,core_or_elective
+    ... CRS1,FAC1,DEP1,CERT1,100,True
+    ... CRS2,FAC1,DEP1,CERT1,100,True
+    ... """)
+
+Upload the file:
+
+    >>> import cStringIO
+    >>> browser.getLink('Upload data').click()
+    >>> filecontents = cStringIO.StringIO(
+    ...   open('mycertcourses.csv', 'rb').read())
+    >>> filewidget = browser.getControl(name='uploadfile:file')
+    >>> filewidget.add_file(filecontents, 'text/plain', 'mycertcourses.csv')
+    >>> browser.getControl(name='SUBMIT').click()
+
+Step 1: start batch processing:
+
+    >>> browser.getLink('Batch processing').click()
+    >>> browser.getControl(name='select', index=0).click()
+
+Step 2: select a processor and mode:
+
+    >>> importerselect = browser.getControl(name='importer')
+    >>> importerselect.getControl('CertificateCourse importer').selected = True
+    >>> modeselect = browser.getControl(name='mode')
+    >>> modeselect.getControl(value='create').selected = True
+    >>> browser.getControl('Proceed to step 3...').click()
+
+Step 3: Fix headerlines
+
+We get informed that there are no problems with the current header:
+
+    >>> print browser.contents
+    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+    ...
+    Header fields OK
+    ...
+
+The submit button is enabled:
+
+    >>> browser.getControl('Perform import...').disabled
+    False
+
+    >>> browser.getControl('Perform import...').click()
+
+Step 4: See import results
+
+The import was successful:
+
+    >>> print browser.contents
+    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+    ...Successfully processed 2 rows...
+    ...Batch processing finished...
+    ...File:...mycertcourses_zope.mgr.csv...
+
+
+Clean up:
+
+    >>> import shutil
+    >>> shutil.rmtree(dc_path)
Index: /waeup/trunk/src/waeup/browser/pages.py
===================================================================
--- /waeup/trunk/src/waeup/browser/pages.py	(revision 4857)
+++ /waeup/trunk/src/waeup/browser/pages.py	(revision 4858)
@@ -3,6 +3,8 @@
 """
 import copy
+import csv
 import grok
 import os
+import re
 import sys
 from hurry import yui
@@ -28,4 +30,9 @@
 from zope.component.interfaces import Invalid
 from zope.exceptions import DuplicationError
+from zope.session.interfaces import ISession
+
+from waeup.interfaces import IBatchProcessor
+from zope.component import getAllUtilitiesRegisteredFor
+
 
 grok.context(IWAeUPObject)
@@ -245,4 +252,5 @@
     grok.context(IDataCenter)
     grok.name('index')
+    grok.require('waeup.manageUniversity')
     title = u'Data Center'
     pnav = 0
@@ -251,4 +259,5 @@
     grok.context(IDataCenter)
     grok.name('upload')
+    grok.require('waeup.manageUniversity')
     title = u'Data Center Upload'
     pnav = 0
@@ -262,5 +271,6 @@
         try:
             filename = uploadfile.filename
-            target = os.path.join(self.context.storage, filename)
+            target = os.path.join(self.context.storage,
+                                  self.getNormalizedFileName(filename))
             open(target, 'wb').write(uploadfile.read())
         except IOError:
@@ -270,4 +280,19 @@
         self.redirect(self.url(self.context))
 
+    def getNormalizedFileName(self, filename):
+        """Build sane filename.
+
+        An uploaded file foo.csv will be stored as foo_USERNAME.csv
+        where username is the principal id of the currently logged in
+        user.
+
+        Spaces in filename are replaced by underscore.
+        """
+        username = self.request.principal.id
+        filename = filename.replace(' ', '_')
+        # Only accept typical filname chars...
+        filtered_username = ''.join(re.findall('[a-zA-Z0-9_\.\-]', username))
+        base, ext = os.path.splitext(filename)
+        return '%s_%s%s' % (base, filtered_username, ext.lower())
 
 class DataCenterImportCSVPage(WAeUPPage):
@@ -310,4 +335,292 @@
         return
 
+class DatacenterImportStep1(WAeUPPage):
+    """Manual import step 1: choose file
+    """
+    grok.context(IDataCenter)
+    grok.name('import1')
+    grok.template('datacenterimport1page')
+    grok.require('waeup.manageUniversity')
+    title = u'Process CSV file'
+    pnav = 0
+
+    def getFiles(self):
+        files = self.context.getFiles(sort='date')
+        for file in files:
+            name = file.name
+            if not name.endswith('.csv') and not name.endswith('.pending'):
+                continue
+            yield file
+    
+    def update(self, filename=None, select=None, cancel=None):
+        if cancel is not None:
+            self.flash('Import aborted')
+            self.redirect(self.url(self.context))
+            return
+        if select is not None:
+            # A filename was selected
+            session = ISession(self.request)['waeup.sirp']
+            session['import_filename'] = select
+            self.redirect(self.url(self.context, '@@import2'))
+
+class DatacenterImportStep2(WAeUPPage):
+    """Manual import step 2: choose importer
+    """
+    grok.context(IDataCenter)
+    grok.name('import2')
+    grok.template('datacenterimport2page')
+    grok.require('waeup.manageUniversity')
+    title = u'Process CSV file'
+    pnav = 0
+
+    filename = None
+    mode = 'create'
+    importer = None
+
+    def getPreviewHeader(self):
+        """Get the header fields of attached CSV file.
+        """
+        reader = csv.reader(open(self.fullpath, 'rb'))
+        return reader.next()
+    
+    def getPreviewBody(self):
+        """Get the first 5 rows of attached CSV file.
+        """
+        result = []
+        num = 0
+        for row in self.reader:
+            if num > 4:
+                break
+            num += 1
+            row = row.items()
+            # Sort fields in headerfield order
+            row = sorted(row, key=lambda k: self.reader.fieldnames.index(k[0]))
+            row = [x[1] for x in row]
+            result.append(row)
+        result.append(len(result[0]) * ['...'])
+        return result
+
+    def getImporters(self):
+        importers = getAllUtilitiesRegisteredFor(IBatchProcessor)
+        importers = [
+            dict(title=x.name, name=x.util_name) for x in importers]
+        return importers
+        
+    
+    def update(self, mode=None, importer=None,
+               back1=None, cancel=None, proceed=None):
+        session = ISession(self.request)['waeup.sirp']
+        self.filename = session.get('import_filename', None)
+        
+        if self.filename is None or back1 is not None:
+            self.redirect(self.url(self.context, '@@import1'))
+            return
+        if cancel is not None:
+            self.flash('Import aborted')
+            self.redirect(self.url(self.context))
+            return
+        self.mode = mode or session.get('import_mode', self.mode)
+        self.importer = importer or session.get('import_importer', None)
+        session['import_mode'] = self.mode
+        session['import_importer'] = self.importer
+        if proceed is not None:
+            self.redirect(self.url(self.context, '@@import3'))
+            return
+        self.fullpath = os.path.join(self.context.storage, self.filename)
+        self.reader = csv.DictReader(open(self.fullpath, 'rb'))
+
+class DatacenterImportStep3(WAeUPPage):
+    """Manual import step 3: modify header
+    """
+    grok.context(IDataCenter)
+    grok.name('import3')
+    grok.template('datacenterimport3page')
+    grok.require('waeup.manageUniversity')
+    title = u'Process CSV file'
+    pnav = 0
+
+    filename = None
+    mode = None
+    importername = None
+    
+    @property
+    def nextstep(self):
+        return self.url(self.context, '@@import4')
+
+    def getPreviewHeader(self):
+        """Get the header fields of attached CSV file.
+        """
+        reader = csv.reader(open(self.fullpath, 'rb'))
+        return reader.next()
+    
+    def getPreviewBody(self):
+        """Get the first 5 rows of attached CSV file.
+        """
+        result = []
+        num = 0
+        for row in self.reader:
+            if num > 4:
+                break
+            num += 1
+            row = row.items()
+            # Sort fields in headerfield order
+            row = sorted(row, key=lambda k: self.reader.fieldnames.index(k[0]))
+            row = [x[1] for x in row]
+            result.append(row)
+        result.append(len(result[0]) * ['...'])
+        return result
+
+    def getPossibleHeaders(self):
+        """Get the possible headers.
+
+        The headers are described as dicts {value:internal_name,
+        title:displayed_name}
+        """
+        result = [dict(title='<IGNORE COL>', value='--IGNORE--')]
+        headers = self.importer.getHeaders()
+        result.extend([dict(title=x, value=x) for x in headers])
+        return result
+
+    def getWarnings(self):
+        import sys
+        result = []
+        try:
+            self.importer.checkHeaders(self.headerfields, mode=self.mode)
+        except:
+            fatal = '%s' % sys.exc_info()[1]
+            result.append(fatal)
+        return result
+    
+    @property
+    def nextstep(self):
+        return self.url(self.context, '@@import4')
+
+    def update(self, headerfield=None, back2=None, cancel=None, proceed=None):
+        session = ISession(self.request)['waeup.sirp']
+        self.filename = session.get('import_filename', None)
+        self.mode = session.get('import_mode', None)
+        self.importername = session.get('import_importer', None)
+        
+        if None in (self.filename, self.mode, self.importername):
+            self.redirect(self.url(self.context, '@@import2'))
+            return
+        if back2 is not None:
+            self.redirect(self.url(self.context ,'@@import2'))
+            return
+        if cancel is not None:
+            self.flash('Import aborted.')
+            self.redirect(self.url(self.context))
+            return
+
+        self.fullpath = os.path.join(self.context.storage, self.filename)
+        self.headerfields = headerfield or self.getPreviewHeader()
+        session['import_headerfields'] = self.headerfields
+
+        if proceed is not None:
+            self.redirect(self.url(self.context, '@@import4'))
+            return
+        
+        self.importer = getUtility(IBatchProcessor, name=self.importername)
+        self.reader = csv.DictReader(open(self.fullpath, 'rb'))
+
+class DatacenterImportStep4(WAeUPPage):
+    """Manual import step 4: do actual import
+    """
+    grok.context(IDataCenter)
+    grok.name('import4')
+    grok.template('datacenterimport4page')
+    grok.require('waeup.manageUniversity')
+    title = u'Process CSV file'
+    pnav = 0
+
+    filename = None
+    mode = None
+    importername = None
+    headerfields = None
+    warnnum = None
+
+    def update(self, back=None, finish=None, showlog=None):
+        if finish is not None:
+            self.redirect(self.url(self.context))
+            return
+        if back is not None:
+            self.redirect(self.url(self.context, '@@import3'))
+            return
+        session = ISession(self.request)['waeup.sirp']
+        self.filename = session.get('import_filename', None)
+        self.mode = session.get('import_mode', None)
+        self.importername = session.get('import_importer', None)
+        self.headerfields = session.get('import_headerfields', None)
+        
+        if None in (self.filename, self.mode, self.importername,
+                    self.headerfields):
+            self.redirect(self.url(self.context, '@@import3'))
+            return
+
+        if showlog is not None:
+            logfilename = "%s.%s.msg" % (self.filename, self.mode)
+            session['logname'] = logfilename
+            self.redirect(self.url(self.context, '@@show'))
+            return
+            
+        self.fullpath = os.path.join(self.context.storage, self.filename)
+        self.importer = getUtility(IBatchProcessor, name=self.importername)
+        (linenum, warnings) = self.importer.doImport(
+            self.fullpath, self.headerfields, self.mode,
+            self.request.principal.id)
+        self.warn_num = len(warnings)
+        if self.warn_num:
+            self.flash('Processing of %d rows failed!' % self.warn_num)
+        self.flash('Successfully processed %s rows' % (
+                linenum - (self.warn_num)))
+
+class DatacenterLogsOverview(WAeUPPage):
+    grok.context(IDataCenter)
+    grok.name('logs')
+    grok.template('datacenterlogspage')
+    grok.require('waeup.manageUniversity')
+    title = u'Data Center Logs'
+    pnav = 0
+
+    def update(self, show=None, remove=None, logname=None, back=None):
+        session = ISession(self.request)['waeup.sirp']
+        if back is not None:
+            self.redirect(self.url(self.context))
+            return
+        if logname is not None:
+            session['logname'] = logname
+            if remove is not None:
+                fullpath = os.path.join(self.context.storage, logname)
+                try:
+                    os.unlink(fullpath)
+                    self.flash('File %s deleted.' % logname)
+                except:
+                    self.flash("Could not delete %s: " % logname)
+                    self.flash("Problem: %s" % sys.exc_info()[1])
+
+        if show is not None:
+            self.redirect(self.url(self.context, '@@show'))
+            return
+        self.files = self.context.getLogFiles()
+
+class DatacenterLogsFileview(WAeUPPage):
+    grok.context(IDataCenter)
+    grok.name('show')
+    grok.template('datacenterlogsshowfilepage')
+    grok.require('waeup.manageUniversity')
+    title = u'Show file'
+    pnav = 0
+
+    def update(self, show=None, remove=None, back=None):
+        session = ISession(self.request)['waeup.sirp']
+        logname = session.get('logname', None)
+        if back is not None or logname is None:
+            self.redirect(self.url(self.context, '@@logs'))
+            return
+        self.filename = logname
+        self.files = self.context.getLogFiles()
+        fullpath = os.path.join(self.context.storage, logname)
+        self.filecontents = open(fullpath, 'rb').read()
+
 class DatacenterSettings(WAeUPPage):
     grok.context(IDataCenter)
@@ -317,5 +630,4 @@
     title = u'Data Center Settings'
     pnav = 0
-    #grok.template('master')
 
     def update(self, newpath=None, move=False, overwrite=False,
Index: /waeup/trunk/src/waeup/browser/static/purple.css
===================================================================
--- /waeup/trunk/src/waeup/browser/static/purple.css	(revision 4857)
+++ /waeup/trunk/src/waeup/browser/static/purple.css	(revision 4858)
@@ -14,4 +14,12 @@
 
 /***** Uli Stuff *****/
+
+/* Make YUI button icons appear vertically centered... */
+.yui-skin-sam .yui-button a img {
+  vertical-align: middle;
+  margin-right: 3px;
+  margin-top: -1px;
+  margin-left: -2px;
+}
 
 /* Fix logo image height: as image is taller than h1 text, header area
Index: /waeup/trunk/src/waeup/browser/templates/datacenterimport1page.pt
===================================================================
--- /waeup/trunk/src/waeup/browser/templates/datacenterimport1page.pt	(revision 4858)
+++ /waeup/trunk/src/waeup/browser/templates/datacenterimport1page.pt	(revision 4858)
@@ -0,0 +1,31 @@
+<h2>Process CSV file</h2>
+<h3>Step 1</h3>
+<p>
+  Using batch processing you can mass-create, mass-update, or
+  mass-remove datasets from the database using CSV files.
+</p>
+<p>
+  Please select a file for processing from the list below
+</p>
+<form method="POST">
+  <table>
+    <thead>
+      <th /><th>Filename</th><th>Datasets</th><th>Size</th><th>Date</th>
+    </thead>
+    <tbody>
+      <tr tal:repeat="file view/getFiles"
+	  tal:attributes="class python: repeat['file'].odd() and 'odd' or ''">
+	<td>
+	  <button type="submit" name="select"
+		  tal:attributes="value file/name"
+		  >Select</button>
+	</td>
+	<td tal:content="file/name">Filename.csv</td>
+	<td tal:content="python: file.lines - 1">12</td>
+	<td tal:content="file/size">123 Bytes</td>
+	<td tal:content="file/getDate">1.1.1970</td>
+      </tr>
+    </tbody>
+  </table>
+  <input type="submit" name="cancel" value="Cancel" />
+</form>
Index: /waeup/trunk/src/waeup/browser/templates/datacenterimport2page.pt
===================================================================
--- /waeup/trunk/src/waeup/browser/templates/datacenterimport2page.pt	(revision 4858)
+++ /waeup/trunk/src/waeup/browser/templates/datacenterimport2page.pt	(revision 4858)
@@ -0,0 +1,77 @@
+<h2>Process CSV file</h2>
+<h3>Step 2</h3>
+
+<form method="POST">
+  <p>
+    <b>File:</b> <span tal:content="view/filename">Filename.csv</span>
+    <input type="hidden" name="filename"
+	   tal:attributes="value view/filename" />
+  </p>
+  <p>
+    Please select a file-processor and a processing-mode from the 
+    selections below:
+  </p>
+
+  <div>
+    <table style="border: 1px solid #000;"
+	   tal:define="body view/getPreviewBody;
+		       headers view/getPreviewHeader;
+		       fieldnum python: len(headers)">
+      <thead>
+	<tr style="border: 1px solid #ccc;">
+	  <th style="border: 1px solid #888;"
+	      tal:repeat="headerfield headers"
+	      tal:content="headerfield">
+	    headerfield
+	  </th>
+	</tr>
+      </thead>
+      <tbody>
+	<tr tal:repeat="row body"
+	    tal:attributes="class python: repeat['row'].odd() and 'odd' or 'even'"
+	    style="border: 1px solid #000;">
+	  <td tal:repeat="value row"
+	      tal:content="value"
+	      style="border: 1px solid #000;">
+	    value
+	  </td>
+	</tr>
+      </tbody>
+    </table>
+  </div>
+
+  <div>
+    <b>Processor:</b>
+
+    <select name="importer">
+      <option tal:repeat="importer view/getImporters"
+	      tal:attributes="value importer/name;
+			      selected python: importer['name'] == view.importer and 'selected' or ''"
+	      tal:content="importer/title">
+	Some Importer
+      </option>
+    </select>
+
+  </div>
+
+  <p>&nbsp;</p>
+
+  <div>
+    <b>Mode:</b>
+
+    <input type="radio" name="mode" value="create" checked="checked"
+	   tal:attributes="checked python: view.mode == 'create' and 'checked' or ''" /> Create
+    &nbsp;&nbsp;
+    <input type="radio" name="mode" value="update"
+	   tal:attributes="checked python: view.mode == 'update' and 'checked' or ''" /> Update
+    &nbsp;&nbsp;
+    <input type="radio" name="mode" value="remove"
+	   tal:attributes="checked python: view.mode == 'remove' and 'checked' or ''" /> Remove
+
+    <p>&nbsp;</p>
+  </div>
+
+  <input type="submit" name="cancel" value="Cancel" />
+  <input type="submit" name="back1" value="Back to step 1" />
+  <input type="submit" name="proceed" value="Proceed to step 3..." />
+</form>
Index: /waeup/trunk/src/waeup/browser/templates/datacenterimport3page.pt
===================================================================
--- /waeup/trunk/src/waeup/browser/templates/datacenterimport3page.pt	(revision 4858)
+++ /waeup/trunk/src/waeup/browser/templates/datacenterimport3page.pt	(revision 4858)
@@ -0,0 +1,104 @@
+<h2>Process CSV file</h2>
+<h3>Step 3</h3>
+<div tal:define="warnings view/getWarnings">
+<form>
+
+  <p>
+    Eventually modify headerfields of import file below.
+  </p>
+
+  <p>
+    <b>File:</b> <span tal:content="view/filename">Filename.csv</span>
+    <input type="hidden" name="filename"
+	   tal:attributes="value view/filename" />
+  </p>
+  <p>
+    <b>Processor:</b>
+    <span tal:content="view/importer/name">Importer Name</span>
+    <input type="hidden" name="importer"
+	   tal:attributes="value view/importer/util_name" />
+  </p>
+  <p>
+    <b>Processing mode: </b>
+    <span tal:content="view/mode">mode</span>
+    <input type="hidden" name="mode"
+	   tal:attributes="value view/mode" />
+  </p>
+
+  <div>
+    <div class="alert error ui-state-error"
+	 tal:condition="warnings">
+      <span class="ui-icon ui-icon-alert">A</span>
+      Current settings will give the follwing warnings, when importing:
+      <div tal:repeat="warning warnings" 
+	   tal:content="warning">
+	Warning messages
+      </div>
+    </div>
+
+    <div class="alert notice ui-state-info"
+	 tal:condition="not: warnings">
+      <span class="ui-icon ui-icon-info">A</span>
+      Header fields OK
+    </div>
+  </div>
+
+
+  <div>
+    <table style="border: 1px solid #000;"
+	   tal:define="body view/getPreviewBody;
+		       headers view/headerfields;
+		       fieldnum python: len(headers)">
+      <thead>
+	<tr style="border: 1px solid #ccc;">
+	  <th style="border: 1px solid #888;"
+	      tal:repeat="headerfield headers"
+	      tal:content="headerfield">
+	    headerfield
+	  </th>
+	</tr>
+	<tr style="border: 1px solid #ccc;">
+	  <th style="border: 1px solid #888; text-align=center;"
+	      tal:repeat="headerfield headers">
+	    <i>change to:</i>
+	  </th>
+	</tr>
+	<tr style="border: 1px solid #ccc;">
+	  <th style="border: 1px solid #888;"
+	      tal:repeat="headerfield headers">
+	    <select name="headerfield">
+	      <option tal:repeat="opt view/getPossibleHeaders"
+		      tal:attributes="value opt/value;
+				      selected python: opt['value']==headerfield and 'selected' or '';"
+		      tal:content="opt/title">
+		new_header_name
+	      </option>
+	    </select>
+	  </th>
+	</tr>
+      </thead>
+      <tbody>
+	<tr tal:repeat="row body"
+	    tal:attributes="class python: repeat['row'].odd() and 'odd' or 'even'"
+	    style="border: 1px solid #000;">
+	  <td tal:repeat="value row"
+	      tal:content="value"
+	      style="border: 1px solid #000;">
+	    value
+	  </td>
+	</tr>
+      </tbody>
+    </table>
+  </div>
+
+
+  <input type="submit" name="cancel" value="Cancel" />
+  <input type="reset" name="reset" value="Reset" />
+  <input type="submit" name="update" value="Set headerfields" />
+  <br />
+  <input type="submit" name="back2" value="Back to step 2" />
+  <input tal:attributes="disabled python: len(warnings) and 'disabled' or ''"
+	 type="submit" name="proceed" value="Perform import..." />
+
+</form>
+</div>
Index: /waeup/trunk/src/waeup/browser/templates/datacenterimport4page.pt
===================================================================
--- /waeup/trunk/src/waeup/browser/templates/datacenterimport4page.pt	(revision 4858)
+++ /waeup/trunk/src/waeup/browser/templates/datacenterimport4page.pt	(revision 4858)
@@ -0,0 +1,22 @@
+<h2>Process CSV file</h2>
+<h3>Step 4</h3>
+<p>
+  Batch processing finished.
+</p>
+<p>
+  <b>File:</b> <span tal:content="view/filename">Filename.csv</span>
+</p>
+<p>
+  <b>Processor:</b>
+  <span tal:content="view/importer/name">Importer Name</span>
+</p>
+<p>
+  <b>Processing mode: </b>
+  <span tal:content="view/mode">mode</span>
+</p>
+<form method="POST">
+  <input tal:condition="view/warn_num"
+	 type="submit" name="back" value="Back to step 3" />
+  <input type="submit" name="finish" value="Back to data center" />
+  <input type="submit" name="showlog" value="View processing log" />
+</form>
Index: /waeup/trunk/src/waeup/browser/templates/datacenterlogspage.pt
===================================================================
--- /waeup/trunk/src/waeup/browser/templates/datacenterlogspage.pt	(revision 4858)
+++ /waeup/trunk/src/waeup/browser/templates/datacenterlogspage.pt	(revision 4858)
@@ -0,0 +1,55 @@
+<h2>Available Batch Logs</h2>
+
+<p>
+  Here you can see the logfiles of batch operations like mass import,
+  removal or update of data.
+</p>
+<p>
+  Use the buttons on the right to examine or delete the files.
+</p>
+
+<div tal:define="files view/files">
+  <p tal:condition="not: files">
+    Currently no log files are available.
+  </p>
+  <table tal:condition="files">
+    <thead>
+      <tr>
+	<th>File</th><th>User/Date</th><th>Operation/Status</th><th></th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr tal:repeat="file files"
+	  tal:attributes="class python: repeat['file'].odd() and 'odd' or 'even'">
+	<td tal:content="file/name">file.name</td>
+	<td>
+	  <div>
+	    <span tal:content="file/userid">zope.manager</span>
+	    <span tal:content="file/uploaddate">Fri Jan 15 17:44:25 2010</span>
+	  </div>
+	</td>
+	<td>
+	  <div>
+	    <span tal:content="file/mode">create</span>
+	  </div>
+	  <div>
+	    <span tal:content="file/stats">12 lines(12 okay/0 failed)</span>
+	  </div>
+	</td>
+	<td class="text-right">
+	  <form method="POST">
+	    <input type="submit" name="show" value="Show" />
+	    <input type="submit" name="remove" value="Delete" />
+	    <input type="hidden" name="logname"
+		   tal:attributes="value file/name" />
+	  </form>
+	</td>
+      </tr>
+    </tbody>
+  </table>
+
+  <form method="POST">
+    <input type="submit" name="back" value="Back to Data Center" />
+  </form>
+
+</div>
Index: /waeup/trunk/src/waeup/browser/templates/datacenterlogsshowfilepage.pt
===================================================================
--- /waeup/trunk/src/waeup/browser/templates/datacenterlogsshowfilepage.pt	(revision 4858)
+++ /waeup/trunk/src/waeup/browser/templates/datacenterlogsshowfilepage.pt	(revision 4858)
@@ -0,0 +1,17 @@
+<h2>
+  Logfile <span tal:replace="view/filename">file.csv</span>
+</h2>
+
+<form method="POST">
+  <input type="submit" name="back" value="Back" />
+</form>
+
+<div class="bd">
+  <pre tal:content="view/filecontents">
+    blah, blah
+  </pre>
+</div>
+
+<form method="POST">
+  <input type="submit" name="back" value="Back" />
+</form>
Index: /waeup/trunk/src/waeup/browser/templates/datacenterpage.pt
===================================================================
--- /waeup/trunk/src/waeup/browser/templates/datacenterpage.pt	(revision 4857)
+++ /waeup/trunk/src/waeup/browser/templates/datacenterpage.pt	(revision 4858)
Index: /waeup/trunk/src/waeup/browser/viewlets.py
===================================================================
--- /waeup/trunk/src/waeup/browser/viewlets.py	(revision 4857)
+++ /waeup/trunk/src/waeup/browser/viewlets.py	(revision 4858)
@@ -369,5 +369,33 @@
     target = 'addcertificatecourse'
 
-
+#
+# Actions with a 'browse' icon...
+#
+class BrowseActionButton(ActionButton):
+    grok.baseclass()
+    grok.context(IWAeUPObject)
+    grok.template('actionbutton')
+    grok.viewletmanager(ActionBar)
+    grok.require('waeup.manageUniversity')
+    icon = 'actionicon_manage.png' # File must exist in static/
+    target = '@@show' # link to this viewname.
+    text = 'Show batch logs' # Text to display on the button
+
+class BrowseDatacenterLogs(BrowseActionButton):
+    grok.context(IDataCenter)
+    grok.view(DatacenterPage)
+    grok.order(4)
+    icon = 'documentinfo_templet.png'
+    target = '@@logs'
+    text = 'Show batch logs'
+
+class BatchOpButton(ActionButton):
+    grok.context(IDataCenter)
+    grok.view(DatacenterPage)
+    grok.order(6)
+    icon = 'actionbox_templet.png'
+    target = '@@import1'
+    text = 'Batch processing'
+    
 #
 # Primary navigation tabs (in upper left navigation bar)...
Index: /waeup/trunk/src/waeup/datacenter.py
===================================================================
--- /waeup/trunk/src/waeup/datacenter.py	(revision 4857)
+++ /waeup/trunk/src/waeup/datacenter.py	(revision 4858)
@@ -63,5 +63,5 @@
         return result
     
-    def getFiles(self):
+    def getFiles(self, sort='name'):
         """Get a list of files stored in `storage`.
 
@@ -76,6 +76,22 @@
                 continue
             result.append(DataCenterFile(fullpath))
-        return result
-
+        if sort == 'date':
+            # sort results in newest-first order...
+            result = sorted(result, key=lambda x: x.getTimeStamp(),
+                            reverse=True)
+        return result
+
+    def getLogFiles(self):
+        """Get a list of .msg files.
+        """
+        result = []
+        files = self.getFiles()
+        for file in files:
+            if not file.name.endswith('.msg'):
+                continue
+            result.append(
+                LogFile(os.path.join(self.storage, file.name)))
+        return result
+            
     def setStoragePath(self, path, move=False, overwrite=False):
         """Set the path where to store files.
@@ -179,4 +195,5 @@
         return
 
+
 class DataCenterFile(object):
     """A description of a file stored in data center.
@@ -189,4 +206,5 @@
         self.size = self.getSize()
         self.uploaddate = self.getDate()
+        self.lines = self.getLinesNumber()
 
     def getDate(self):
@@ -196,4 +214,9 @@
         return date.strftime('%c')
 
+    def getTimeStamp(self):
+        """Get a (machine readable) timestamp.
+        """
+        return os.path.getctime(self.context)
+    
     def getSize(self):
         """Get a human readable file size.
@@ -209,4 +232,57 @@
         return size
 
+    def getLinesNumber(self):
+        """Get number of lines.
+        """
+        num = 0
+        for line in open(self.context, 'rb'):
+            num += 1
+        return num
+    
+class LogFile(DataCenterFile):
+    """A description of a log file.
+    """
+    def __init__(self, context):
+        super(LogFile, self).__init__(context)
+        self._markers = dict()
+        self._parsed = False
+        self.userid = self.getUserId()
+        self.mode = self.getMode()
+        self.stats = self.getStats()
+        self.source = self.getSourcePath()
+
+    def _parseFile(self, maxline=10):
+        """Find markers in a file.
+        """
+        if self._parsed:
+            return
+        for line in open(self.context, 'rb'):
+            line = line.strip()
+            if not ':' in line:
+                continue
+            name, text = line.split(':', 1)
+            self._markers[name.lower()] = text
+        self._parsed = True
+        return
+
+    def _getMarker(self, marker):
+        marker = marker.lower()
+        if not self._parsed:
+            self._parseFile()
+        if marker in self._markers.keys():
+            return self._markers[marker]
+    
+    def getUserId(self):
+        return self._getMarker('user') or '<UNKNOWN>'
+
+    def getMode(self):
+        return self._getMarker('mode') or '<NOT SET>'
+
+    def getStats(self):
+        return self._getMarker('processed') or '<Info not avail.>'
+
+    def getSourcePath(self):
+        return self._getMarker('source') or None
+    
 class Import(object):
     """Helper class to aggregate imports and their data.
Index: /waeup/trunk/src/waeup/interfaces.py
===================================================================
--- /waeup/trunk/src/waeup/interfaces.py	(revision 4857)
+++ /waeup/trunk/src/waeup/interfaces.py	(revision 4858)
@@ -9,4 +9,9 @@
 from waeup.permissions import RoleSource
 
+class FatalCSVError(Exception):
+    """Some row could not be processed.
+    """
+    pass
+
 def SimpleWAeUPVocabulary(*terms):
     """A well-buildt vocabulary provides terms with a value, token and
@@ -21,9 +26,10 @@
        up a catalog.
     """
-    catalog = None
     def getValues(self):
-        if self.catalog is None:
-            self.catalog = getUtility(ICatalog, name='courses_catalog')
-        return list(self.catalog.searchResults(code=('', 'z*')))
+        catalog = getUtility(ICatalog, name='courses_catalog')
+        return list(catalog.searchResults(code=('', 'z*')))
+
+    def getToken(self, value):
+        return value.code
         
     def getTitle(self, value):
@@ -201,4 +207,5 @@
     review_state = schema.Choice(
         title = u'review state',
+        default = 'unchecked',
         values = ['unchecked', 'checked']
         )
@@ -348,5 +355,29 @@
         `clear_old_data` is set to True.
         """
-        
+
+class IBatchProcessor(Interface):
+    """A batch processor that handles mass-operations.
+    """
+    name = schema.TextLine(
+        title = u'Importer name'
+        )
+
+    mode = schema.Choice(
+        title = u'Import mode',
+        values = ['create', 'update', 'remove']
+        )
+    
+    def doImport(path):
+        """Read data from ``path`` and update connected object.
+        """
+
+class ISchemaTypeConverter(Interface):
+    """A converter for zope.schema.types.
+    """
+    def convert(string):
+        """Convert string to certain schema type.
+        """
+
+    
 class IUserAccount(IWAeUPObject):
     """A user account.
@@ -403,4 +434,17 @@
     """A data center file.
     """
+
+    name = schema.TextLine(
+        title = u'Filename')
+
+    size = schema.TextLine(
+        title = u'Human readable file size')
+
+    uploaddate = schema.TextLine(
+        title = u'Human readable upload datetime')
+
+    lines = schema.Int(
+        title = u'Number of lines in file')
+        
     def getDate():
         """Get creation timestamp from file in human readable form.
@@ -410,2 +454,6 @@
         """Get human readable size of file.
         """
+
+    def getLinesNumber():
+        """Get number of lines of file.
+        """
Index: /waeup/trunk/src/waeup/utils/api.txt
===================================================================
--- /waeup/trunk/src/waeup/utils/api.txt	(revision 4857)
+++ /waeup/trunk/src/waeup/utils/api.txt	(revision 4858)
@@ -15,4 +15,6 @@
 
    helpers.txt
+   batching.txt
    importexport.txt
    csvimport.txt
+   converters.txt
Index: /waeup/trunk/src/waeup/utils/batching.py
===================================================================
--- /waeup/trunk/src/waeup/utils/batching.py	(revision 4858)
+++ /waeup/trunk/src/waeup/utils/batching.py	(revision 4858)
@@ -0,0 +1,512 @@
+"""WAeUP components for batch processing.
+
+Batch processors eat CSV files to add, update or remove large numbers
+of certain kinds of objects at once.
+"""
+import grok
+import csv
+import os
+import sys
+import time
+from zope.component import createObject
+from zope.interface import Interface
+from zope.schema import getFields
+from zope.schema.interfaces import IText
+from waeup.interfaces import (IBatchProcessor, IFacultyContainer, IFaculty,
+                              ICourse, IDepartment, ICertificate,
+                              ICertificateCourse, ISchemaTypeConverter,
+                              FatalCSVError)
+
+class BatchProcessor(grok.GlobalUtility):
+    """A processor to add, update, or remove data.
+
+    This is a non-active baseclass.
+    """
+    grok.provides(IBatchProcessor)
+    grok.context(Interface)
+    grok.baseclass()
+
+    # Name used in pages and forms...
+    name = u'Department importer'
+    
+    # Internal name...
+    util_name = 'departmentimporter'
+    
+    # Items for this processor need an interface with zope.schema fields.
+    iface = IDepartment
+    
+    # The name must be the same as the util_name attribute in order to
+    # register this utility correctly.
+    grok.name(util_name)
+
+    # Headers needed to locate items...
+    location_fields = ['code', 'faculty_code']
+    
+    # A factory with this name must be registered...
+    factory_name = 'waeup.Department'
+
+    @property
+    def required_fields(self):
+        """Required fields that have no default.
+
+        A list of names of field, whose value cannot be set if not
+        given during creation. Therefore these fields must exist in
+        input.
+
+        Fields with a default != missing_value do not belong to this
+        category.
+        """
+        result = []
+        for key, field in getFields(self.iface).items():
+            if key in self.location_fields:
+                continue
+            if field.default is not field.missing_value:
+                continue
+            if field.required:
+                result.append(key)
+        return result
+        
+    @property
+    def req(self):
+        result = dict(
+            create = self.location_fields + self.required_fields,
+            update = self.location_fields,
+            remove = self.location_fields,
+        )
+        return result
+
+    @property
+    def available_fields(self):
+        result = []
+        return sorted(list(set(
+                    self.location_fields + getFields(self.iface).keys())))
+    
+    def getHeaders(self, mode='create'):
+        return self.available_fields
+
+    def checkHeaders(self, headerfields, mode='create'):
+        req = self.req[mode]
+        # Check for required fields...
+        for field in req:
+            if not field in headerfields:
+                raise FatalCSVError(
+                    "Need at least columns %s for import!" %
+                    ', '.join(["'%s'" % x for x in req]))
+        # Check for double fields...
+        not_ignored_fields = [x for x in headerfields
+                              if not x.startswith('--')]
+        if len(set(not_ignored_fields)) < len(not_ignored_fields):
+            raise FatalCSVError(
+                "Double headers: each column name may only appear once.")
+        return True
+
+    def applyMapping(self, row, mapping):
+        """Apply mapping to a row of CSV data.
+        """
+        result = dict()
+        for key, replacement in mapping.items():
+            result[replacement] = row[key]
+        return result
+        
+    def getMapping(self, path, headerfields, mode):
+        """Get a mapping from CSV file headerfields to actually used
+           fieldnames.
+        """
+        result = dict()
+        reader = csv.reader(open(path, 'rb'))
+        raw_header = reader.next()
+        for num, field in enumerate(headerfields):
+            if field not in self.location_fields and mode == 'remove':
+                # Ignore non-location fields when removing...
+                field = '--IGNORE--'
+            result[raw_header[num]] = field
+        return result
+
+    def getFieldConverters(self, fieldnames):
+        """Get converters for fieldnames.
+        """
+        result = dict()
+        for key, field in getFields(self.iface).items():
+            if key not in fieldnames:
+                continue
+            converter = ISchemaTypeConverter(field)
+            result[key] = converter
+        return result
+    
+    def convertToTypes(self, row, converter_dict):
+        """Convert values in given row to destination type.
+        """
+        if '--IGNORE--' in row.keys():
+            del row['--IGNORE--']
+        warnings = []
+        for key, value in row.items():
+            converter = converter_dict.get(key, None)
+            if converter:
+                try:
+                    row.update({key:converter_dict[key].fromString(value)})
+                except:
+                    msg = "conversion error: field %s: %r" % (
+                        key, sys.exc_info()[1])
+                    warnings.append(msg)
+        return (row, warnings)
+
+    def callFactory(self, *args, **kw):
+        return createObject(self.factory_name)
+
+    def parentsExist(self, row, site):
+        """Tell whether the parent object for data in ``row`` exists.
+        """
+        raise NotImplementedError('method not implemented')
+
+    def entryExists(self, row, site):
+        """Tell whether there already exists an entry for ``row`` data.
+        """
+        raise NotImplementedError('method not implemented')
+
+    def getParent(self, row, site):
+        """Get the parent object for the entry in ``row``.
+        """
+        raise NotImplementedError('method not implemented')
+    
+    def addEntry(self, obj, row, site):
+        """Add the entry given given by ``row`` data.
+        """
+        raise NotImplementedError('method not implemented')
+
+    def delEntry(self, row, site):
+        """Delete entry given by ``row`` data.
+        """
+        raise NotImplementedError('method not implemented')        
+
+    def updateEntry(self, obj, row, site):
+        for key, value in row.items():
+            setattr(obj, key, value)
+        parent = self.getParent(row, site)
+        parent[row['name']] = obj
+        return
+
+    def createLogfile(self, path, fail_path, num, warnings, mode, user,
+                      timedelta):
+        """Write a log file.
+        """
+        failmsg_path = "%s.%s.msg" % (path, mode)
+        fd = open(failmsg_path, 'wb')
+        fd.write("Source: %s\n" % path)
+        fd.write("Mode: %s\n" % mode)
+        fd.write("Date: %s\n" % time.ctime())
+        fd.write("User: %s\n" % user)
+        fd.write("Failed datasets: %s\n" % fail_path)
+        fd.write("Processing time: %0.3f s (%0.4f s/item)\n" % (
+                timedelta, timedelta/(num or 1)))
+        fd.write("Processed: %s lines (%s successful/ %s failed)\n" % (
+                num, num - len(warnings), len(warnings)
+                ))
+        fd.write("-" * 70 + '\n')
+        failnum = 1
+        for linenum, text in sorted(warnings.items()):
+            fd.write("line %s (%s): %s\n" % (failnum, linenum, text))
+            failnum += 1
+        fd.close()
+        return
+    
+    def doImport(self, path, headerfields, mode='create', user='Unknown'):
+        """Perform actual import.
+        """
+        time_start = time.time()
+        self.checkHeaders(headerfields, mode)
+        mapping = self.getMapping(path, headerfields, mode)
+        converters = self.getFieldConverters(headerfields)
+        reader = csv.DictReader(open(path, 'rb'))
+        
+        failed_path = "%s.%s.pending" % (path, mode, )
+        failed_headers = [x for x in mapping.values()
+               if x != '--IGNORE--']
+        failed_writer = csv.DictWriter(open(failed_path, 'wb'),
+                                       failed_headers)
+        failed_writer.writerow(dict([(x,x) for x in failed_headers]))
+        
+        warnings = dict()
+        num =0
+        site = grok.getSite()
+        for raw_row in reader:
+            num += 1
+            string_row = self.applyMapping(raw_row, mapping)
+            row, conv_warnings = self.convertToTypes(string_row, converters)
+            if len(conv_warnings):
+                warnings[num] = ', '.join(conv_warnings)
+                failed_writer.writerow(row)
+                continue
+                
+            if mode == 'create':
+                if not self.parentsExist(row, site):
+                    warnings[num] = "Not all parents do exist yet. Skipping"
+                    failed_writer.writerow(row)
+                    continue
+                if self.entryExists(row, site):
+                    warnings[num] = 'This object already exists. Skipping.'
+                    failed_writer.writerow(row)
+                    continue
+                obj = self.callFactory()
+                for key, value in row.items():
+                    setattr(obj, key, value)
+                self.addEntry(obj, row, site)
+            elif mode == 'remove':
+                if not self.entryExists(row, site):
+                    warnings[num] = "Cannot remove: no such entry."
+                    failed_writer.writerow(row)
+                    continue
+                self.delEntry(row, site)
+            elif mode == 'update':
+                obj = self.getEntry(row, site)
+                if obj is None:
+                    warnings[num] = "Cannot update: no such entry."
+                    failed_writer.writerow(row)
+                    continue
+                self.updateEntry(obj, row, site)
+
+        time_end = time.time()
+        timedelta = time_end - time_start
+        
+        if path.endswith('.pending'):
+            os.unlink(path)
+            os.rename(failed_path, path)
+            failed_path = path
+        self.createLogfile(path, failed_path, num, warnings, mode, user,
+                           timedelta)
+        if len(warnings) == 0:
+            del failed_writer
+            os.unlink(failed_path)
+        return (num, warnings)
+
+class FacultyProcessor(BatchProcessor):
+    """A batch processor for IFaculty objects.
+    """
+    grok.provides(IBatchProcessor)
+    grok.context(Interface)
+    util_name = 'facultyimporter'
+    grok.name(util_name)
+
+    name = u'Faculty importer'
+    iface = IFaculty
+
+    location_fields = ['code',]
+    factory_name = 'waeup.Faculty'
+
+    def parentsExist(self, row, site):
+        return 'faculties' in site.keys()
+
+    def entryExists(self, row, site):
+        return row['code'] in site['faculties'].keys()
+
+    def getParent(self, row, site):
+        return site['faculties']
+
+    def getEntry(self, row, site):
+        if not self.entryExists(row, site):
+            return None
+        parent = self.getParent(row, site)
+        return parent.get(row['code'])
+    
+    def addEntry(self, obj, row, site):
+        parent = self.getParent(row, site)
+        parent.addFaculty(obj)
+        return
+
+    def delEntry(self, row, site):
+        parent = self.getParent(row, site)
+        del parent[row['code']]
+        pass
+
+class DepartmentProcessor(BatchProcessor):
+    """A batch processor for IDepartment objects.
+    """
+    grok.provides(IBatchProcessor)
+    grok.context(Interface)
+    util_name = 'departmentimporter'
+    grok.name(util_name)
+
+    name = u'Department importer'
+    iface = IDepartment
+
+    location_fields = ['code', 'faculty_code']
+    factory_name = 'waeup.Department'
+
+    def parentsExist(self, row, site):
+        if not 'faculties' in site.keys():
+            return False
+        return row['faculty_code'] in site['faculties']
+
+    def entryExists(self, row, site):
+        if not self.parentsExist(row, site):
+            return False
+        parent = self.getParent(row, site)
+        return row['code'] in parent.keys()
+
+    def getParent(self, row, site):
+        return site['faculties'][row['faculty_code']]
+
+    def getEntry(self, row, site):
+        if not self.entryExists(row, site):
+            return None
+        parent = self.getParent(row, site)
+        return parent.get(row['code'])
+    
+    def addEntry(self, obj, row, site):
+        parent = self.getParent(row, site)
+        parent.addDepartment(obj)
+        return
+
+    def delEntry(self, row, site):
+        parent = self.getParent(row, site)
+        del parent[row['code']]
+        return
+
+class CourseProcessor(BatchProcessor):
+    """A batch processor for ICourse objects.
+    """
+    grok.provides(IBatchProcessor)
+    grok.context(Interface)
+    util_name = 'courseimporter'
+    grok.name(util_name)
+
+    name = u'Course importer'
+    iface = ICourse
+
+    location_fields = ['code', 'faculty_code', 'department_code']
+    factory_name = 'waeup.Course'
+
+    def parentsExist(self, row, site):
+        if not 'faculties' in site.keys():
+            return False
+        if not row['faculty_code'] in site['faculties'].keys():
+            return False
+        faculty = site['faculties'][row['faculty_code']]
+        return row['department_code'] in faculty.keys()
+
+    def entryExists(self, row, site):
+        if not self.parentsExist(row, site):
+            return False
+        parent = self.getParent(row, site)
+        return row['code'] in parent.keys()
+
+    def getParent(self, row, site):
+        dept = site['faculties'][row['faculty_code']][row['department_code']]
+        return dept.courses
+
+    def getEntry(self, row, site):
+        if not self.entryExists(row, site):
+            return None
+        parent = self.getParent(row, site)
+        return parent.get(row['code'])
+    
+    def addEntry(self, obj, row, site):
+        parent = self.getParent(row, site)
+        parent.addCourse(obj)
+        return
+
+    def delEntry(self, row, site):
+        parent = self.getParent(row, site)
+        del parent[row['code']]
+        return
+
+class CertificateProcessor(BatchProcessor):
+    """A batch processor for ICertificate objects.
+    """
+    grok.provides(IBatchProcessor)
+    grok.context(Interface)
+    util_name = 'certificateimporter'
+    grok.name(util_name)
+
+    name = u'Certificate importer'
+    iface = ICertificate
+
+    location_fields = ['code', 'faculty_code', 'department_code']
+    factory_name = 'waeup.Certificate'
+
+    def parentsExist(self, row, site):
+        if not 'faculties' in site.keys():
+            return False
+        if not row['faculty_code'] in site['faculties'].keys():
+            return False
+        faculty = site['faculties'][row['faculty_code']]
+        return row['department_code'] in faculty.keys()
+
+    def entryExists(self, row, site):
+        if not self.parentsExist(row, site):
+            return False
+        parent = self.getParent(row, site)
+        return row['code'] in parent.keys()
+
+    def getParent(self, row, site):
+        dept = site['faculties'][row['faculty_code']][row['department_code']]
+        return dept.certificates
+
+    def getEntry(self, row, site):
+        if not self.entryExists(row, site):
+            return None
+        parent = self.getParent(row, site)
+        return parent.get(row['code'])
+
+    def addEntry(self, obj, row, site):
+        parent = self.getParent(row, site)
+        parent.addCertificate(obj)
+        return
+
+    def delEntry(self, row, site):
+        parent = self.getParent(row, site)
+        del parent[row['code']]
+        return
+
+class CertificateCourseProcessor(BatchProcessor):
+    """A batch processor for ICertificateCourse objects.
+    """
+    grok.provides(IBatchProcessor)
+    grok.context(Interface)
+    util_name = 'certificatecourseimporter'
+    grok.name(util_name)
+
+    name = u'CertificateCourse importer'
+    iface = ICertificateCourse
+
+    location_fields = ['course', 'level', 'faculty_code', 'department_code',
+                       'certificate_code',]
+    factory_name = 'waeup.CertificateCourse'
+
+    def parentsExist(self, row, site):
+        if not 'faculties' in site.keys():
+            return False
+        if not row['faculty_code'] in site['faculties'].keys():
+            return False
+        faculty = site['faculties'][row['faculty_code']]
+        if not row['department_code'] in faculty.keys():
+            return False
+        dept = faculty[row['department_code']]
+        return row['certificate_code'] in dept.certificates.keys()
+
+    def entryExists(self, row, site):
+        if not self.parentsExist(row, site):
+            return False
+        parent = self.getParent(row, site)
+        code = "%s_%s" % (row['course'].code, row['level'])
+        return code in parent.keys()
+
+    def getParent(self, row, site):
+        dept = site['faculties'][row['faculty_code']][row['department_code']]
+        return dept.certificates[row['certificate_code']]
+
+    def getEntry(self, row, site):
+        if not self.entryExists(row, site):
+            return None
+        parent = self.getParent(row, site)
+        return parent.get(row['course'])
+
+    def addEntry(self, obj, row, site):
+        parent = self.getParent(row, site)
+        parent.addCourseRef(row['course'],
+                            row['level'], row['core_or_elective'])
+        return
+
+    def delEntry(self, row, site):
+        parent = self.getParent(row, site)
+        parent.delCourseRef(row['course'].code, row['level'])
+        return
Index: /waeup/trunk/src/waeup/utils/batching.txt
===================================================================
--- /waeup/trunk/src/waeup/utils/batching.txt	(revision 4858)
+++ /waeup/trunk/src/waeup/utils/batching.txt	(revision 4858)
@@ -0,0 +1,436 @@
+:mod:`waeup.utils.batching` -- Batch processing
+***********************************************
+
+Batch processing is much more than pure data import.
+
+:test-layer: functional
+
+Overview
+========
+
+Basically, it means processing CSV files in order to mass-create,
+mass-remove, or mass-update data.
+
+So you can feed CSV files to importers or processors, that are part of
+the batch-processing mechanism.
+
+Importers/Processors
+--------------------
+
+Each CSV file processor
+
+* accepts a single data type identified by an interface.
+
+* knows about the places inside a site (University) where to store,
+  remove or update the data.
+
+* can check headers before processing data.
+
+* supports the mode 'create', 'update', 'remove'.
+
+* creates logs and failed-data csv files.
+
+Output
+------
+
+The results of processing are written to logfiles. Beside this a new
+CSV file is created during processing, containing only those data
+sets, that could not be processed.
+
+This new CSV file is called like the input file, appended by mode and
+'.pending'. So, when the input file is named 'foo.csv' and something
+went wrong during processing, then a file 'foo.csv.create.pending'
+will be generated (if the operation mode was 'create'). The log files
+tells why these lines could not be processed.
+
+It looks like this::
+ 
+     -----+      +---------+
+    /     |      |         |              +------+
+   | .csv +----->|Batch-   |              |      |
+   |      |      |processor+----changes-->| ZODB |
+   |  +------+   |         |              |      |
+   +--|      |   |         +              +------+
+      | Mode +-->|         |                 -------+
+      |      |   |         +----outputs-+-> /       |
+      |      |   +---------+            |  |.pending|
+      +------+   ^                      |  |        |
+                 |                      |  +--------+
+           +-----++                     v
+           |Inter-|                  -----+
+           |face  |                 /     |
+           +------+                | .msg |
+                                   |      |
+                                   +------+
+
+
+Creating a batch processor
+==========================
+
+We create an own batch processor for an own datatype. This datatype
+must be based on an interface that the batcher can use for converting
+data.
+
+Founding Stoneville
+-------------------
+
+We start with the interface:
+
+    >>> from zope.interface import Interface
+    >>> from zope import schema
+    >>> class ICave(Interface):
+    ...   """A cave."""
+    ...   name = schema.TextLine(
+    ...     title = u'Cave name',
+    ...     default = u'Unnamed',
+    ...     required = True)
+    ...   dinoports = schema.Int(
+    ...     title = u'Number of DinoPorts (tm)',
+    ...     required = False,
+    ...     default = 1)
+    ...   owner = schema.TextLine(
+    ...     title = u'Owner name',
+    ...     required = True,
+    ...     missing_value = 'Fred Estates Inc.')
+
+Now a class that implements this interface:
+
+    >>> import grok
+    >>> class Cave(object):
+    ...   grok.implements(ICave)
+    ...   def __init__(self, name=u'Unnamed', dinoports=2,
+    ...                owner='Fred Estates Inc.'):
+    ...     self.name = name
+    ...     self.dinoports = 2
+    ...     self.owner = owner
+
+We also provide a factory for caves. Strictly speaking, this not
+necessary but makes the batch processor we create afterwards, better
+understandable.
+
+    >>> from zope.component import getGlobalSiteManager
+    >>> from zope.component.factory import Factory
+    >>> from zope.component.interfaces import IFactory
+    >>> gsm = getGlobalSiteManager()
+    >>> cave_maker = Factory(Cave, 'A cave', 'Buy caves here!')
+    >>> gsm.registerUtility(cave_maker, IFactory, 'Lovely Cave')
+
+Now we can create caves using a factory:
+
+    >>> from zope.component import createObject
+    >>> createObject('Lovely Cave')
+    <Cave object at 0x...>
+
+This is nice, but we still lack a place, where we can place all the
+lovely caves we want to sell.
+
+Furthermore, as a replacement for a real site, we define a place where
+all caves can be stored: Stoneville! This is a lovely place for
+upperclass cavemen (which are the only ones that can afford more than
+one dinoport).
+
+We found Stoneville:
+
+    >>> stoneville = dict()
+
+Everything in place.
+
+Now, to improve local health conditions, imagine we want to populate
+Stoneville with lots of new happy dino-hunting natives that slept on
+the bare ground in former times and had no idea of
+bathrooms. Disgusting, isn't it?
+
+Lots of cavemen need lots of caves.
+
+Of course we can do something like:
+
+    >>> cave1 = createObject('Lovely Cave')
+    >>> cave1.name = "Fred's home"
+    >>> cave1.owner = "Fred"
+    >>> stoneville[cave1.name] = cave1
+
+and Stoneville has exactly
+
+    >>> len(stoneville)
+    1
+
+inhabitant. But we don't want to do this for hundreds or thousands of
+citizens-to-be, do we?
+
+It is much easier to create a simple CSV list, where we put in all the
+data and let a batch processor do the job.
+
+The list is already here:
+
+    >>> open('newcomers.csv', 'wb').write(
+    ... """name,dinoports,owner
+    ... Barneys Home,2,Barney
+    ... Wilmas Asylum,1,Wilma
+    ... Freds Dinoburgers,10,Fred
+    ... Joeys Drive-in,110,Joey
+    ... """)
+
+All we need, is a batch processor now.
+
+    >>> from waeup.utils.batching import BatchProcessor
+    >>> class CaveProcessor(BatchProcessor):
+    ...   util_name = 'caveprocessor'
+    ...   grok.name(util_name)
+    ...   name = 'Cave Processor'
+    ...   iface = ICave
+    ...   location_fields = ['name']
+    ...   factory_name = 'Lovely Cave'
+    ...
+    ...   def parentsExist(self, row, site):
+    ...     return True
+    ...
+    ...   def getParent(self, row, site):
+    ...     return stoneville
+    ...
+    ...   def entryExists(self, row, site):
+    ...     return row['name'] in stoneville.keys()
+    ...
+    ...   def getEntry(self, row, site):
+    ...     if not self.entryExists(row, site):
+    ...       return None
+    ...     return stoneville[row['name']]
+    ...
+    ...   def delEntry(self, row, site):
+    ...     del stoneville[row['name']]
+    ...
+    ...   def addEntry(self, obj, row, site):
+    ...     stoneville[row['name']] = obj
+    ...
+    ...   def updateEntry(self, obj, row, site):
+    ...     for key, value in row.items():
+    ...       setattr(obj, key, value)
+
+Create the fellows:
+
+    >>> processor = CaveProcessor()
+    >>> processor.doImport('newcomers.csv', ['name', 'dinoports', 'owner'],
+    ...                    mode='create', user='Bob')
+    (4, {})
+
+The result means: four entries were processed and no warnings
+occured. Let's check:
+
+    >>> sorted(stoneville.keys())
+    [u'Barneys Home', ..., u'Wilmas Asylum']
+
+The values of the Cave instances have correct type:
+
+    >>> barney = stoneville['Barneys Home']
+    >>> barney.dinoports
+    2
+
+which is a number, not a string.
+
+Apparently, when calling the processor, we gave some more info than
+only the CSV filepath. What does it all mean?
+
+While the first argument is the path to the CSV file, we also have to
+give an ordered list of headernames. These replace the header field
+names that are actually in the file. This way we can override faulty
+headers.
+
+The ``mode`` paramter tells what kind of operation we want to perform:
+``create``, ``update``, or ``remove`` data.
+
+The ``user`` parameter finally is optional and only used for logging.
+
+We can, by the way, see the results of our run in a logfile which is
+named ``newcomers.csv.create.msg``:
+
+    >>> print open('newcomers.csv.create.msg').read()
+    Source: newcomers.csv
+    Mode: create
+    Date: ...
+    User: Bob
+    Failed datasets: newcomers.csv.create.pending
+    Processing time: ... s (... s/item)
+    Processed: 4 lines (4 successful/ 0 failed)
+    ------------------------------------------------------------...
+    <BLANKLINE>
+
+As we can see, the processing was successful. Otherwise, all problems
+could be read here as we can see, if we do the same operation again:
+
+    >>> processor.doImport('newcomers.csv', ['name', 'dinoports', 'owner'],
+    ...                    mode='create', user='Bob')
+    (4, {1: 'This object already exists...})
+
+The log file will tell us this in more detail:
+
+    >>> print open('newcomers.csv.create.msg').read()
+    Source: newcomers.csv
+    Mode: create
+    Date: ...
+    User: Bob
+    Failed datasets: newcomers.csv.create.pending
+    Processing time: ... s (... s/item)
+    Processed: 4 lines (0 successful/ 4 failed)
+    ----------------------------------------------------------------------
+    line 1 (1): This object already exists. Skipping.
+    line 2 (2): This object already exists. Skipping.
+    line 3 (3): This object already exists. Skipping.
+    line 4 (4): This object already exists. Skipping.
+
+This time a new file was created, which keeps all the rows we could not
+process:
+
+    >>> print open('newcomers.csv.create.pending').read()
+    owner,name,dinoports
+    Barney,Barneys Home,2
+    Wilma,Wilmas Asylum,1
+    Fred,Freds Dinoburgers,10
+    Joey,Joeys Drive-in,110
+
+This way we can correct the faulty entries and afterwards retry without
+having the already processed rows in the way.
+
+
+Updating entries
+----------------
+
+To update entries, we just call the batchprocessor in a different
+mode:
+
+    >>> processor.doImport('newcomers.csv', ['name', 'dinoports', 'owner'],
+    ...                    mode='update', user='Bob')
+    (4, {})
+
+No we want to tell, that Wilma got an extra port for her second dino:
+
+    >>> open('newcomers.csv', 'wb').write(
+    ... """name,dinoports,owner
+    ... Wilmas Asylum,2,Wilma
+    ... """)
+
+    >>> wilma = stoneville['Wilmas Asylum']
+    >>> wilma.dinoports
+    1
+
+We start the processor:
+
+    >>> processor.doImport('newcomers.csv', ['name', 'dinoports', 'owner'],
+    ...                    mode='update', user='Bob')
+    (1, {})
+
+    >>> wilma = stoneville['Wilmas Asylum']
+    >>> wilma.dinoports
+    2
+
+Wilma's number of dinoports raised.
+
+If we try to update an unexisting entry, an error occurs:
+
+    >>> open('newcomers.csv', 'wb').write(
+    ... """name,dinoports,owner
+    ... NOT-WILMAS-ASYLUM,2,Wilma
+    ... """)
+
+    >>> processor.doImport('newcomers.csv', ['name', 'dinoports', 'owner'],
+    ...                    mode='update', user='Bob')
+    (1, {1: 'Cannot update: no such entry.'})
+    
+Also invalid values will be spotted:
+
+    >>> open('newcomers.csv', 'wb').write(
+    ... """name,dinoports,owner
+    ... Wilmas Asylum,NOT-A-NUMBER,Wilma
+    ... """)
+
+    >>> processor.doImport('newcomers.csv', ['name', 'dinoports', 'owner'],
+    ...                    mode='update', user='Bob')
+    (1, {1: 'conversion error: ... dinoports: ValueError...NOT-A-NUMBER...
+
+We can also update only some cols, leaving some out. We skip the
+'dinoports' column in the next run:
+
+    >>> open('newcomers.csv', 'wb').write(
+    ... """name,owner
+    ... Wilmas Asylum,Barney
+    ... """)
+
+    >>> processor.doImport('newcomers.csv', ['name', 'owner'],
+    ...                    mode='update', user='Bob')
+    (1, {})
+
+    >>> wilma.owner
+    u'Barney'
+
+We can however, not leave out the 'location field' ('name' in our
+case), as this one tells us which entry to update:
+
+    >>> open('newcomers.csv', 'wb').write(
+    ... """name,dinoports,owner
+    ... 2,Wilma
+    ... """)
+
+    >>> processor.doImport('newcomers.csv', ['dinoports', 'owner'],
+    ...                    mode='update', user='Bob')
+    Traceback (most recent call last):
+    ...
+    FatalCSVError: Need at least columns 'name' for import!
+
+This time we get even an exception!
+
+We can tell to set dinoports to ``None`` although this is not a
+number, as we declared the field not required in the interface:
+
+    >>> open('newcomers.csv', 'wb').write(
+    ... """name,dinoports,owner
+    ... "Wilmas Asylum",,"Wilma"
+    ... """)
+
+    >>> processor.doImport('newcomers.csv', ['name', 'dinoports', 'owner'],
+    ...                    mode='update', user='Bob')
+    (1, {})
+
+    >>> wilma.dinoports is None
+    True
+
+Generally, empty strings are considered as ``None``:
+
+    >>> open('newcomers.csv', 'wb').write(
+    ... """name,dinoports,owner
+    ... "Wilmas Asylum","","Wilma"
+    ... """)
+
+    >>> processor.doImport('newcomers.csv', ['name', 'dinoports', 'owner'],
+    ...                    mode='update', user='Bob')
+    (1, {})
+
+    >>> wilma.dinoports is None
+    True
+
+Removing entries
+----------------
+
+In 'remove' mode we can delete entries. Here validity of values in
+non-location fields doesn't matter because those fields are ignored.
+
+    >>> open('newcomers.csv', 'wb').write(
+    ... """name,dinoports,owner
+    ... "Wilmas Asylum","ILLEGAL-NUMBER",""
+    ... """)
+
+    >>> processor.doImport('newcomers.csv', ['name', 'dinoports', 'owner'],
+    ...                    mode='remove', user='Bob')
+    (1, {})
+
+    >>> sorted(stoneville.keys())
+    [u'Barneys Home', "Fred's home", u'Freds Dinoburgers', u'Joeys Drive-in']
+
+Oops! Wilma is gone.
+
+
+Clean up:
+
+    >>> import os
+    >>> os.unlink('newcomers.csv')
+    >>> os.unlink('newcomers.csv.create.pending')
+    >>> os.unlink('newcomers.csv.create.msg')
+    >>> os.unlink('newcomers.csv.remove.msg')
+    >>> os.unlink('newcomers.csv.update.msg')
Index: /waeup/trunk/src/waeup/utils/converters.py
===================================================================
--- /waeup/trunk/src/waeup/utils/converters.py	(revision 4858)
+++ /waeup/trunk/src/waeup/utils/converters.py	(revision 4858)
@@ -0,0 +1,165 @@
+"""Converters for zope.schema-based datatypes.
+"""
+import grok
+from zope.component import getMultiAdapter
+from zope.publisher.browser import TestRequest
+try:
+    from zope.app.form.browser.interfaces import ITerms
+except ImportError:
+    from zope.browser.interfaces import ITerms
+from zope.schema.interfaces import IBool, IText, IInt, IChoice
+from waeup.interfaces import ISchemaTypeConverter
+
+# If a string has this value, it is considered as 'missing_value' or None.
+NONE_STRING_VALUE = ''
+
+class Converter(grok.Adapter):
+    """Base for string-or-none to zope.schema field converters.
+    """
+    grok.baseclass()
+    grok.provides(ISchemaTypeConverter)
+    
+    def __init__(self, context):
+        """Create a converter with context, a zope.schema.Field, as context.
+        """
+        self.context = context
+
+    def _convertValueFromString(self, string):
+        """You must at least override this method to build a working
+           converter.
+        """
+        raise NotImplementedError('method not implemented')
+    
+    def fromString(self, string=None, strict=True):
+        """Convert ``string`` to value according to assigned field type.
+        """
+        result = None
+        if string is NONE_STRING_VALUE:
+            string = None
+        if string is None:
+            if self.context.required is True:
+                result = self.context.default
+            else:
+                result = self.context.missing_value
+        else:
+            result = self._convertValueFromString(string)
+        if strict:
+            self.context.validate(result)
+        return result
+
+    def _convertValueToString(self, value):
+        return str(value)
+
+    def toString(self, value, strict=True):
+        if strict:
+            self.context.validate(value)
+        if value == self.context.missing_value:
+            return None
+        return self._convertValueToString(value)
+
+class BoolConverter(Converter):
+    """A converter for zope.schema.Bool fields.
+    """
+    grok.context(IBool)
+    grok.provides(ISchemaTypeConverter)
+
+    def _convertValueFromString(self, string):
+        if string is NONE_STRING_VALUE:
+            string = None
+        if string is None:
+            return None
+        if string.lower() in ['1', 'true', 'yes']:
+            return True
+        return False
+
+    def _convertValueToString(self, value):
+        if value is None:
+            return None
+        if value:
+            return '1'
+        return '0'
+    
+class TextConverter(Converter):
+    """A converter for zope.schema.interfaces.IText fields.
+    """
+    grok.context(IText)
+    grok.provides(ISchemaTypeConverter)
+
+    def _convertValueFromString(self, string):
+        return unicode(string)
+
+class IntConverter(Converter):
+    """A converter for zope.schema.Int fields.
+    """
+    grok.context(IInt)
+    grok.provides(ISchemaTypeConverter)
+
+    def _convertValueFromString(self, string):
+        return int(string)
+
+class ChoiceConverter(Converter):
+    """A converter for zope.schema.Choice fields.
+    """
+    grok.context(IChoice)
+    grok.provides(ISchemaTypeConverter)
+
+    tokens = None
+    values = None
+    
+    def __init__(self, context):
+        self.context = context
+        if not hasattr(self.context.source, 'factory'):
+            try:
+                self.terms = getMultiAdapter(
+                    (self.context.source, TestRequest()), ITerms)
+            except:
+                self.terms = None
+
+            return
+        if not hasattr(self.context.source.factory, 'getToken'):
+            return
+        # For expensive token/key lookups we create a 'cache'
+        # here. This speeds up mass operations with many conversions
+        # by factor 10 or more.
+        
+        # Mapping token -> value
+        self.tokens = dict([(self.context.source.factory.getToken(x), x)
+                            for x in self.context.source.factory.getValues()])
+        # Mapping value -> token
+        self.values = dict([(y,x) for x,y in self.tokens.items()])
+
+    def _convertValueFromString(self, string):
+        if self.tokens is not None:
+            return self.tokens[string]
+        if self.terms is not None:
+            return self.terms.getValue(string)
+        return self.context.source.getTermByToken(string).value
+
+    def _convertValueToString(self, value):
+        if self.values is not None:
+            return self.values[value]
+        if self.terms is not None:
+            return self.terms.getTerm(value).token
+        return str(value)
+
+    def fromString(self, string=None, strict=False):
+        """Convert ``string`` to value according to assigned field type.
+
+        We change the default for ``strict``: this disables extra
+        validation checks for Choice fields and saves lots of time. If
+        a string/value is out of allowed range we get a value or key
+        error anyway.
+        """
+        return super(ChoiceConverter, self).fromString(string=string,
+                                                       strict=strict)
+
+    def toString(self, value, strict=False):
+        """Convert ``value`` to string according to assigned field type.
+
+        We change the default for ``strict``: this disables extra
+        validation checks for Choice fields and saves lots of time. If
+        a string/value is out of allowed range we get a value or key
+        error anyway.
+        """
+        return super(ChoiceConverter, self).toString(value=value,
+                                                     strict=strict)
Index: /waeup/trunk/src/waeup/utils/converters.txt
===================================================================
--- /waeup/trunk/src/waeup/utils/converters.txt	(revision 4858)
+++ /waeup/trunk/src/waeup/utils/converters.txt	(revision 4858)
@@ -0,0 +1,738 @@
+:mod:`waeup.utils.converters` -- Converters
+*******************************************
+
+.. module:: waeup.utils.converters
+
+Converters for :mod:`zope.schema` based data.
+
+.. contents::
+
+
+:Test-Layer: unit
+
+As converters are registered as adapters, we have to grok the package
+first:
+
+    >>> import grok
+    >>> grok.testing.grok('waeup')
+
+Constants
+=========
+
+.. attribute:: NONE_STRING_VALUE
+
+   This value determines, which string represents
+   'non-values'. Current setting is:
+
+      >>> from waeup.utils.converters import NONE_STRING_VALUE
+      >>> NONE_STRING_VALUE
+      ''
+
+   If this value is found during conversion from strings, the value to
+   set will become ``None``.
+
+
+Adapters
+========
+
+The :mod:`waeup.utils.converters` module is basically a collection of
+adapters for field types of :mod:`zope.schema`.
+
+:class:`Converter`
+------------------
+
+.. class:: Converter(field)
+
+   Base class for converter classes. You cannot create working
+   instances of this class. Instead derive from it and implement the
+   missing classes.
+
+   `field` should be an object implementing some interface from
+   :mod:`zope.schema` like :class:`zope.schema.TextLine`,
+   `zope.schema.Choice` or similar.
+
+   .. method:: provides(waeup.interfaces.ISchemaTypeConverter)
+
+   .. method:: _convertValueFromString(string)
+
+       Raises :exc:`NotImplementedError`.
+
+       Called by :meth:`fromString()` to convert a given string to a
+       value appropriate for the applied field type. The string might
+       be ``None`` or ``missing_value`` from field definition.
+
+       Override this method in derived classes to get a working
+       converter.
+
+   .. method:: _convertValueToString(value)
+
+       Raises :exc:`NotImplementedError`.
+
+       Called by :meth:`toString()` to convert a given value to a
+       string suitable to be stored for instance in CSV files.
+
+       Override this method in derived classes to get a working
+       converter.
+
+   .. method:: fromString(string[, strict=True])
+
+       Compute a value for the context field from the given
+       `string`. Be aware that this might fail for several reasons,
+       for instance because the string cannot be converted to an
+       integer (if the applied field is an Integer field), etc.
+
+       Use `strict` to enable/disable validations of transformed
+       data.
+
+   .. method:: toString(value[, strict=True])
+
+       Generate a string from ``value``, suitable to be stored in CSV
+       files or similar. This method does preliminary checks, handles
+       defaults and ``missing_value`` stuff and then calls
+       :meth:`_convertValueToString(value)` to do the actual
+       conversion.
+
+       Use `strict` to enable/disable validations of transformed
+       data.
+       
+
+
+:class:`TextConverter`
+----------------------
+
+.. class:: TextConverter(field)
+
+   Create a converter for text fields.
+
+   `field` must be an object implementing
+   ``zope.schema.interfaces.IText``. This is true for instance for
+   :class:`zope.schema.TextLine` fields and most other, purely
+   text-related field types.
+
+   Instances of this class are available as adapters from :mod:`IText`
+   to :mod:`ISchemaTypeConverter`.
+
+   .. method:: adapts(zope.schema.IText)
+
+   .. method:: provides(waeup.interfaces.ISchemaTypeConverter)
+
+   .. method:: fromString(string[, strict=True])
+
+       Compute a unicode string value from the given `string`. Be
+       aware that this might fail if no conversion of the delivered
+       data is possible.
+
+       Use `strict` to enable/disable validations of transformed
+       data.
+
+   .. method:: toString(value[, strict=True])
+
+       Return `value` as string or ``None``.
+
+       If `value` is ``None`` (or field's `missing_value`), ``None``
+       is returned.
+
+       If the value is not valid for the applied field an exception
+       might be raised.
+
+       Use `strict` to enable/disable validations of input data.
+
+
+:class:`BoolConverter`
+----------------------
+
+.. class:: BoolConverter(field)
+
+   Create a converter for boolean fields.
+
+   `field` must be an object implementing
+   ``zope.schema.interfaces.IBool``.
+
+   Instances of this class are available as adapters from :mod:`IBool`
+   to :mod:`ISchemaTypeConverter`.
+
+   .. method:: adapts(zope.schema.IBool)
+
+   .. method:: provides(waeup.interfaces.ISchemaTypeConverter)
+
+   .. method:: fromString(string[, strict=True])
+
+       Compute an integer number value from the given `string`. In
+       general '1', 'true', and 'yes' are interpreted as ``True``,
+       ``None`` is interpreted as 'no value' and all other values are
+       interpreted as ``True``.
+
+       Use `strict` to enable/disable validations of transformed
+       data.
+
+   .. method:: toString(value[, strict=True])
+
+       Return `value` as string or ``None``.
+
+       If `value` is ``None`` (or field's `missing_value`), ``None``
+       is returned. Otherwise ``0`` or ``1`` are returned.
+
+       If the value is not valid for the applied field (i.e. not an
+       integer or `missing_value`) an exception
+       might be raised.
+
+       Use `strict` to enable/disable validations of input data.
+
+
+:class:`IntConverter`
+---------------------
+
+.. class:: IntConverter(field)
+
+   Create a converter for integer fields.
+
+   `field` must be an object implementing
+   ``zope.schema.interfaces.IInt``.
+
+   Instances of this class are available as adapters from :mod:`IInt`
+   to :mod:`ISchemaTypeConverter`.
+
+   .. method:: adapts(zope.schema.IInt)
+
+   .. method:: provides(waeup.interfaces.ISchemaTypeConverter)
+
+   .. method:: fromString(string[, strict=True])
+
+       Compute an integer number value from the given `string`. Be
+       aware that this might fail if no conversion of the delivered
+       data is possible.
+
+       Use `strict` to enable/disable validations of transformed
+       data.
+
+   .. method:: toString(value[, strict=True])
+
+       Return `value` as string or ``None``.
+
+       If `value` is ``None`` (or field's `missing_value`), ``None``
+       is returned.
+
+       If the value is not valid for the applied field (i.e. not an
+       integer or `missing_value`) an exception
+       might be raised.
+
+       Use `strict` to enable/disable validations of input data.
+
+
+
+:class:`ChoiceConverter`
+------------------------
+
+.. class:: ChoiceConverter(field)
+
+   Create a converter for choice fields.
+
+   `field` must be an object implementing
+   ``zope.schema.interfaces.IChoice``.
+
+   Instances of this class are available as adapters from :mod:`IChoice`
+   to :mod:`ISchemaTypeConverter`.
+
+   .. method:: adapts(zope.schema.IChoice)
+
+   .. method:: provides(waeup.interfaces.ISchemaTypeConverter)
+
+   .. method:: fromString(string[, strict=False])
+
+       Compute a value from the given `string`. Be aware that this
+       might fail if no conversion of the delivered data is possible.
+
+       As choices generally require a certain set of allowed values,
+       only these values be allowed, in their tokenized form. The
+       converter takes the given string as token and then tries to
+       generate the appropriate value out of it (see examples below).
+
+       Use `strict` to enable/disable validations of transformed
+       data.
+
+       As an invalid value will result in a `KeyError`, additional
+       validation checks are disabled with this converter. This speeds
+       up things as expensive lookups are avoided.
+
+   .. method:: toString(value[, strict=False])
+
+       Return `value` as string or ``None``.
+
+       If `value` is ``None`` (or field's `missing_value`), ``None``
+       is returned.
+
+       If the value is not valid for the applied field (i.e. not an
+       integer or `missing_value`) an exception
+       might be raised.
+
+       Use `strict` to enable/disable validations of input data.
+
+       As an invalid value will result in a `ValueError`, additional
+       validation checks are disabled with this converter. This speeds
+       up things as expensive lookups are avoided.
+
+
+Examples
+========
+
+Text field converters
+---------------------
+
+We create a field that also implements ``IText``, a regular
+``TextLine`` field:
+
+    >>> from zope.schema import TextLine
+    >>> field = TextLine(title=u'Some Title',
+    ...                  default=u'Default Value',
+    ...                  required=True)
+
+Now we can get a converter for this field by explicitly creating a
+:class:`TextConverter` instance:
+
+    >>> from waeup.utils.converters import TextConverter
+    >>> converter = TextConverter(field)
+
+Or we can just grab a registered adapter:
+
+    >>> from waeup.interfaces import ISchemaTypeConverter
+    >>> converter = ISchemaTypeConverter(field)
+
+This will select the correct converter for us automatically.
+
+    >>> converter
+    <waeup.utils.converters.TextConverter object at 0x...>
+
+Now we can convert strings to this type:
+
+    >>> converter.fromString('blah')
+    u'blah'
+
+And back:
+
+    >>> converter.toString(u'blah')
+    'blah'
+
+Okay, not very surprising. But the field definitions can help also
+deliver values, if the given value is missing:
+
+    >>> converter.fromString(None)
+    u'Default Value'
+
+``None`` is not an acceptable value for fields which are required but
+provide no default:
+
+    >>> field = TextLine(title=u'Some Title',
+    ...                  required=True)
+
+    >>> converter = ISchemaTypeConverter(field)
+    >>> converter.fromString(None)
+    Traceback (most recent call last):
+    ...
+    RequiredMissing
+
+If we want to avoid this type of exception (and risk non-applicable
+data to be stored), we can use the ``strict`` parameter of
+``fromString()``:
+
+    >>> converter.fromString(None, strict=False) is None
+    True 
+
+
+If a field is not required, we will get the ``missing_value`` type in
+same case:
+
+    >>> field = TextLine(title=u'Some Title',
+    ...                  required=False)
+
+    >>> converter = ISchemaTypeConverter(field)
+    >>> converter.fromString(None) is None
+    True
+
+We can also set a value that indicates missing data (``None`` by default):
+
+    >>> field = TextLine(title=u'Some Title',
+    ...                  required=False,
+    ...                  missing_value=u'<NO-VALUE>')
+
+    >>> converter = ISchemaTypeConverter(field)
+    >>> converter.fromString(None)
+    u'<NO-VALUE>'
+
+And put it back:
+
+    >>> converter.toString(u'<NO-VALUE>') is None
+    True
+
+Bool field converters
+---------------------
+
+We create a field that also implements ``IBool``, a regular
+``Bool`` field:
+
+    >>> from zope.schema import Bool
+    >>> field = Bool(title=u'Some truth',
+    ...             default=True,
+    ...             required=True)
+
+Now we can get a converter for this field by explicitly creating a
+:class:`BoolConverter` instance:
+
+    >>> from waeup.utils.converters import BoolConverter
+    >>> converter = BoolConverter(field)
+
+Or we can just grab a registered adapter:
+
+    >>> from waeup.interfaces import ISchemaTypeConverter
+    >>> converter = ISchemaTypeConverter(field)
+
+This will select the correct converter for us automatically.
+
+    >>> converter
+    <waeup.utils.converters.BoolConverter object at 0x...>
+
+Now we can convert strings to this type:
+
+    >>> converter.fromString('yes')
+    True
+
+    >>> converter.fromString('Yes')
+    True
+
+    >>> converter.fromString('yEs')
+    True
+
+    >>> converter.fromString('1')
+    True
+
+    >>> converter.fromString('True')
+    True
+
+    >>> converter.fromString('0')
+    False
+
+    >>> converter.fromString('no')
+    False
+
+    >>> converter.fromString('false')
+    False
+
+
+And back:
+
+    >>> converter.toString(True)
+    '1'
+
+    >>> converter.toString(False)
+    '0'
+
+Okay, not very surprising. But the field definitions can help also
+deliver values, if the given value is missing:
+
+    >>> converter.fromString(None)
+    True
+
+``None`` is not an acceptable value for fields which are required but
+provide no default:
+
+    >>> field = Bool(title=u'Some Truth',
+    ...             required=True)
+
+    >>> converter = ISchemaTypeConverter(field)
+    >>> converter.fromString(None)
+    Traceback (most recent call last):
+    ...
+    RequiredMissing
+
+If we want to avoid this type of exception (and risk non-applicable
+data to be stored), we can use the ``strict`` parameter of
+``fromString()``:
+
+    >>> converter.fromString(None, strict=False) is None
+    True
+
+The same for the inverse operation:
+
+    >>> converter.toString(None)
+    Traceback (most recent call last):
+    ...
+    RequiredMissing
+
+    >>> converter.toString(None, strict=False) is None
+    True
+
+
+If a field is not required, we will get the ``missing_value`` type in
+same case:
+
+    >>> field = Bool(title=u'Some Title',
+    ...             required=False)
+
+    >>> converter = ISchemaTypeConverter(field)
+    >>> converter.fromString(None) is None
+    True
+
+    >>> converter.toString(None) is None
+    True
+
+
+Int field converters
+--------------------
+
+We create a field that also implements ``IInt``, a regular
+``Int`` field:
+
+    >>> from zope.schema import Int
+    >>> field = Int(title=u'Some number',
+    ...             default=12,
+    ...             required=True)
+
+Now we can get a converter for this field by explicitly creating a
+:class:`IntConverter` instance:
+
+    >>> from waeup.utils.converters import IntConverter
+    >>> converter = IntConverter(field)
+
+Or we can just grab a registered adapter:
+
+    >>> from waeup.interfaces import ISchemaTypeConverter
+    >>> converter = ISchemaTypeConverter(field)
+
+This will select the correct converter for us automatically.
+
+    >>> converter
+    <waeup.utils.converters.IntConverter object at 0x...>
+
+Now we can convert strings to this type:
+
+    >>> converter.fromString('666')
+    666
+
+And back:
+
+    >>> converter.toString(666)
+    '666'
+
+Okay, not very surprising. But the field definitions can help also
+deliver values, if the given value is missing:
+
+    >>> converter.fromString(None)
+    12
+
+``None`` is not an acceptable value for fields which are required but
+provide no default:
+
+    >>> field = Int(title=u'Some Title',
+    ...             required=True)
+
+    >>> converter = ISchemaTypeConverter(field)
+    >>> converter.fromString(None)
+    Traceback (most recent call last):
+    ...
+    RequiredMissing
+
+If we want to avoid this type of exception (and risk non-applicable
+data to be stored), we can use the ``strict`` parameter of
+``fromString()``:
+
+    >>> converter.fromString(None, strict=False) is None
+    True
+
+The same for the inverse operation:
+
+    >>> converter.toString(None)
+    Traceback (most recent call last):
+    ...
+    RequiredMissing
+
+    >>> converter.toString(None, strict=False) is None
+    True
+
+
+If a field is not required, we will get the ``missing_value`` type in
+same case:
+
+    >>> field = Int(title=u'Some Title',
+    ...             required=False)
+
+    >>> converter = ISchemaTypeConverter(field)
+    >>> converter.fromString(None) is None
+    True
+
+    >>> converter.toString(None) is None
+    True
+
+
+Choice field converters
+-----------------------
+
+Before choices really work in unit tests, we have to make sure, that
+the following adapters are registered (this is normally done during
+startup automatically):
+
+    >>> from zc.sourcefactory.browser.source import FactoredTerms
+    >>> from zc.sourcefactory.browser.token import (
+    ...   fromString, fromUnicode, fromInteger, fromPersistent)
+    >>> from zope.component import getGlobalSiteManager
+    >>> gsm = getGlobalSiteManager()
+    >>> gsm.registerAdapter(FactoredTerms)
+    >>> gsm.registerAdapter(fromString)
+    >>> gsm.registerAdapter(fromUnicode)
+    >>> gsm.registerAdapter(fromInteger)
+    >>> gsm.registerAdapter(fromPersistent)
+
+We create a field that also implements ``IChoice``, a regular
+``Choice`` field:
+
+    >>> from zope.schema import Choice
+    >>> field = Choice(title=u'Some number',
+    ...                default=u'a',
+    ...                values=['a', 'b', 'c'],
+    ...                required=True)
+
+Now we can get a converter for this field by explicitly creating a
+:class:`ChoiceConverter` instance:
+
+    >>> from waeup.utils.converters import ChoiceConverter
+    >>> converter = ChoiceConverter(field)
+
+Or we can just grab a registered adapter:
+
+    >>> from waeup.interfaces import ISchemaTypeConverter
+    >>> converter = ISchemaTypeConverter(field)
+
+This will select the correct converter for us automatically.
+
+    >>> converter
+    <waeup.utils.converters.ChoiceConverter object at 0x...>
+
+Now we can convert strings to this type:
+
+    >>> converter.fromString('a')
+    'a'
+
+or values back to strings:
+
+    >>> converter.toString('a')
+    'a'
+
+Non-listed values will not be accepted:
+
+    >>> converter.fromString('nonsense')
+    Traceback (most recent call last):
+    ...
+    LookupError: nonsense
+
+Also non-string values will work:
+
+    >>> from zope.schema import Choice
+    >>> field = Choice(title=u'Some number',
+    ...                default=2,
+    ...                values=[1, 2, 3],
+    ...                required=True)
+
+    >>> converter = ISchemaTypeConverter(field)
+    >>> converter.fromString('1')
+    1
+
+If we define an own source, then it will cause no problems:
+
+    >>> from zc.sourcefactory.basic import BasicSourceFactory
+    >>> class CustomSource(BasicSourceFactory):
+    ...   def getValues(self):
+    ...     return [1,2,3]
+    ...    
+    ...   def getTitle(self, value):
+    ...     return "Number %s" % (value,)
+
+    >>> field = Choice(title=u'Some number',
+    ...                default=2,
+    ...                source=CustomSource(),
+    ...                required=True)
+
+    >>> converter = ISchemaTypeConverter(field)
+    >>> converter.fromString('1')
+    1
+
+Note, that when asking for conversion of Choice fields, you must
+deliver the token value of the source/vocabulary. This might be the
+same as the actual value as string, but it it might be different,
+depending of the kind of source/vocabulary used.
+
+Furthermore, when writing 'exporters', i.e. components that turn a
+value into a string for usage in CSV files, then you have to save the
+token value of a term.
+
+Using the ;meth:`toString()` will deliver the token for us:
+
+    >>> converter.toString(1)
+    '1'
+
+    >>> from waeup.interfaces import ICourse
+    >>> from zope.schema import getFields
+    >>> field = getFields(ICourse)['semester']
+
+    >>> converter = ISchemaTypeConverter(field)
+    >>> converter.fromString('0')
+    0
+
+    >>> converter.toString(1)
+    '1'
+
+A more complex (but still realistic example) for conversion of
+:mode:`zope.schema.Choice` fields follows. Here we have a special kind
+of object, the `Cave` class, and get their `name` attribute as token.
+
+    >>> class Cave(object):
+    ...   name = 'Home sweet home'
+
+    >>> def tokenizer(cave):
+    ...   return cave.name
+
+The tokenizer has to be registered as an Cave-to-IToken adapter to
+keep :mod:`zc.sourcefactory` happy:
+
+    >>> from zc.sourcefactory.interfaces import IToken
+    >>> from zope.component import getGlobalSiteManager
+    >>> gsm = getGlobalSiteManager()
+    >>> gsm.registerAdapter(tokenizer, required=(Cave,), provided=IToken)
+
+Now we finally can create two objects of `Cave` and make a field, that
+gives the choice between these two objects:
+
+    >>> obj1 = Cave()
+    >>> obj1.name = 'Wilma'
+
+    >>> obj2 = Cave()
+    >>> obj2.name = 'Fred'
+
+    >>> from zc.sourcefactory.basic import BasicSourceFactory
+    >>> class CustomSource(BasicSourceFactory):
+    ...   def getValues(self):
+    ...     return [obj1, obj2]
+    ...    
+    ...   def getTitle(self, value):
+    ...     return value.name
+
+    >>> field = Choice(title=u'Some cave',
+    ...                default=obj1,
+    ...                source=CustomSource(),
+    ...                required=True)
+
+We get a converter for this field in the usual way. The
+:meth:`waeup.utils.converters.fromString()` method will return one of
+the objects if we feed it with ``'Wilma'`` or ``'Fred'``:
+
+    >>> converter = ISchemaTypeConverter(field)
+    >>> result = converter.fromString('Wilma')
+    >>> result
+    <Cave object at 0x...>
+
+    >>> result is obj1, result.name
+    (True, 'Wilma')
+
+If we use the converter the other way round, it will call the
+tokenizer above and deliver ``'Wilma'`` or ``'Fred'`` as they are the
+tokens of the objects in question:
+
+    >>> converter.toString(obj2)
+    'Fred'
