Index: main/waeup.kofa/branches/henrik-regista/CHANGES.txt
===================================================================
--- main/waeup.kofa/branches/henrik-regista/CHANGES.txt	(revision 11957)
+++ main/waeup.kofa/branches/henrik-regista/CHANGES.txt	(revision 11958)
@@ -5,8 +5,10 @@
 ===================
 
+* Add components for customer management.
+
+* Rename 'kofa' 'ikoba', 'university' 'company' and 'student' 'customer'.
+
 * Enucleation: Keep only the portal's framework.
   Remove university, students, applicants, hostels and accesscodes modules.
-
-* Rename 'kofa' 'ikoba', 'university' 'company' and 'student' 'customer'.
 
 0.1 (never released)
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/browser/interfaces.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/browser/interfaces.py	(revision 11957)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/browser/interfaces.py	(revision 11958)
@@ -184,2 +184,9 @@
         constraint=validate_email,
         )
+
+class ICustomerNavigationBase(IIkobaObject):
+    """Objects that provide customer navigation should
+       implement this interface.
+    """
+    customer = Attribute('''Some customer object that has a '''
+                        ''' `display_fullname` attribute.''')
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/browser/layout.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/browser/layout.py	(revision 11957)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/browser/layout.py	(revision 11958)
@@ -36,4 +36,5 @@
 from waeup.ikoba.utils.helpers import to_timezone
 from waeup.ikoba.authentication import get_principal_role_manager
+from waeup.ikoba.browser.interfaces import ICustomerNavigationBase
 
 grok.templatedir('templates')
@@ -258,4 +259,10 @@
         return self.request.principal.user_type == 'customer'
 
+    def getCustomerName(self):
+        """Return the customer name.
+        """
+        if ICustomerNavigationBase.providedBy(self.context):
+            return self.context.customer.display_fullname
+
     def formatDatetime(self,datetimeobj):
         if isinstance(datetimeobj, datetime):
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/authentication.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/authentication.py	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/authentication.py	(revision 11958)
@@ -0,0 +1,304 @@
+## $Id: authentication.py 10055 2013-04-04 15:12:43Z uli $
+## 
+## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+## 
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+## 
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+"""
+Authenticate customers.
+"""
+import grok
+import time
+from zope.component import getUtility
+from zope.password.interfaces import IPasswordManager
+from zope.pluggableauth.interfaces import (
+    IAuthenticatorPlugin, ICredentialsPlugin)
+from zope.pluggableauth.plugins.session import (
+    SessionCredentialsPlugin, SessionCredentials)
+from zope.publisher.interfaces.http import IHTTPRequest
+from zope.session.interfaces import ISession
+from waeup.ikoba.authentication import (
+    IkobaPrincipalInfo, get_principal_role_manager, FailedLoginInfo)
+from waeup.ikoba.interfaces import (
+    IAuthPluginUtility, IUserAccount, IPasswordValidator)
+from waeup.ikoba.customers.interfaces import ICustomer
+
+class CustomerAccount(grok.Adapter):
+    """An adapter to turn customer objects into accounts on-the-fly.
+    """
+    grok.context(ICustomer)
+    grok.implements(IUserAccount)
+
+    public_name = None
+
+    @property
+    def name(self):
+        return self.context.customer_id
+
+    @property
+    def password(self):
+        return getattr(self.context, 'password', None)
+
+    @property
+    def title(self):
+        return self.context.display_fullname
+
+    @property
+    def email(self):
+        return self.context.email
+
+    @property
+    def phone(self):
+        return self.context.phone
+
+    @property
+    def user_type(self):
+        return u'customer'
+
+    @property
+    def description(self):
+        return self.title
+
+    @property
+    def failed_logins(self):
+        if not hasattr(self.context, 'failed_logins'):
+            self.context.failed_logins = FailedLoginInfo()
+        return self.context.failed_logins
+
+    def _get_roles(self):
+        prm = get_principal_role_manager()
+        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
+                 if x[0].startswith('waeup.')]
+        return roles
+
+    def _set_roles(self, roles):
+        """Set roles for principal denoted by this account.
+        """
+        prm = get_principal_role_manager()
+        old_roles = self.roles
+        for role in old_roles:
+            # Remove old roles, not to be set now...
+            if role.startswith('waeup.') and role not in roles:
+                prm.unsetRoleForPrincipal(role, self.name)
+        for role in roles:
+            prm.assignRoleToPrincipal(role, self.name)
+        return
+
+    roles = property(_get_roles, _set_roles)
+
+    def setPassword(self, password):
+        """Set a password (LDAP-compatible) SSHA encoded.
+
+        We do not store passwords in plaintext. Encrypted password is
+        stored as unicode string.
+        """
+        passwordmanager = getUtility(IPasswordManager, 'SSHA')
+        self.context.password = passwordmanager.encodePassword(password)
+
+    def checkPassword(self, password):
+        """Check whether the given `password` matches the one stored by
+        customers or the temporary password set by officers.
+
+        We additionally check if customer account has been suspended.
+        """
+        if not isinstance(password, basestring):
+            return False
+        passwordmanager = getUtility(IPasswordManager, 'SSHA')
+        temp_password = self.context.getTempPassword()
+        if temp_password:
+            return passwordmanager.checkPassword(temp_password, password)
+        if not getattr(self.context, 'password', None):
+            # unset/empty passwords do never match
+            return False
+        if self.context.suspended == True:
+            return False
+        return passwordmanager.checkPassword(self.context.password, password)
+
+class CustomersAuthenticatorPlugin(grok.GlobalUtility):
+    grok.implements(IAuthenticatorPlugin)
+    grok.provides(IAuthenticatorPlugin)
+    grok.name('customers')
+
+    def authenticateCredentials(self, credentials):
+        """Authenticate `credentials`.
+
+        `credentials` is a tuple (login, password).
+
+        We look up customers to find out whether a respective customer
+        exists, then check the password and return the resulting
+        `PrincipalInfo` or ``None`` if no such customer can be found.
+        """
+        if not isinstance(credentials, dict):
+            return None
+        if not ('login' in credentials and 'password' in credentials):
+            return None
+        account = self.getAccount(credentials['login'])
+        if account is None:
+            return None
+        if not account.checkPassword(credentials['password']):
+            return None
+        return IkobaPrincipalInfo(id=account.name,
+                             title=account.title,
+                             description=account.description,
+                             email=account.email,
+                             phone=account.phone,
+                             public_name=account.public_name,
+                             user_type=account.user_type)
+
+    def principalInfo(self, id):
+        """Get a principal identified by `id`.
+
+        This one is required by IAuthenticatorPlugin but not needed here
+        (see respective docstring in applicants package).
+        """
+        return None
+
+    def getAccount(self, login):
+        """Look up a customer identified by `login`. Returns an account.
+
+        Currently, we simply look up the key under which the customer
+        is stored in the portal. That means we hit if login name and
+        name under which the customer is stored match.
+
+        Returns not a customer but an account object adapted from any
+        customer found.
+
+        If no such customer exists, ``None`` is returned.
+        """
+        site = grok.getSite()
+        if site is None:
+            return None
+        customerscontainer = site.get('customers', None)
+        if customerscontainer is None:
+            return None
+        customer = customerscontainer.get(login, None)
+        if customer is None:
+            return None
+        return IUserAccount(customer)
+
+class PasswordChangeCredentialsPlugin(grok.GlobalUtility,
+                                      SessionCredentialsPlugin):
+    """A session credentials plugin that handles the case of a user
+    changing his/her own password.
+
+    When users change their own password they might find themselves
+    logged out on next request.
+
+    To avoid this, we support to use a 'change password' page a bit
+    like a regular login page. That means, on each request we lookup
+    the sent data for a login field called 'customer_id' and a
+    password.
+
+    If both exist, this means someone sent new credentials.
+
+    We then look for the old credentials stored in the user session.
+    If the new credentials' login (the customer_id) matches the old
+    one's, we set the new credentials in session _but_ we return the
+    old credentials for the authentication plugins to check as for the
+    current request (and for the last time) the old credentials apply.
+
+    No valid credentials are returned by this plugin if one of the
+    follwing circumstances is true
+
+    - the sent request is not a regular IHTTPRequest
+
+    - the credentials to set do not match the old ones
+
+    - no credentials are sent with the request
+
+    - no credentials were set before (i.e. the user has no session
+      with credentials set before)
+
+    - no session exists already
+
+    - password and repeated password do not match
+
+    Therefore it is mandatory to put this plugin in the line of all
+    credentials plugins _before_ other plugins, so that the regular
+    credentials plugins can drop in as a 'fallback'.
+
+    This plugin was designed for customers to change their passwords,
+    but might be used to allow password resets for other types of
+    accounts as well.
+    """
+    grok.provides(ICredentialsPlugin)
+    grok.name('customer_pw_change')
+
+    loginpagename = 'login'
+    loginfield = 'customer_id'
+    passwordfield = 'change_password'
+    repeatfield = 'change_password_repeat'
+
+    def extractCredentials(self, request):
+        if not IHTTPRequest.providedBy(request):
+            return None
+        login = request.get(self.loginfield, None)
+        password = request.get(self.passwordfield, None)
+        password_repeat = request.get(self.repeatfield, None)
+
+        if not login or not password:
+            return None
+
+        validator = getUtility(IPasswordValidator)
+        errors = validator.validate_password(password, password_repeat)
+        if errors:
+            return None
+
+        session = ISession(request)
+        sessionData = session.get(
+            'zope.pluggableauth.browserplugins')
+        if not sessionData:
+            return None
+
+        old_credentials = sessionData.get('credentials', None)
+        if old_credentials is None:
+            # Password changes for already authenticated users only!
+            return None
+        if old_credentials.getLogin() != login:
+            # Special treatment only for users that change their own pw.
+            return None
+        old_credentials = {
+            'login': old_credentials.getLogin(),
+            'password': old_credentials.getPassword()}
+
+        # Set new credentials in session. These will be active on next request
+        new_credentials = SessionCredentials(login, password)
+        sessionData['credentials'] = new_credentials
+
+        # Return old credentials for this one request only
+        return old_credentials
+
+class CustomersAuthenticatorSetup(grok.GlobalUtility):
+    """Register or unregister customer authentication for a PAU.
+
+    This piece is called when a new site is created.
+    """
+    grok.implements(IAuthPluginUtility)
+    grok.name('customers_auth_setup')
+
+    def register(self, pau):
+        plugins = list(pau.credentialsPlugins)
+        # this plugin must come before the regular credentials plugins
+        plugins.insert(0, 'customer_pw_change')
+        pau.credentialsPlugins = tuple(plugins)
+        plugins = list(pau.authenticatorPlugins)
+        plugins.append('customers')
+        pau.authenticatorPlugins = tuple(plugins)
+        return pau
+
+    def unregister(self, pau):
+        plugins = [x for x in pau.authenticatorPlugins
+                   if x != 'customers']
+        pau.authenticatorPlugins = tuple(plugins)
+        return pau
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/batching.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/batching.py	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/batching.py	(revision 11958)
@@ -0,0 +1,364 @@
+## $Id: batching.py 11891 2014-10-28 20:02:45Z henrik $
+##
+## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+"""Batch processing components for customer objects.
+
+Batch processors eat CSV files to add, update or remove large numbers
+of certain kinds of objects at once.
+
+Here we define the processors for customers specific objects like
+customers and subobjects.
+"""
+import grok
+import unicodecsv as csv # XXX: csv ops should move to dedicated module.
+from time import time
+from datetime import datetime
+from zope.i18n import translate
+from zope.interface import Interface
+from zope.schema import getFields
+from zope.component import queryUtility, getUtility, createObject
+from zope.event import notify
+from zope.catalog.interfaces import ICatalog
+from hurry.workflow.interfaces import IWorkflowState, IWorkflowInfo
+from waeup.ikoba.interfaces import (
+    IBatchProcessor, FatalCSVError, IObjectConverter, IUserAccount,
+    IObjectHistory, VALIDATED, REGISTERED, IGNORE_MARKER)
+from waeup.ikoba.interfaces import IIkobaUtils
+from waeup.ikoba.interfaces import MessageFactory as _
+from waeup.ikoba.customers.interfaces import (
+    ICustomer, ICustomerUpdateByRegNo)
+from waeup.ikoba.customers.workflow import  (
+    IMPORTABLE_STATES, IMPORTABLE_TRANSITIONS)
+from waeup.ikoba.utils.batching import BatchProcessor
+
+class CustomerProcessor(BatchProcessor):
+    """A batch processor for ICustomer objects.
+    """
+    grok.implements(IBatchProcessor)
+    grok.provides(IBatchProcessor)
+    grok.context(Interface)
+    util_name = 'customerprocessor'
+    grok.name(util_name)
+
+    name = _('Customer Processor')
+    iface = ICustomer
+    iface_byregnumber = ICustomerUpdateByRegNo
+
+    location_fields = []
+    factory_name = 'waeup.Customer'
+
+    @property
+    def available_fields(self):
+        fields = getFields(self.iface)
+        return sorted(list(set(
+            ['customer_id','reg_number',
+            'password', 'state', 'transition'] + fields.keys())))
+
+    def checkHeaders(self, headerfields, mode='create'):
+        if 'state' in headerfields and 'transition' in headerfields:
+            raise FatalCSVError(
+                "State and transition can't be  imported at the same time!")
+        if not 'reg_number' in headerfields and not 'customer_id' \
+            in headerfields:
+            raise FatalCSVError(
+                "Need at least columns customer_id or reg_number " +
+                "for import!")
+        if mode == 'create':
+            for field in self.required_fields:
+                if not field in headerfields:
+                    raise FatalCSVError(
+                        "Need at least columns %s for import!" %
+                        ', '.join(["'%s'" % x for x in self.required_fields]))
+        # Check for fields to be ignored...
+        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 parentsExist(self, row, site):
+        return 'customers' in site.keys()
+
+    def getLocator(self, row):
+        if row.get('customer_id',None) not in (None, IGNORE_MARKER):
+            return 'customer_id'
+        elif row.get('reg_number',None) not in (None, IGNORE_MARKER):
+            return 'reg_number'
+        else:
+            return None
+
+    # The entry never exists in create mode.
+    def entryExists(self, row, site):
+        return self.getEntry(row, site) is not None
+
+    def getParent(self, row, site):
+        return site['customers']
+
+    def getEntry(self, row, site):
+        if not 'customers' in site.keys():
+            return None
+        if self.getLocator(row) == 'customer_id':
+            if row['customer_id'] in site['customers']:
+                customer = site['customers'][row['customer_id']]
+                return customer
+        elif self.getLocator(row) == 'reg_number':
+            reg_number = row['reg_number']
+            cat = queryUtility(ICatalog, name='customers_catalog')
+            results = list(
+                cat.searchResults(reg_number=(reg_number, reg_number)))
+            if results:
+                return results[0]
+        return None
+
+    def addEntry(self, obj, row, site):
+        parent = self.getParent(row, site)
+        parent.addCustomer(obj)
+        # Reset _curr_cust_id if customer_id has been imported
+        if self.getLocator(row) == 'customer_id':
+            parent._curr_cust_id -= 1
+        # We have to log this if state is provided. If not,
+        # logging is done by the event handler handle_customer_added
+        if 'state' in row:
+            parent.logger.info('%s - Customer record created' % obj.customer_id)
+        history = IObjectHistory(obj)
+        history.addMessage(_('Customer record created'))
+        return
+
+    def delEntry(self, row, site):
+        customer = self.getEntry(row, site)
+        if customer is not None:
+            parent = self.getParent(row, site)
+            parent.logger.info('%s - Customer removed' % customer.customer_id)
+            del parent[customer.customer_id]
+        pass
+
+    def checkUpdateRequirements(self, obj, row, site):
+        """Checks requirements the object must fulfill when being updated.
+
+        This method is not used in case of deleting or adding objects.
+
+        Returns error messages as strings in case of requirement
+        problems.
+        """
+        transition = row.get('transition', IGNORE_MARKER)
+        if transition not in (IGNORE_MARKER, ''):
+            allowed_transitions = IWorkflowInfo(obj).getManualTransitionIds()
+            if transition not in allowed_transitions:
+                return 'Transition not allowed.'
+        return None
+
+    def updateEntry(self, obj, row, site, filename):
+        """Update obj to the values given in row.
+        """
+        items_changed = ''
+
+        # Remove customer_id from row if empty
+        if 'customer_id' in row and row['customer_id'] in (None, IGNORE_MARKER):
+            row.pop('customer_id')
+
+        # Update password
+        # XXX: Take DELETION_MARKER into consideration
+        if 'password' in row:
+            passwd = row.get('password', IGNORE_MARKER)
+            if passwd not in ('', IGNORE_MARKER):
+                if passwd.startswith('{SSHA}'):
+                    # already encrypted password
+                    obj.password = passwd
+                else:
+                    # not yet encrypted password
+                    IUserAccount(obj).setPassword(passwd)
+                items_changed += ('%s=%s, ' % ('password', passwd))
+            row.pop('password')
+
+        # Update registration state
+        if 'state' in row:
+            state = row.get('state', IGNORE_MARKER)
+            if state not in (IGNORE_MARKER, ''):
+                value = row['state']
+                IWorkflowState(obj).setState(value)
+                msg = _("State '${a}' set", mapping = {'a':value})
+                history = IObjectHistory(obj)
+                history.addMessage(msg)
+                items_changed += ('%s=%s, ' % ('state', state))
+            row.pop('state')
+
+        if 'transition' in row:
+            transition = row.get('transition', IGNORE_MARKER)
+            if transition not in (IGNORE_MARKER, ''):
+                value = row['transition']
+                IWorkflowInfo(obj).fireTransition(value)
+                items_changed += ('%s=%s, ' % ('transition', transition))
+            row.pop('transition')
+
+        # apply other values...
+        items_changed += super(CustomerProcessor, self).updateEntry(
+            obj, row, site, filename)
+
+        # Log actions...
+        parent = self.getParent(row, site)
+        if hasattr(obj,'customer_id'):
+            # Update mode: the customer exists and we can get the customer_id.
+            # Create mode: the record contains the customer_id
+            parent.logger.info(
+                '%s - %s - %s - updated: %s'
+                % (self.name, filename, obj.customer_id, items_changed))
+        else:
+            # Create mode: the customer does not yet exist
+            # XXX: It seems that this never happens because customer_id
+            # is always set.
+            parent.logger.info(
+                '%s - %s - %s - imported: %s' 
+                % (self.name, filename, obj.customer_id, items_changed))
+        return items_changed
+
+    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 ['customer_id', 'reg_number'] and mode == 'remove':
+                continue
+            if field == u'--IGNORE--':
+                # Skip ignored columns in failed and finished data files.
+                continue
+            result[raw_header[num]] = field
+        return result
+
+    def checkConversion(self, row, mode='create'):
+        """Validates all values in row.
+        """
+        iface = self.iface
+        if mode in ['update', 'remove']:
+            if self.getLocator(row) == 'reg_number':
+                iface = self.iface_byregnumber
+        converter = IObjectConverter(iface)
+        errs, inv_errs, conv_dict =  converter.fromStringDict(
+            row, self.factory_name, mode=mode)
+        if 'transition' in row:
+            if row['transition'] not in IMPORTABLE_TRANSITIONS:
+                if row['transition'] not in (IGNORE_MARKER, ''):
+                    errs.append(('transition','not allowed'))
+        if 'state' in row:
+            if row['state'] not in IMPORTABLE_STATES:
+                if row['state'] not in (IGNORE_MARKER, ''):
+                    errs.append(('state','not allowed'))
+                else:
+                    # State is an attribute of Customer and must not
+                    # be changed if empty.
+                    conv_dict['state'] = IGNORE_MARKER
+        try:
+            # Correct cust_id counter. As the IConverter for customers
+            # creates customer objects that are not used afterwards, we
+            # have to fix the site-wide customer_id counter.
+            site = grok.getSite()
+            customers = site['customers']
+            customers._curr_cust_id -= 1
+        except (KeyError, TypeError, AttributeError):
+                pass
+        return errs, inv_errs, conv_dict
+
+
+class CustomerProcessorBase(BatchProcessor):
+    """A base for customer subitem processor.
+
+    Helps reducing redundancy.
+    """
+    grok.baseclass()
+
+    # additional available fields
+    # beside 'customer_id', 'reg_number' and 'matric_number'
+    additional_fields = []
+
+    #: header fields additionally required
+    additional_headers = []
+
+    @property
+    def available_fields(self):
+        fields = ['customer_id','reg_number'] + self.additional_fields
+        return sorted(list(set(fields + getFields(
+                self.iface).keys())))
+
+    def checkHeaders(self, headerfields, mode='ignore'):
+        if not 'reg_number' in headerfields and not 'customer_id' \
+            in headerfields:
+            raise FatalCSVError(
+                "Need at least columns customer_id " +
+                "or reg_number for import!")
+        for name in self.additional_headers:
+            if not name in headerfields:
+                raise FatalCSVError(
+                    "Need %s for import!" % name)
+
+        # Check for fields to be ignored...
+        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 _getCustomer(self, row, site):
+        NON_VALUES = ['', IGNORE_MARKER]
+        if not 'customers' in site.keys():
+            return None
+        if row.get('customer_id', '') not in NON_VALUES:
+            if row['customer_id'] in site['customers']:
+                customer = site['customers'][row['customer_id']]
+                return customer
+        elif row.get('reg_number', '') not in NON_VALUES:
+            reg_number = row['reg_number']
+            cat = queryUtility(ICatalog, name='customers_catalog')
+            results = list(
+                cat.searchResults(reg_number=(reg_number, reg_number)))
+            if results:
+                return results[0]
+        return None
+
+    def parentsExist(self, row, site):
+        return self.getParent(row, site) is not None
+
+    def entryExists(self, row, site):
+        return self.getEntry(row, site) is not None
+
+    def checkConversion(self, row, mode='ignore'):
+        """Validates all values in row.
+        """
+        converter = IObjectConverter(self.iface)
+        errs, inv_errs, conv_dict =  converter.fromStringDict(
+            row, self.factory_name, mode=mode)
+        return errs, inv_errs, conv_dict
+
+    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 ['customer_id', 'reg_number', 'p_id', 'code', 'level'
+                             ] and mode == 'remove':
+                continue
+            if field == u'--IGNORE--':
+                # Skip ignored columns in failed and finished data files.
+                continue
+            result[raw_header[num]] = field
+        return result
+
+
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/browser.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/browser.py	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/browser.py	(revision 11958)
@@ -0,0 +1,142 @@
+## $Id: browser.py 11862 2014-10-21 07:07:04Z henrik $
+##
+## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+"""UI components for customers and related components.
+"""
+
+import grok
+from waeup.ikoba.interfaces import MessageFactory as _
+from waeup.ikoba.browser.layout import (
+    IkobaPage, IkobaEditFormPage, IkobaAddFormPage, IkobaDisplayFormPage,
+    IkobaForm, NullValidator)
+from waeup.ikoba.browser.breadcrumbs import Breadcrumb
+from waeup.ikoba.utils.helpers import get_current_principal, to_timezone, now
+from waeup.ikoba.customers.interfaces import (
+    ICustomersContainer
+    )
+from waeup.ikoba.customers.catalog import search
+
+class CustomersBreadcrumb(Breadcrumb):
+    """A breadcrumb for the customers container.
+    """
+    grok.context(ICustomersContainer)
+    title = _('Customers')
+
+    @property
+    def target(self):
+        user = get_current_principal()
+        if getattr(user, 'user_type', None) == 'customer':
+            return None
+        return self.viewname
+
+class CustomersContainerPage(IkobaPage):
+    """The standard view for customer containers.
+    """
+    grok.context(ICustomersContainer)
+    grok.name('index')
+    grok.require('waeup.viewCustomersContainer')
+    grok.template('containerpage')
+    label = _('Find customers')
+    search_button = _('Find customer(s)')
+    pnav = 4
+
+    def update(self, *args, **kw):
+        form = self.request.form
+        self.hitlist = []
+        if form.get('searchtype', None) == 'suspended':
+            self.searchtype = form['searchtype']
+            self.searchterm = None
+        elif 'searchterm' in form and form['searchterm']:
+            self.searchterm = form['searchterm']
+            self.searchtype = form['searchtype']
+        elif 'old_searchterm' in form:
+            self.searchterm = form['old_searchterm']
+            self.searchtype = form['old_searchtype']
+        else:
+            if 'search' in form:
+                self.flash(_('Empty search string'), type="warning")
+            return
+        if self.searchtype == 'current_session':
+            try:
+                self.searchterm = int(self.searchterm)
+            except ValueError:
+                self.flash(_('Only year dates allowed (e.g. 2011).'),
+                           type="danger")
+                return
+        self.hitlist = search(query=self.searchterm,
+            searchtype=self.searchtype, view=self)
+        if not self.hitlist:
+            self.flash(_('No customer found.'), type="warning")
+        return
+
+class CustomersContainerManagePage(IkobaPage):
+    """The manage page for customer containers.
+    """
+    grok.context(ICustomersContainer)
+    grok.name('manage')
+    grok.require('waeup.manageCustomer')
+    grok.template('containermanagepage')
+    pnav = 4
+    label = _('Manage customer section')
+    search_button = _('Find customer(s)')
+    remove_button = _('Remove selected')
+
+    def update(self, *args, **kw):
+        form = self.request.form
+        self.hitlist = []
+        if form.get('searchtype', None) == 'suspended':
+            self.searchtype = form['searchtype']
+            self.searchterm = None
+        elif 'searchterm' in form and form['searchterm']:
+            self.searchterm = form['searchterm']
+            self.searchtype = form['searchtype']
+        elif 'old_searchterm' in form:
+            self.searchterm = form['old_searchterm']
+            self.searchtype = form['old_searchtype']
+        else:
+            if 'search' in form:
+                self.flash(_('Empty search string'), type="warning")
+            return
+        if self.searchtype == 'current_session':
+            try:
+                self.searchterm = int(self.searchterm)
+            except ValueError:
+                self.flash(_('Only year dates allowed (e.g. 2011).'),
+                           type="danger")
+                return
+        if not 'entries' in form:
+            self.hitlist = search(query=self.searchterm,
+                searchtype=self.searchtype, view=self)
+            if not self.hitlist:
+                self.flash(_('No customer found.'), type="warning")
+            if 'remove' in form:
+                self.flash(_('No item selected.'), type="warning")
+            return
+        entries = form['entries']
+        if isinstance(entries, basestring):
+            entries = [entries]
+        deleted = []
+        for entry in entries:
+            if 'remove' in form:
+                del self.context[entry]
+                deleted.append(entry)
+        self.hitlist = search(query=self.searchterm,
+            searchtype=self.searchtype, view=self)
+        if len(deleted):
+            self.flash(_('Successfully removed: ${a}',
+                mapping = {'a':', '.join(deleted)}))
+        return
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/browser_templates/containermanagepage.pt
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/browser_templates/containermanagepage.pt	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/browser_templates/containermanagepage.pt	(revision 11958)
@@ -0,0 +1,66 @@
+<form method="POST" i18n:domain="waeup.kofa" class="form-inline">
+<br />
+  <div class="form-group">
+    <input class="btn btn-primary" type="submit" name="search"
+      tal:attributes="value view/search_button" />
+  </div>
+  <div class="form-group">
+    <select name="searchtype" class="form-control">
+      <option i18n:translate="" value="customer_id">with id</option>
+      <option i18n:translate="" value="fullname">with name</option>
+      <option i18n:translate="" value="reg_number">with registration number</option>
+      <option i18n:translate="" value="suspended">
+        with deactivated account (search term omitted)
+      </option>
+      <option i18n:translate="" value="transcript">
+        who requested transcript (search term omitted)
+      </option>
+    </select>
+  </div>
+  <div class="form-group">
+    <input type="text" class="form-control" name="searchterm" />
+  </div>
+
+  <p>&nbsp;</p>
+  <div tal:condition="view/hitlist">
+    <h3>Search Results</h3>
+    <input type="hidden" name="old_searchterm"
+         tal:attributes="value view/searchterm" />
+    <input type="hidden" name="old_searchtype"
+         tal:attributes="value view/searchtype" />
+
+    <br /><br />
+    <table class="kofa-data-table dataTableManage">
+      <thead>
+    	<tr>
+    	  <th>&nbsp;</th>
+        <th i18n:translate="">Student Id</th>
+        <th i18n:translate="">Reg. Number</th>
+        <th i18n:translate="">State</th>
+        <th i18n:translate="">Full Name</th>
+    	</tr>
+      </thead>
+      <tbody>
+    	<tr tal:repeat="item view/hitlist">
+    	  <td><input type="checkbox" name="entries"
+    	             tal:attributes="value item/context/__name__" /></td>
+    	  <td> <a tal:attributes="href item/url">
+          <span tal:content="item/customer_id">A123456</span></a>
+        </td>
+        <td class="center"> <a tal:attributes="href item/url">
+          <span tal:content="item/reg_number">9999999</span></a>
+        </td>
+        <td tal:content="item/state">admitted</td>
+    	  <td tal:content="item/display_fullname">Bob</td>
+    	</tr>
+      </tbody>
+    </table>
+    <input type="checkbox" onClick="toggle(this, 'entries')" />
+    <span  i18n:translate="">Select all</span>
+    <br /><br />
+    <input type="submit" name="remove"
+           tal:attributes="value view/remove_button" class="btn btn-default"
+           onclick="return confirmPost('Are you sure?')"/>
+
+  </div>
+</form>
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/browser_templates/containerpage.pt
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/browser_templates/containerpage.pt	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/browser_templates/containerpage.pt	(revision 11958)
@@ -0,0 +1,54 @@
+<form method="POST" i18n:domain="waeup.kofa" class="form-inline">
+<br />
+  <div class="form-group">
+    <input class="btn btn-primary" type="submit" name="search"
+      tal:attributes="value view/search_button" />
+  </div>
+  <div class="form-group">
+    <select name="searchtype" class="form-control">
+      <option i18n:translate="" value="customer_id">with id</option>
+      <option i18n:translate="" value="fullname">with name</option>
+      <option i18n:translate="" value="reg_number">with registration number</option>
+      <option i18n:translate="" value="suspended">
+        with deactivated account (search term omitted)
+      </option>
+      <option i18n:translate="" value="transcript">
+        who requested transcript (search term omitted)
+      </option>
+    </select>
+  </div>
+  <div class="form-group">
+    <input type="text" class="form-control" name="searchterm" />
+  </div>
+</form>
+<br />
+<div tal:condition="view/hitlist">
+  <h3 i18n:translate="">Search Results</h3>
+  <input type="hidden" name="old_searchterm"
+       tal:attributes="value view/searchterm" />
+  <input type="hidden" name="old_searchtype"
+       tal:attributes="value view/searchtype" />
+  <table class="kofa-data-table dataTable">
+    <thead>
+    <tr>
+      <th i18n:translate="">Student Id</th>
+      <th i18n:translate="">Reg. Number</th>
+      <th i18n:translate="">State</th>
+      <th i18n:translate="">Full Name</th>
+    </tr>
+    </thead>
+    <tbody>
+    <tr tal:repeat="item view/hitlist">
+      <td> <a tal:attributes="href item/url">
+        <span tal:content="item/customer_id">A123456</span></a>
+      </td>
+      <td class="center"> <a tal:attributes="href item/url">
+        <span tal:content="item/reg_number">9999999</span></a>
+      </td>
+      <td tal:content="item/state">admitted</td>
+      <td tal:content="item/display_fullname">Bob</td>
+    </tr>
+    </tbody>
+  </table>
+</div>
+
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/catalog.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/catalog.py	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/catalog.py	(revision 11958)
@@ -0,0 +1,112 @@
+## $Id: catalog.py 10465 2013-08-07 11:18:43Z henrik $
+##
+## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+"""Cataloging and searching components for customers.
+"""
+import grok
+from grok import index
+from hurry.query import Eq, Text
+from hurry.query.query import Query
+from zope.catalog.interfaces import ICatalog
+from zope.component import queryUtility
+from waeup.ikoba.catalog import FilteredCatalogQueryBase
+from waeup.ikoba.interfaces import (
+    ICompany, IQueryResultItem)
+from waeup.ikoba.customers.interfaces import ICustomer
+
+class CustomersCatalog(grok.Indexes):
+    """A catalog for customers.
+    """
+    grok.site(ICompany)
+    grok.name('customers_catalog')
+    grok.context(ICustomer)
+
+    customer_id = index.Field(attribute='customer_id')
+    fullname = index.Text(attribute='fullname')
+    email = index.Field(attribute='email')
+    reg_number = index.Field(attribute='reg_number')
+    state = index.Field(attribute='state')
+
+class CustomerQueryResultItem(object):
+    grok.implements(IQueryResultItem)
+
+    title = u'Customer Query Item'
+    description = u'Some customer found in a search'
+
+    def __init__(self, context, view):
+        self.context = context
+        self.url = view.url(context)
+        self.customer_id = context.customer_id
+        self.display_fullname = context.display_fullname
+        self.reg_number = context.reg_number
+        self.state = context.state
+        self.translated_state = context.translated_state
+
+def search(query=None, searchtype=None, view=None):
+    hitlist = []
+    if searchtype in ('fullname',):
+        results = Query().searchResults(
+            Text(('customers_catalog', searchtype), query))
+    elif searchtype == 'suspended':
+        # 'suspended' is not indexed
+        cat = queryUtility(ICatalog, name='customers_catalog')
+        all = cat.searchResults(customer_id=(None, None))
+        for customer in all:
+            if customer.suspended:
+                hitlist.append(CustomerQueryResultItem(customer, view=view))
+        return hitlist
+    else:
+        # Temporary solution to display all customers added
+        if query == '*':
+            cat = queryUtility(ICatalog, name='customers_catalog')
+            results = cat.searchResults(customer_id=(None, None))
+        else:
+            results = Query().searchResults(
+                Eq(('customers_catalog', searchtype), query))
+    for result in results:
+        hitlist.append(CustomerQueryResultItem(result, view=view))
+    return hitlist
+
+class SimpleFieldSearch(object):
+    """A programmatic (no UI required) search.
+
+    Looks up a given field attribute of the customers catalog for a
+    single value. So normally you would call an instance of this
+    search like this:
+
+      >>> SimpleFieldSearch()(reg_number='somevalue')
+
+    """
+    catalog_name = 'customers_catalog'
+    def __call__(self, **kw):
+        """Search customers catalog programmatically.
+        """
+        if len(kw) != 1:
+            raise ValueError('must give exactly one index name to search')
+        cat = queryUtility(ICatalog, name=self.catalog_name)
+        index_name, query_term = kw.items()[0]
+        results = cat.searchResults(index_name=(query_term, query_term))
+        return results
+
+#: an instance of `SimpleFieldSearch` looking up customers catalog.
+simple_search = SimpleFieldSearch()
+
+class CustomersQuery(FilteredCatalogQueryBase):
+    """Query customers in a site. See waeup.ikoba.catalog for more info.
+    """
+    cat_name = 'customers_catalog'
+    defaults = dict(customer_id=None) # make sure we get all studs by default
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/customer.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/customer.py	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/customer.py	(revision 11958)
@@ -0,0 +1,416 @@
+## $Id: customer.py 11604 2014-04-29 07:31:54Z henrik $
+##
+## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+"""
+Container for the various objects owned by customers.
+"""
+import os
+import re
+import shutil
+import grok
+from datetime import datetime, timedelta
+from hurry.workflow.interfaces import IWorkflowState, IWorkflowInfo
+from zope.password.interfaces import IPasswordManager
+from zope.component import getUtility, createObject
+from zope.component.interfaces import IFactory
+from zope.interface import implementedBy
+from zope.securitypolicy.interfaces import IPrincipalRoleManager
+from zope.schema.interfaces import ConstraintNotSatisfied
+
+from waeup.ikoba.image import IkobaImageFile
+from waeup.ikoba.imagestorage import DefaultFileStoreHandler
+from waeup.ikoba.interfaces import (
+    IObjectHistory, IUserAccount, IFileStoreNameChooser, IFileStoreHandler,
+    IIkobaUtils, registration_states_vocab, IExtFileStore,
+    CREATED, ADMITTED, CLEARANCE, PAID, REGISTERED, VALIDATED, RETURNING)
+from waeup.ikoba.customers.export import EXPORTER_NAMES
+from waeup.ikoba.customers.interfaces import (
+    ICustomer, ICustomerNavigation, ICSVCustomerExporter)
+from waeup.ikoba.customers.utils import generate_customer_id
+from waeup.ikoba.utils.helpers import attrs_to_fields, now, copy_filesystem_tree
+
+RE_CUSTID_NON_NUM = re.compile('[^\d]+')
+
+class Customer(grok.Container):
+    """This is a customer container for the various objects
+    owned by customers.
+    """
+    grok.implements(ICustomer, ICustomerNavigation)
+    grok.provides(ICustomer)
+
+    temp_password_minutes = 10
+
+    def __init__(self):
+        super(Customer, self).__init__()
+        # The site doesn't exist in unit tests
+        try:
+            self.customer_id = generate_customer_id()
+        except TypeError:
+            self.customer_id = u'Z654321'
+        self.password = None
+        self.temp_password = None
+        return
+
+    def setTempPassword(self, user, password):
+        """Set a temporary password (LDAP-compatible) SSHA encoded for
+        officers.
+
+        """
+        passwordmanager = getUtility(IPasswordManager, 'SSHA')
+        self.temp_password = {}
+        self.temp_password[
+            'password'] = passwordmanager.encodePassword(password)
+        self.temp_password['user'] = user
+        self.temp_password['timestamp'] = datetime.utcnow() # offset-naive datetime
+
+    def getTempPassword(self):
+        """Check if a temporary password has been set and if it
+        is not expired. 
+
+        Return the temporary password if valid,
+        None otherwise. Unset the temporary password if expired.
+        """
+        temp_password_dict = getattr(self, 'temp_password', None)
+        if temp_password_dict is not None:
+            delta = timedelta(minutes=self.temp_password_minutes)
+            now = datetime.utcnow()
+            if now < temp_password_dict.get('timestamp') + delta:
+                return temp_password_dict.get('password')
+            else:
+                # Unset temporary password if expired
+                self.temp_password = None
+        return None
+
+    def writeLogMessage(self, view, message):
+        ob_class = view.__implemented__.__name__.replace('waeup.ikoba.','')
+        self.__parent__.logger.info(
+            '%s - %s - %s' % (ob_class, self.__name__, message))
+        return
+
+    @property
+    def display_fullname(self):
+        middlename = getattr(self, 'middlename', None)
+        ikoba_utils = getUtility(IIkobaUtils)
+        return ikoba_utils.fullname(self.firstname, self.lastname, middlename)
+
+    @property
+    def fullname(self):
+        middlename = getattr(self, 'middlename', None)
+        if middlename:
+            return '%s-%s-%s' % (self.firstname.lower(),
+                middlename.lower(), self.lastname.lower())
+        else:
+            return '%s-%s' % (self.firstname.lower(), self.lastname.lower())
+
+    @property
+    def state(self):
+        state = IWorkflowState(self).getState()
+        return state
+
+    @property
+    def translated_state(self):
+        state = registration_states_vocab.getTermByToken(
+            self.state).title
+        return state
+
+    @property
+    def history(self):
+        history = IObjectHistory(self)
+        return history
+
+    @property
+    def customer(self):
+        return self
+
+    @property
+    def personal_data_expired(self):
+        return False
+
+# Set all attributes of Customer required in ICustomer as field
+# properties. Doing this, we do not have to set initial attributes
+# ourselves and as a bonus we get free validation when an attribute is
+# set.
+Customer = attrs_to_fields(Customer)
+
+class CustomerFactory(grok.GlobalUtility):
+    """A factory for customers.
+    """
+    grok.implements(IFactory)
+    grok.name(u'waeup.Customer')
+    title = u"Create a new customer.",
+    description = u"This factory instantiates new customer instances."
+
+    def __call__(self, *args, **kw):
+        return Customer()
+
+    def getInterfaces(self):
+        return implementedBy(Customer)
+
+@grok.subscribe(ICustomer, grok.IObjectAddedEvent)
+def handle_customer_added(customer, event):
+    """If a customer is added all subcontainers are automatically added
+    and the transition create is fired. The latter produces a logging
+    message.
+    """
+    # Assign global customer role for new customer
+    account = IUserAccount(customer)
+    account.roles = ['waeup.Customer']
+    # Assign local CustomerRecordOwner role
+    role_manager = IPrincipalRoleManager(customer)
+    role_manager.assignRoleToPrincipal(
+        'waeup.local.CustomerRecordOwner', customer.customer_id)
+    if customer.state is None:
+        IWorkflowInfo(customer).fireTransition('create')
+    return
+
+def path_from_custid(customer_id):
+    """Convert a customer_id into a predictable relative folder path.
+
+    Used for storing files.
+
+    Returns the name of folder in which files for a particular customer
+    should be stored. This is a relative path, relative to any general
+    customers folder with 5 zero-padded digits (except when customer_id
+    is overlong).
+
+    We normally map 1,000 different customer ids into one single
+    path. For instance ``K1000000`` will give ``01000/K1000000``,
+    ``K1234567`` will give ``0123/K1234567`` and ``K12345678`` will
+    result in ``1234/K12345678``.
+
+    For lower numbers < 10**6 we return the same path for up to 10,000
+    customer_ids. So for instance ``KM123456`` will result in
+    ``00120/KM123456`` (there will be no path starting with
+    ``00123``).
+
+    Works also with overlong number: here the leading zeros will be
+    missing but ``K123456789`` will give reliably
+    ``12345/K123456789`` as expected.
+    """
+    # remove all non numeric characters and turn this into an int.
+    num = int(RE_CUSTID_NON_NUM.sub('', customer_id))
+    if num < 10**6:
+        # store max. of 10000 custs per folder and correct num for 5 digits
+        num = num / 10000 * 10
+    else:
+        # store max. of 1000 custs per folder
+        num = num / 1000
+    # format folder name to have 5 zero-padded digits
+    folder_name = u'%05d' % num
+    folder_name = os.path.join(folder_name, customer_id)
+    return folder_name
+
+def move_customer_files(customer, del_dir):
+    """Move files belonging to `customer` to `del_dir`.
+
+    `del_dir` is expected to be the path to the site-wide directory
+    for storing backup data.
+
+    The current files of the customer are removed after backup.
+
+    If the customer has no associated files stored, nothing is done.
+    """
+    cust_id = customer.customer_id
+
+    src = getUtility(IExtFileStore).root
+    src = os.path.join(src, 'customers', path_from_custid(cust_id))
+
+    dst = os.path.join(
+        del_dir, 'media', 'customers', path_from_custid(cust_id))
+
+    if not os.path.isdir(src):
+        # Do not copy if no files were stored.
+        return
+    if not os.path.exists(dst):
+        os.makedirs(dst, 0755)
+    copy_filesystem_tree(src, dst)
+    shutil.rmtree(src)
+    return
+
+def update_customer_deletion_csvs(customer, del_dir):
+    """Update deletion CSV files with data from customer.
+
+    `del_dir` is expected to be the path to the site-wide directory
+    for storing backup data.
+
+    Each exporter available for customers (and their many subobjects)
+    is called in order to export CSV data of the given customer to csv
+    files in the site-wide backup directory for object data (see
+    DataCenter).
+
+    Each exported row is appended a column giving the deletion date
+    (column `del_date`) as a UTC timestamp.
+    """
+    for name in EXPORTER_NAMES:
+        exporter = getUtility(ICSVCustomerExporter, name=name)
+        csv_data = exporter.export_customer(customer)
+        csv_data = csv_data.split('\r\n')
+
+        # append a deletion timestamp on each data row
+        timestamp = str(now().replace(microsecond=0)) # store UTC timestamp
+        for num, row in enumerate(csv_data[1:-1]):
+            csv_data[num+1] = csv_data[num+1] + ',' + timestamp
+        csv_path = os.path.join(del_dir, '%s.csv' % name)
+
+        # write data to CSV file
+        if not os.path.exists(csv_path):
+            # create new CSV file (including header line)
+            csv_data[0] = csv_data[0] + ',del_date'
+            open(csv_path, 'wb').write('\r\n'.join(csv_data))
+        else:
+            # append existing CSV file (omitting headerline)
+            open(csv_path, 'a').write('\r\n'.join(csv_data[1:]))
+    return
+
+@grok.subscribe(ICustomer, grok.IObjectRemovedEvent)
+def handle_customer_removed(customer, event):
+    """If a customer is removed a message is logged and data is put
+       into a backup location.
+
+    The data of the removed customer is appended to CSV files in local
+    datacenter and any existing external files (passport images, etc.)
+    are copied over to this location as well.
+
+    Documents in the file storage refering to the given customer are
+    removed afterwards (if they exist). Please make no assumptions
+    about how the deletion takes place. Files might be deleted
+    individually (leaving the customers file directory intact) or the
+    whole customer directory might be deleted completely.
+
+    All CSV rows created/appended contain a timestamp with the
+    datetime of removal in an additional `del_date` column.
+
+    XXX: blocking of used customer_ids yet not implemented.
+    """
+    comment = 'Customer record removed'
+    target = customer.customer_id
+    try:
+        site = grok.getSite()
+        site['customers'].logger.info('%s - %s' % (
+            target, comment))
+    except KeyError:
+        # If we delete an entire university instance there won't be
+        # a customers subcontainer
+        return
+
+    del_dir = site['datacenter'].deleted_path
+
+    # save files of the customer
+    move_customer_files(customer, del_dir)
+
+    # update CSV files
+    update_customer_deletion_csvs(customer, del_dir)
+    return
+
+#: The file id marker for customer files
+CUSTOMER_FILE_STORE_NAME = 'file-customer'
+
+class CustomerFileNameChooser(grok.Adapter):
+    """A file id chooser for :class:`Customer` objects.
+
+    `context` is an :class:`Customer` instance.
+
+    The :class:`CustomerImageNameChooser` can build/check file ids for
+    :class:`Customer` objects suitable for use with
+    :class:`ExtFileStore` instances. The delivered file_id contains
+    the file id marker for :class:`Customer` object and the customer id
+    of the context customer.
+
+    This chooser is registered as an adapter providing
+    :class:`waeup.ikoba.interfaces.IFileStoreNameChooser`.
+
+    File store name choosers like this one are only convenience
+    components to ease the task of creating file ids for customer
+    objects. You are nevertheless encouraged to use them instead of
+    manually setting up filenames for customers.
+
+    .. seealso:: :mod:`waeup.ikoba.imagestorage`
+
+    """
+    grok.context(ICustomer)
+    grok.implements(IFileStoreNameChooser)
+
+    def checkName(self, name=None, attr=None):
+        """Check whether the given name is a valid file id for the context.
+
+        Returns ``True`` only if `name` equals the result of
+        :meth:`chooseName`.
+
+        """
+        return name == self.chooseName()
+
+    def chooseName(self, attr, name=None):
+        """Get a valid file id for customer context.
+
+        *Example:*
+
+        For a customer with customer id ``'A123456'`` and
+        with attr ``'nice_image.jpeg'`` stored in
+        the customers container this chooser would create:
+
+          ``'__file-customer__customers/A/A123456/nice_image_A123456.jpeg'``
+
+        meaning that the nice image of this applicant would be
+        stored in the site-wide file storage in path:
+
+          ``customers/A/A123456/nice_image_A123456.jpeg``
+
+        """
+        basename, ext = os.path.splitext(attr)
+        cust_id = self.context.customer_id
+        marked_filename = '__%s__%s/%s_%s%s' % (
+            CUSTOMER_FILE_STORE_NAME, path_from_custid(cust_id), basename,
+            cust_id, ext)
+        return marked_filename
+
+
+class CustomerFileStoreHandler(DefaultFileStoreHandler, grok.GlobalUtility):
+    """Customer specific file handling.
+
+    This handler knows in which path in a filestore to store customer
+    files and how to turn this kind of data into some (browsable)
+    file object.
+
+    It is called from the global file storage, when it wants to
+    get/store a file with a file id starting with
+    ``__file-customer__`` (the marker string for customer files).
+
+    Like each other file store handler it does not handle the files
+    really (this is done by the global file store) but only computes
+    paths and things like this.
+    """
+    grok.implements(IFileStoreHandler)
+    grok.name(CUSTOMER_FILE_STORE_NAME)
+
+    def pathFromFileID(self, store, root, file_id):
+        """All customer files are put in directory ``customers``.
+        """
+        marker, filename, basename, ext = store.extractMarker(file_id)
+        sub_root = os.path.join(root, 'customers')
+        return super(CustomerFileStoreHandler, self).pathFromFileID(
+            store, sub_root, basename)
+
+    def createFile(self, store, root, filename, file_id, file):
+        """Create a browsable file-like object.
+        """
+        # call super method to ensure that any old files with
+        # different filename extension are deleted.
+        file, path, file_obj =  super(
+            CustomerFileStoreHandler, self).createFile(
+            store, root,  filename, file_id, file)
+        return file, path, IkobaImageFile(
+            file_obj.filename, file_obj.data)
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/export.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/export.py	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/export.py	(revision 11958)
@@ -0,0 +1,126 @@
+## $Id: export.py 11757 2014-07-10 12:20:30Z henrik $
+##
+## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+"""Exporters for customer related stuff.
+"""
+import os
+import grok
+from datetime import datetime
+from zope.component import getUtility
+from waeup.ikoba.interfaces import (
+    IExtFileStore, IFileStoreNameChooser, IIkobaUtils)
+from waeup.ikoba.interfaces import MessageFactory as _
+from waeup.ikoba.customers.catalog import CustomersQuery
+from waeup.ikoba.customers.interfaces import (
+    ICustomer, ICSVCustomerExporter)
+from waeup.ikoba.utils.batching import ExporterBase
+from waeup.ikoba.utils.helpers import iface_names, to_timezone
+
+#: A tuple containing all exporter names referring to customers or
+#: subobjects thereof.
+EXPORTER_NAMES = ('customers', )
+
+def get_customers(site, cust_filter=CustomersQuery()):
+    """Get all customers registered in catalog in `site`.
+    """
+    return cust_filter.query()
+
+class CustomerExporterBase(ExporterBase):
+    """Exporter for customers or related objects.
+
+    This is a baseclass.
+    """
+    grok.baseclass()
+    grok.implements(ICSVCustomerExporter)
+    grok.provides(ICSVCustomerExporter)
+
+    def filter_func(self, x, **kw):
+        return x
+
+    def get_filtered(self, site, **kw):
+        """Get customers from a catalog filtered by keywords.
+
+        customers_catalog is the default catalog. The keys must be valid
+        catalog index names.
+        Returns a simple empty list, a list with `Customer`
+        objects or a catalog result set with `Customer`
+        objects.
+
+        .. seealso:: `waeup.ikoba.customers.catalog.CustomersCatalog`
+
+        """
+        # Pass only given keywords to create FilteredCatalogQuery objects.
+        # This way we avoid
+        # trouble with `None` value ambivalences and queries are also
+        # faster (normally less indexes to ask). Drawback is, that
+        # developers must look into catalog to see what keywords are
+        # valid.
+        query = CustomersQuery(**kw)
+        return query.query()
+
+    def export(self, values, filepath=None):
+        """Export `values`, an iterable, as CSV file.
+
+        If `filepath` is ``None``, a raw string with CSV data is returned.
+        """
+        writer, outfile = self.get_csv_writer(filepath)
+        for value in values:
+            self.write_item(value, writer)
+        return self.close_outfile(filepath, outfile)
+
+    def export_all(self, site, filepath=None):
+        """Export customers into filepath as CSV data.
+
+        If `filepath` is ``None``, a raw string with CSV data is returned.
+        """
+        return self.export(self.filter_func(get_customers(site)), filepath)
+
+    def export_customer(self, customer, filepath=None):
+        return self.export(self.filter_func([customer]), filepath=filepath)
+
+    def export_filtered(self, site, filepath=None, **kw):
+        """Export items denoted by `kw`.
+
+        If `filepath` is ``None``, a raw string with CSV data should
+        be returned.
+        """
+        data = self.get_filtered(site, **kw)
+        return self.export(self.filter_func(data, **kw), filepath=filepath)
+
+
+class CustomersExporter(grok.GlobalUtility, CustomerExporterBase):
+    """Exporter for Customers.
+    """
+    grok.name('customers')
+
+    #: Fieldnames considered by this exporter
+    fields = tuple(sorted(iface_names(ICustomer))) + (
+        'password', 'state', 'history',)
+
+    #: The title under which this exporter will be displayed
+    title = _(u'Customers')
+
+    def mangle_value(self, value, name, context=None):
+        if name == 'history':
+            value = value.messages
+        if name == 'phone' and value is not None:
+            # Append hash '#' to phone numbers to circumvent
+            # unwanted excel automatic
+            value = str('%s#' % value)
+        return super(
+            CustomersExporter, self).mangle_value(
+            value, name, context=context)
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/interfaces.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/interfaces.py	(revision 11957)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/interfaces.py	(revision 11958)
@@ -22,6 +22,11 @@
 from zc.sourcefactory.contextual import BasicContextualSourceFactory
 from waeup.ikoba.interfaces import MessageFactory as _
+from waeup.ikoba.interfaces import (
+    IIkobaObject, application_sessions_vocab, validate_email, ICSVExporter)
 from waeup.ikoba.schema import TextLineChoice, FormattedDate, PhoneNumber
-from waeup.ikoba.interfaces import IIkobaObject
+from waeup.ikoba.browser.interfaces import ICustomerNavigationBase
+
+from waeup.ikoba.customers.vocabularies import (
+    contextual_reg_num_source, GenderSource, nats_vocab)
 
 class ICustomersUtils(Interface):
@@ -71,4 +76,16 @@
     unique_customer_id = Attribute("""A unique customer id.""")
 
+
+class ICustomerNavigation(ICustomerNavigationBase):
+    """Interface needed for custom navigation, logging, etc.
+
+    """
+    customer = Attribute('Customer object of context.')
+
+    def writeLogMessage(view, message):
+        """Write a view specific log message into custom.log.
+
+        """
+
 class ICustomer(IIkobaObject):
     """Representation of a customer.
@@ -76,2 +93,107 @@
     """
 
+    history = Attribute('Object history, a list of messages')
+    state = Attribute('Returns the registration state of a customer')
+    password = Attribute('Encrypted password of a customer')
+    temp_password = Attribute(
+        'Dictionary with user name, timestamp and encrypted password')
+    fullname = Attribute('All name parts separated by hyphens')
+    display_fullname = Attribute('The fullname of an applicant')
+
+    suspended = schema.Bool(
+        title = _(u'Account suspended'),
+        default = False,
+        required = False,
+        )
+
+    suspended_comment = schema.Text(
+        title = _(u"Reasons for Deactivation"),
+        required = False,
+        description = _(
+            u'This message will be shown if and only if deactivated '
+            'customers try to login.'),
+        )
+
+    customer_id = schema.TextLine(
+        title = _(u'Student Id'),
+        required = False,
+        )
+
+    firstname = schema.TextLine(
+        title = _(u'First Name'),
+        required = True,
+        )
+
+    middlename = schema.TextLine(
+        title = _(u'Middle Name'),
+        required = False,
+        )
+
+    lastname = schema.TextLine(
+        title = _(u'Last Name (Surname)'),
+        required = True,
+        )
+
+    sex = schema.Choice(
+        title = _(u'Sex'),
+        source = GenderSource(),
+        required = True,
+        )
+
+    reg_number = TextLineChoice(
+        title = _(u'Registration Number'),
+        required = True,
+        readonly = False,
+        source = contextual_reg_num_source,
+        )
+
+    email = schema.ASCIILine(
+        title = _(u'Email'),
+        required = False,
+        constraint=validate_email,
+        )
+    phone = PhoneNumber(
+        title = _(u'Phone'),
+        description = u'',
+        required = False,
+        )
+
+    def setTempPassword(user, password):
+        """Set a temporary password (LDAP-compatible) SSHA encoded for
+        officers.
+
+        """
+
+    def getTempPassword():
+        """Check if a temporary password has been set and if it
+        is not expired.
+
+        Return the temporary password if valid,
+        None otherwise. Unset the temporary password if expired.
+        """
+
+class ICustomerUpdateByRegNo(ICustomer):
+    """Representation of a customer. Skip regular reg_number validation.
+
+    """
+    reg_number = schema.TextLine(
+        title = _(u'Registration Number'),
+        required = False,
+        )
+
+class ICSVCustomerExporter(ICSVExporter):
+    """A regular ICSVExporter that additionally supports exporting
+      data from a given customer object.
+    """
+    def get_filtered(site, **kw):
+        """Get a filtered set of customer.
+        """
+
+    def export_student(student, filepath=None):
+        """Export data for a given customer.
+        """
+
+    def export_filtered(site, filepath=None, **kw):
+        """Export filtered set of customers.
+        """
+
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/permissions.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/permissions.py	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/permissions.py	(revision 11958)
@@ -0,0 +1,83 @@
+## $Id: permissions.py 10465 2013-08-07 11:18:43Z henrik $
+##
+## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+"""
+Permissions for the customer section.
+"""
+import grok
+
+# Customer section permissions
+
+class HandleCustomer(grok.Permission):
+    grok.name('waeup.handleCustomer')
+
+class ViewCustomer(grok.Permission):
+    grok.name('waeup.viewCustomer')
+
+class ViewCustomersTab(grok.Permission):
+    grok.name('waeup.viewCustomersTab')
+
+class ViewMyCustomerDataTab(grok.Permission):
+    grok.name('waeup.viewMyCustomerDataTab')
+
+class ViewCustomersContainer(grok.Permission):
+    grok.name('waeup.viewCustomersContainer')
+
+class PayCustomer(grok.Permission):
+    grok.name('waeup.payCustomer')
+
+class UploadCustomerFile(grok.Permission):
+    grok.name('waeup.uploadCustomerFile')
+
+class ManageCustomer(grok.Permission):
+    grok.name('waeup.manageCustomer')
+
+class LoginAsCustomer(grok.Permission):
+    grok.name('waeup.loginAsCustomer')
+
+# Local role
+class CustomerRecordOwner(grok.Role):
+    grok.name('waeup.local.CustomerRecordOwner')
+    grok.title(u'Customer Record Owner')
+    grok.permissions('waeup.handleCustomer', 'waeup.uploadCustomerFile',
+                     'waeup.viewCustomer', 'waeup.payCustomer')
+
+# Site Roles
+class CustomerRole(grok.Role):
+    grok.name('waeup.Customer')
+    grok.title(u'Customer (do not assign)')
+    grok.permissions('waeup.viewProducts', 'waeup.viewMyCustomerDataTab',
+                     'waeup.Authenticated')
+
+class CustomersOfficer(grok.Role):
+    grok.name('waeup.CustomersOfficer')
+    grok.title(u'Customers Officer (view only)')
+    grok.permissions('waeup.viewCustomer','waeup.viewCustomers',
+          'waeup.viewCustomersTab', 'waeup.viewCustomersContainer')
+
+class CustomersManager(grok.Role):
+    grok.name('waeup.CustomersManager')
+    grok.title(u'Customers Manager')
+    grok.permissions('waeup.viewCustomer', 'waeup.viewCustomers',
+                     'waeup.manageCustomer', 'waeup.viewCustomersContainer',
+                     'waeup.payCustomer', 'waeup.uploadCustomerFile',
+                     'waeup.viewCustomersTab')
+
+class CustomerImpersonator(grok.Role):
+    grok.name('waeup.CustomerImpersonator')
+    grok.title(u'Customer Impersonator')
+    grok.permissions('waeup.loginAsCustomer')
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/sample_customer_data.csv
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/sample_customer_data.csv	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/sample_customer_data.csv	(revision 11958)
@@ -0,0 +1,10 @@
+customer_id,firstname,lastname,reg_number,date_of_birth,,phone,sex,state
+X666666,Aaren,Pieri,1,1990-01-02,aa@aa.ng,1234,M,courses validated
+Y777777,Claus,Finau,2,1990-01-03,aa@aa.ng,1234,m,courses validated
+,Susann,Berson,3,1990-01-04,aa@aa.ng,1234,F,courses validated
+,Else,Mueller,4,1990-01-05,aa@aa.ng,1234,f,school fee paid
+X888888,Alfons,Meier,5,1990-01-02,aa@aa.ng,1234,m,courses validated
+X999999,Herbert,Mueller,6,1990-01-02,aa@aa.ng,1234,m,courses registered
+X111111,Fabian,Riester,7,1990-01-02,aa@aa.ng,1234,m,courses validated
+X222222,Thomas,Kinderman,8,1990-01-02,aa@aa.ng,1234,m,courses validated
+X333333,Paula,Schall,9,1990-01-02,aa@aa.ng,1234,m,courses registered
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/sample_customer_data_duplicates.csv
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/sample_customer_data_duplicates.csv	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/sample_customer_data_duplicates.csv	(revision 11958)
@@ -0,0 +1,5 @@
+customer_id,firstname,lastname,reg_number,date_of_birth,email,phone,password,sex,state
+A123456,Aaren,Pieri,1,1990-01-02#,aa@aa.ng,1234,mypw1,m,clearance started
+C123456,Aaren,Berson,2,1990-01-04,aa@aa.ng,1234,mypw1,m,admitted
+F123456,Frank,Meyer,1,1990-01-06,aa@aa.ng,1234,,m,
+A123456,Uli,Schulz,3,1990-01-07,aa@aa.ng,1234,,m,
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/sample_customer_data_update.csv
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/sample_customer_data_update.csv	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/sample_customer_data_update.csv	(revision 11958)
@@ -0,0 +1,4 @@
+reg_number,date_of_birth,state
+1,1980-01-02,admitted
+2,1980-01-03,
+3,1980-01-04,
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/test_batching.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/test_batching.py	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/test_batching.py	(revision 11958)
@@ -0,0 +1,240 @@
+# -*- coding: utf-8 -*-
+## $Id: test_batching.py 11756 2014-07-09 12:46:08Z henrik $
+##
+## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+"""Unit tests for customer-related data processors.
+"""
+import os
+import shutil
+import tempfile
+import unittest
+import datetime
+import grok
+from time import time
+from zope.event import notify
+from zope.component import createObject, queryUtility
+from zope.component.hooks import setSite, clearSite
+from zope.catalog.interfaces import ICatalog
+from zope.interface.verify import verifyClass, verifyObject
+from hurry.workflow.interfaces import IWorkflowState
+
+from waeup.ikoba.app import Company
+from waeup.ikoba.interfaces import IBatchProcessor, FatalCSVError, IUserAccount
+from waeup.ikoba.customers.batching import CustomerProcessor
+from waeup.ikoba.customers.customer import Customer
+from waeup.ikoba.testing import FunctionalLayer, FunctionalTestCase
+
+CUSTOMER_SAMPLE_DATA = open(
+    os.path.join(os.path.dirname(__file__), 'sample_customer_data.csv'),
+    'rb').read()
+
+CUSTOMER_HEADER_FIELDS = CUSTOMER_SAMPLE_DATA.split(
+    '\n')[0].split(',')
+
+CUSTOMER_SAMPLE_DATA_UPDATE = open(
+    os.path.join(os.path.dirname(__file__), 'sample_customer_data_update.csv'),
+    'rb').read()
+
+CUSTOMER_HEADER_FIELDS_UPDATE = CUSTOMER_SAMPLE_DATA_UPDATE.split(
+    '\n')[0].split(',')
+
+CUSTOMER_SAMPLE_DATA_DUPLICATES = open(
+    os.path.join(os.path.dirname(__file__),
+                 'sample_customer_data_duplicates.csv'),
+    'rb').read()
+
+CUSTOMER_HEADER_FIELDS_DUPLICATES = CUSTOMER_SAMPLE_DATA_DUPLICATES.split(
+    '\n')[0].split(',')
+
+class CustomerImportExportSetup(FunctionalTestCase):
+
+    layer = FunctionalLayer
+
+    def setUp(self):
+        super(CustomerImportExportSetup, self).setUp()
+        self.dc_root = tempfile.mkdtemp()
+        self.workdir = tempfile.mkdtemp()
+        app = Company()
+        app['datacenter'].setStoragePath(self.dc_root)
+        self.getRootFolder()['app'] = app
+        self.app = self.getRootFolder()['app']
+        setSite(app)
+
+        self.logfile = os.path.join(
+            self.app['datacenter'].storage, 'logs', 'customers.log')
+        return
+
+    def tearDown(self):
+        super(CustomerImportExportSetup, self).tearDown()
+        shutil.rmtree(self.workdir)
+        shutil.rmtree(self.dc_root)
+        clearSite()
+        return
+
+    def setup_for_export(self):
+        customer = Customer()
+        customer.customer_id = u'A111111'
+        self.app['customers'][customer.customer_id] = self.customer = customer
+        self.outfile = os.path.join(self.workdir, 'myoutput.csv')
+        return
+
+    def setup_customer(self, customer):
+        # set predictable values for `customer`
+        customer.matric_number = u'234'
+        customer.perm_address = u'Customerroad 21\nLagos 123456\n'
+        customer.reg_number = u'123'
+        customer.firstname = u'Anna'
+        customer.lastname = u'Tester'
+        customer.middlename = u'M.'
+        customer.date_of_birth = datetime.date(1981, 2, 4)
+        #customer.sex = 'f'
+        customer.email = 'anna@sample.com'
+        customer.phone = u'+234-123-12345'
+        customer.notice = u'Some notice\nin lines.'
+        customer.nationality = u'NG'
+
+        return customer
+
+class CustomerProcessorTest(CustomerImportExportSetup):
+
+    layer = FunctionalLayer
+
+    def setUp(self):
+        super(CustomerProcessorTest, self).setUp()
+
+        # Add customer with subobjects
+        customer = Customer()
+        self.app['customers'].addCustomer(customer)
+        customer = self.setup_customer(customer)
+        notify(grok.ObjectModifiedEvent(customer))
+        self.customer = self.app['customers'][customer.customer_id]
+
+        self.processor = CustomerProcessor()
+        self.csv_file = os.path.join(self.workdir, 'sample_customer_data.csv')
+        self.csv_file_update = os.path.join(
+            self.workdir, 'sample_customer_data_update.csv')
+        self.csv_file_duplicates = os.path.join(
+            self.workdir, 'sample_customer_data_duplicates.csv')
+        open(self.csv_file, 'wb').write(CUSTOMER_SAMPLE_DATA)
+        open(self.csv_file_update, 'wb').write(CUSTOMER_SAMPLE_DATA_UPDATE)
+        open(self.csv_file_duplicates, 'wb').write(CUSTOMER_SAMPLE_DATA_DUPLICATES)
+
+    def test_interface(self):
+        # Make sure we fulfill the interface contracts.
+        assert verifyObject(IBatchProcessor, self.processor) is True
+        assert verifyClass(
+            IBatchProcessor, CustomerProcessor) is True
+
+    def test_parentsExist(self):
+        self.assertFalse(self.processor.parentsExist(None, dict()))
+        self.assertTrue(self.processor.parentsExist(None, self.app))
+
+    def test_entryExists(self):
+        assert self.processor.entryExists(
+            dict(customer_id='ID_NONE'), self.app) is False
+        assert self.processor.entryExists(
+            dict(reg_number='123'), self.app) is True
+
+    def test_getParent(self):
+        parent = self.processor.getParent(None, self.app)
+        assert parent is self.app['customers']
+
+    def test_getEntry(self):
+        assert self.processor.getEntry(
+            dict(customer_id='ID_NONE'), self.app) is None
+        assert self.processor.getEntry(
+            dict(customer_id=self.customer.customer_id), self.app) is self.customer
+
+    def test_addEntry(self):
+        new_customer = Customer()
+        self.processor.addEntry(
+            new_customer, dict(), self.app)
+        assert len(self.app['customers'].keys()) == 2
+
+    def test_checkConversion(self):
+        # Make sure we can check conversions and that the cust_id
+        # counter is not raised during such checks.
+        initial_cust_id = self.app['customers']._curr_cust_id
+        errs, inv_errs, conv_dict = self.processor.checkConversion(
+            dict(reg_number='1', state='admitted'))
+        self.assertEqual(len(errs),0)
+        # Empty state is allowed
+        errs, inv_errs, conv_dict = self.processor.checkConversion(
+            dict(reg_number='1', state=''))
+        self.assertEqual(len(errs),0)
+        #self.assertTrue(('state', 'no value provided') in errs)
+        errs, inv_errs, conv_dict = self.processor.checkConversion(
+            dict(reg_number='1', state='nonsense'))
+        self.assertEqual(len(errs),1)
+        self.assertTrue(('state', 'not allowed') in errs)
+        new_cust_id = self.app['customers']._curr_cust_id
+        self.assertEqual(initial_cust_id, new_cust_id)
+        return
+
+    def test_checkUpdateRequirements(self):
+        # Make sure that pg customers can't be updated with wrong transition.
+        err = self.processor.checkUpdateRequirements(self.customer,
+            dict(reg_number='1', state='returning'), self.app)
+        self.assertTrue(err is None)
+
+    def test_delEntry(self):
+        assert self.customer.customer_id in self.app['customers'].keys()
+        self.processor.delEntry(
+            dict(reg_number=self.customer.reg_number), self.app)
+        assert self.customer.customer_id not in self.app['customers'].keys()
+
+    def test_import(self):
+        self.assertEqual(self.app['customers']._curr_cust_id, 1000001)
+        num, num_warns, fin_file, fail_file = self.processor.doImport(
+            self.csv_file, CUSTOMER_HEADER_FIELDS)
+        self.assertEqual(num_warns,0)
+        self.assertEqual(len(self.app['customers']), 10)
+        self.assertEqual(self.app['customers']['X666666'].reg_number,'1')
+        self.assertEqual(
+            self.app['customers']['X666666'].state, 'courses validated')
+        # Two new customer_ids have been created.
+        self.assertEqual(self.app['customers']._curr_cust_id, 1000003)
+        shutil.rmtree(os.path.dirname(fin_file))
+
+    def test_import_update(self):
+        num, num_warns, fin_file, fail_file = self.processor.doImport(
+            self.csv_file, CUSTOMER_HEADER_FIELDS)
+        shutil.rmtree(os.path.dirname(fin_file))
+        num, num_warns, fin_file, fail_file = self.processor.doImport(
+            self.csv_file_update, CUSTOMER_HEADER_FIELDS_UPDATE, 'update')
+        self.assertEqual(num_warns,0)
+        # state has changed
+        self.assertEqual(self.app['customers']['X666666'].state,'admitted')
+        # state has not changed
+        self.assertEqual(self.app['customers']['Y777777'].state,
+                         'courses validated')
+        shutil.rmtree(os.path.dirname(fin_file))
+
+    def test_import_duplicate_data(self):
+        num, num_warns, fin_file, fail_file = self.processor.doImport(
+            self.csv_file_duplicates, CUSTOMER_HEADER_FIELDS_DUPLICATES)
+        content = open(fail_file).read()
+        self.assertEqual(num_warns,2)
+        self.assertEqual(
+            content,
+            'reg_number,password,firstname,lastname,sex,phone,state,date_of_birth,customer_id,email,--ERRORS--\r\n'
+            '1,,Frank,Meyer,m,1234,,1990-01-06,F123456,aa@aa.ng,reg_number: reg_number\r\n'
+            '3,,Uli,Schulz,m,1234,,1990-01-07,A123456,aa@aa.ng,This object already exists. Skipping.\r\n'
+
+            )
+        shutil.rmtree(os.path.dirname(fin_file))
+
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/test_catalog.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/test_catalog.py	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/test_catalog.py	(revision 11958)
@@ -0,0 +1,89 @@
+## $Id: test_catalog.py 10552 2013-08-28 14:33:33Z henrik $
+##
+## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+import grok
+import shutil
+import tempfile
+from zope.event import notify
+from zope.catalog.interfaces import ICatalog
+from zope.component import queryUtility, createObject
+from zope.component.hooks import setSite
+from waeup.ikoba.app import Company
+from waeup.ikoba.testing import FunctionalLayer, FunctionalTestCase
+from waeup.ikoba.customers.customer import Customer
+
+class CatalogTestSetup(FunctionalTestCase):
+    # A setup for testing catalog related stuff.
+    #
+    # sets up a site with some customer already created.
+    layer = FunctionalLayer
+
+    def setUp(self):
+        super(CatalogTestSetup, self).setUp()
+
+        # Prepopulate ZODB
+        app = Company()
+        self.dc_root = tempfile.mkdtemp()
+        app['datacenter'].setStoragePath(self.dc_root)
+
+        # Prepopulate the ZODB...
+        self.getRootFolder()['app'] = app
+        self.app = self.getRootFolder()['app']
+        setSite(self.app)
+
+        # Create customer with subobjects
+        customer = Customer()
+        customer.firstname = u'Bob'
+        customer.lastname = u'Tester'
+        customer.matric_number = u'1234'
+        self.app['customers'].addCustomer(customer)
+        self.customer_id = customer.customer_id
+        self.customer = self.app['customers'][self.customer_id]
+        # Update the customers_catalog
+        notify(grok.ObjectModifiedEvent(self.customer))
+        return
+
+    def tearDown(self):
+        shutil.rmtree(self.dc_root)
+        super(CatalogTestSetup, self).tearDown()
+        return
+
+class CustomerCatalogTests(CatalogTestSetup):
+
+    layer = FunctionalLayer
+
+    def test_get_catalog(self):
+        # We can get an customers catalog if we wish
+        cat = queryUtility(ICatalog, name='customers_catalog')
+        assert cat is not None
+
+    def test_search_by_id(self):
+        # We can find a certain customer id
+        cat = queryUtility(ICatalog, name='customers_catalog')
+        results = cat.searchResults(customer_id=(self.customer_id,
+                                                self.customer_id))
+        results = [x for x in results] # Turn results generator into list
+        assert len(results) == 1
+        assert results[0] is self.app['customers'][self.customer_id]
+
+    def test_search_by_name(self):
+        # We can find a certain name
+        cat = queryUtility(ICatalog, name='customers_catalog')
+        results = cat.searchResults(fullname='Bob Tester')
+        results = [x for x in results] # Turn results generator into list
+        assert len(results) == 1
+        assert results[0] is self.app['customers'][self.customer_id]
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/test_customer.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/test_customer.py	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/tests/test_customer.py	(revision 11958)
@@ -0,0 +1,216 @@
+## $Id: test_customer.py 10059 2013-04-05 08:11:29Z henrik $
+##
+## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+"""Tests for customers and related.
+"""
+import os
+import re
+import unittest
+import grok
+from cStringIO import StringIO
+from datetime import tzinfo
+from zope.component import getUtility, queryUtility, createObject
+from zope.catalog.interfaces import ICatalog
+from zope.component.interfaces import IFactory
+from zope.event import notify
+from zope.interface import verify
+from zope.schema.interfaces import RequiredMissing
+from waeup.ikoba.interfaces import IExtFileStore, IFileStoreNameChooser
+from waeup.ikoba.customers.export import EXPORTER_NAMES
+from waeup.ikoba.customers.customer import (
+    Customer, CustomerFactory, handle_customer_removed, path_from_custid)
+from waeup.ikoba.customers.interfaces import (
+    ICustomer, ICustomerNavigation)
+from waeup.ikoba.customers.tests.test_batching import CustomerImportExportSetup
+from waeup.ikoba.testing import FunctionalLayer, FunctionalTestCase
+
+class HelperTests(unittest.TestCase):
+    # Tests for helper functions in customer module.
+
+    def test_path_from_custid(self):
+        # make sure we get predictable paths from customer ids.
+        self.assertEqual(
+            path_from_custid('K1000000'), u'01000/K1000000')
+        self.assertEqual(
+            path_from_custid('K1234567'), u'01234/K1234567')
+        self.assertEqual(
+            path_from_custid('K12345678'), u'12345/K12345678')
+        # The algorithm works also for overlong numbers, just to be
+        # sure.
+        self.assertEqual(
+            path_from_custid('K123456789'), u'123456/K123456789')
+        # low numbers (< 10**6) are treated special: they get max. of
+        # 10,000 entries. That's mainly because of old customers
+        # migrated into our portal.
+        self.assertEqual(
+            path_from_custid('KM123456'), u'00120/KM123456')
+        return
+
+class CustomerTest(FunctionalTestCase):
+
+    layer = FunctionalLayer
+
+    def setUp(self):
+        super(CustomerTest, self).setUp()
+        self.customer = Customer()
+        self.customer.firstname = u'Anna'
+        self.customer.lastname = u'Tester'
+        return
+
+    def tearDown(self):
+        super(CustomerTest, self).tearDown()
+        return
+
+    def test_interfaces(self):
+        verify.verifyClass(ICustomer, Customer)
+        verify.verifyClass(ICustomerNavigation, Customer)
+        verify.verifyObject(ICustomer, self.customer)
+        verify.verifyObject(ICustomerNavigation, self.customer)
+        return
+
+    #def test_base(self):
+    #    department = Department()
+    #    studycourse = CustomerStudyCourse()
+    #    self.assertRaises(
+    #        TypeError, studycourse.addCustomerStudyLevel, department)
+    #    studylevel = CustomerStudyLevel()
+    #    self.assertRaises(
+    #        TypeError, studylevel.addCourseTicket, department, department)
+
+
+class CustomerRemovalTests(CustomerImportExportSetup):
+    # Test handle_customer_removed
+    #
+    # This is a complex action updating several CSV files and moving
+    # stored files to a backup location.
+    #
+    # These tests make no assumptions about the CSV files except that
+    # they contain a deletion timestamp at end of each data row
+
+    layer = FunctionalLayer
+
+    def setUp(self):
+        super(CustomerRemovalTests, self).setUp()
+        self.setup_for_export()
+        return
+
+    def create_passport_img(self, customer):
+        # create some passport file for `customer`
+        storage = getUtility(IExtFileStore)
+        image_path = os.path.join(os.path.dirname(__file__), 'test_image.jpg')
+        self.image_contents = open(image_path, 'rb').read()
+        file_id = IFileStoreNameChooser(customer).chooseName(
+            attr='passport.jpg')
+        storage.createFile(file_id, StringIO(self.image_contents))
+
+    def test_backup_single_customer_data(self):
+        # when a single customer is removed, the data is backed up.
+        self.setup_customer(self.customer)
+        # Add a fake image
+        self.create_passport_img(self.customer)
+        handle_customer_removed(self.customer, None)
+        del_dir = self.app['datacenter'].deleted_path
+        del_img_path = os.path.join(
+            del_dir, 'media', 'customers', '00110', 'A111111',
+            'passport_A111111.jpg')
+
+        # The image was copied over
+        self.assertTrue(os.path.isfile(del_img_path))
+        self.assertEqual(
+            open(del_img_path, 'rb').read(),
+            self.image_contents)
+
+        # The customer data were put into CSV files
+        for name in EXPORTER_NAMES:
+            csv_path = os.path.join(del_dir, '%s.csv' % name)
+            self.assertTrue(os.path.isfile(csv_path))
+            contents = open(csv_path, 'rb').read().split('\r\n')
+            # We expect 3 lines output including a linebreak at end of file.
+            self.assertEqual(len(contents), 3)
+        return
+
+    def test_backup_append_csv(self):
+        # when several customers are removed, existing CSVs are appended
+        self.setup_customer(self.customer)
+        # Add a fake image
+        self.create_passport_img(self.customer)
+        del_dir = self.app['datacenter'].deleted_path
+        # put fake data into customers.csv with trailing linebreak
+        customers_csv = os.path.join(del_dir, 'customers.csv')
+        open(customers_csv, 'wb').write('line1\r\nline2\r\n')
+        handle_customer_removed(self.customer, None)
+        contents = open(customers_csv, 'rb').read().split('\r\n')
+        # there should be 4 lines in result csv (including trailing linebreak)
+        self.assertEqual(len(contents), 4)
+        return
+
+    def test_old_files_removed(self):
+        # make sure old files are not accessible any more
+        self.setup_customer(self.customer)
+        # Add a fake image
+        self.create_passport_img(self.customer)
+        # make sure we can access the image before removal
+        file_store = getUtility(IExtFileStore)
+        image = file_store.getFileByContext(self.customer, attr='passport.jpg')
+        self.assertTrue(image is not None)
+
+        # remove image (hopefully)
+        handle_customer_removed(self.customer, None)
+
+        # the is not accessible anymore
+        image = file_store.getFileByContext(self.customer, attr='passport.jpg')
+        self.assertEqual(image, None)
+        return
+
+    def test_csv_file_entries_have_timestamp(self):
+        # each row in written csv files has a ``del_date`` column to
+        # tell when the associated customer was deleted
+        self.setup_customer(self.customer)
+        del_dir = self.app['datacenter'].deleted_path
+        customers_csv = os.path.join(del_dir, 'customers.csv')
+        handle_customer_removed(self.customer, None)
+        contents = open(customers_csv, 'rb').read().split('\r\n')
+        # the CSV header ends with a ``del_date`` column
+        self.assertTrue(contents[0].endswith(',del_date'))
+        # each line ends with an UTC timestamp
+        timestamp = contents[1][-23:]
+        self.assertTrue(re.match(
+            '^\d\d-\d\d-\d\d \d\d:\d\d:\d\d\+00:00$', timestamp))
+        return
+
+class CustomerFactoryTest(FunctionalTestCase):
+
+    layer = FunctionalLayer
+
+    def setUp(self):
+        super(CustomerFactoryTest, self).setUp()
+        self.factory = CustomerFactory()
+
+    def tearDown(self):
+        super(CustomerFactoryTest, self).tearDown()
+
+    def test_interfaces(self):
+        verify.verifyClass(IFactory, CustomerFactory)
+        verify.verifyObject(IFactory, self.factory)
+
+    def test_factory(self):
+        obj = self.factory()
+        assert isinstance(obj, Customer)
+
+    def test_getInterfaces(self):
+        implemented_by = self.factory.getInterfaces()
+        assert implemented_by.isOrExtends(ICustomer)
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/viewlets.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/viewlets.py	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/viewlets.py	(revision 11958)
@@ -0,0 +1,42 @@
+3## $Id: viewlets.py 11772 2014-07-31 04:38:23Z henrik $
+##
+## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+
+import grok
+from waeup.ikoba.interfaces import IIkobaObject
+from waeup.ikoba.interfaces import MessageFactory as _
+from waeup.ikoba.browser.viewlets import (
+    PrimaryNavTab, ManageActionButton, AddActionButton)
+from waeup.ikoba.browser.layout import (
+    default_primary_nav_template, default_filedisplay_template,
+    default_fileupload_template)
+
+class CustomersTab(PrimaryNavTab):
+    """Customers tab in primary navigation.
+    """
+
+    grok.context(IIkobaObject)
+    grok.order(4)
+    grok.require('waeup.viewCustomersTab')
+    grok.name('customerstab')
+
+    pnav = 4
+    tab_title = _(u'Customers')
+
+    @property
+    def link_target(self):
+        return self.view.application_url('customers')
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/vocabularies.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/vocabularies.py	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/vocabularies.py	(revision 11958)
@@ -0,0 +1,112 @@
+## $Id: vocabularies.py 9778 2012-12-06 15:45:03Z henrik $
+##
+## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+"""Vocabularies and sources for the customer section.
+"""
+from zope.component import getUtility, queryUtility
+from zope.catalog.interfaces import ICatalog
+from zope.interface import implements, directlyProvides
+from zope.schema.interfaces import ISource, IContextSourceBinder
+from zope.schema.interfaces import ValidationError
+from zc.sourcefactory.basic import BasicSourceFactory
+from zc.sourcefactory.contextual import BasicContextualSourceFactory
+from waeup.ikoba.interfaces import SimpleIkobaVocabulary
+from waeup.ikoba.interfaces import MessageFactory as _
+from waeup.ikoba.utils.helpers import get_sorted_preferred
+from waeup.ikoba.utils.countries import COUNTRIES
+
+
+#: a tuple of tuples (<COUNTRY-NAME>, <ISO-CODE>) with Nigeria first.
+COUNTRIES = get_sorted_preferred(COUNTRIES, ['NG'])
+nats_vocab = SimpleIkobaVocabulary(*COUNTRIES)
+
+
+class GenderSource(BasicSourceFactory):
+    """A gender source delivers basically a mapping
+       ``{'m': 'Male', 'f': 'Female'}``
+
+       Using a source, we make sure that the tokens (which are
+       stored/expected for instance from CSV files) are something one
+       can expect and not cryptic IntIDs.
+    """
+    def getValues(self):
+        return ['m', 'f']
+
+    def getToken(self, value):
+        return value[0].lower()
+
+    def getTitle(self, value):
+        if value == 'm':
+            return _('male')
+        if value == 'f':
+            return _('female')
+
+class RegNumNotInSource(ValidationError):
+    """Registration number exists already
+    """
+    # The docstring of ValidationErrors is used as error description
+    # by zope.formlib.
+    pass
+
+class RegNumberSource(object):
+    """A source that accepts any entry for a certain field if not used
+    already.
+
+    Using this kind of source means a way of setting an invariant.
+
+    We accept a value iff:
+    - the value cannot be found in catalog or
+    - the value can be found as part of some item but the bound item
+      is the context object itself.
+    """
+    implements(ISource)
+    cat_name = 'customers_catalog'
+    field_name = 'reg_number'
+    validation_error = RegNumNotInSource
+    comp_field = 'customer_id'
+    def __init__(self, context):
+        self.context = context
+        return
+
+    def __contains__(self, value):
+        """We accept all values not already given to other customers.
+        """
+        cat = queryUtility(ICatalog, self.cat_name)
+        if cat is None:
+            return True
+        kw = {self.field_name: (value, value)}
+        results = cat.searchResults(**kw)
+        for entry in results:
+            if not hasattr(self.context, self.comp_field):
+                # we have no context with comp_field (most probably
+                # while adding a new object, where the container is
+                # the context) which means that the value was given
+                # already to another object (as _something_ was found in
+                # the catalog with that value). Fail on first round.
+                raise self.validation_error(value)
+            if getattr(entry, self.comp_field) != getattr(
+                self.context, self.comp_field):
+                # An entry already given to another customer is not in our
+                # range of acceptable values.
+                raise self.validation_error(value)
+                #return False
+        return True
+
+def contextual_reg_num_source(context):
+    source = RegNumberSource(context)
+    return source
+directlyProvides(contextual_reg_num_source, IContextSourceBinder)
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/workflow.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/workflow.py	(revision 11958)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/customers/workflow.py	(revision 11958)
@@ -0,0 +1,249 @@
+## $Id: batching.py 11891 2014-10-28 20:02:45Z henrik $
+##
+## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+"""Workflow for customers.
+"""
+import grok
+from datetime import datetime
+from zope.component import getUtility
+from hurry.workflow.workflow import Transition, WorkflowState, NullCondition
+from hurry.workflow.interfaces import IWorkflowState, IWorkflowTransitionEvent
+from waeup.ikoba.interfaces import (
+    IObjectHistory, IIkobaWorkflowInfo, IIkobaUtils,
+    CREATED, ADMITTED, CLEARANCE, REQUESTED, CLEARED, PAID, RETURNING,
+    REGISTERED, VALIDATED, GRADUATED, TRANSCRIPT)
+from waeup.ikoba.interfaces import MessageFactory as _
+from waeup.ikoba.workflow import IkobaWorkflow, IkobaWorkflowInfo
+from waeup.ikoba.customers.interfaces import ICustomer, ICustomersUtils
+from waeup.ikoba.utils.helpers import get_current_principal
+
+
+IMPORTABLE_STATES = (ADMITTED, CLEARANCE, REQUESTED, CLEARED, PAID, RETURNING,
+    REGISTERED, VALIDATED, GRADUATED)
+
+REGISTRATION_TRANSITIONS = (
+    Transition(
+        transition_id = 'create',
+        title = _('Create customer'),
+        source = None,
+        condition = NullCondition,
+        msg = _('Record created'),
+        destination = CREATED),
+
+    Transition(
+        transition_id = 'admit',
+        title = _('Admit customer'),
+        msg = _('Admitted'),
+        source = CREATED,
+        destination = ADMITTED),
+
+    Transition(
+        transition_id = 'reset1',
+        title = _('Reset customer'),
+        msg = _('Reset to initial state'),
+        source = ADMITTED,
+        destination = CREATED),
+
+    Transition(
+        transition_id = 'start_clearance',
+        title = _('Start clearance'),
+        msg = _('Clearance started'),
+        source = ADMITTED,
+        destination = CLEARANCE),
+
+    Transition(
+        transition_id = 'reset2',
+        title = _('Reset to admitted'),
+        msg = _("Reset to 'admitted'"),
+        source = CLEARANCE,
+        destination = ADMITTED),
+
+    Transition(
+        transition_id = 'request_clearance',
+        title = _('Request clearance'),
+        msg = _('Clearance requested'),
+        source = CLEARANCE,
+        destination = REQUESTED),
+
+    Transition(
+        transition_id = 'reset3',
+        title = _('Reset to clearance started'),
+        msg = _("Reset to 'clearance started'"),
+        source = REQUESTED,
+        destination = CLEARANCE),
+
+    Transition(
+        transition_id = 'clear',
+        title = _('Clear customer'),
+        msg = _('Cleared'),
+        source = REQUESTED,
+        destination = CLEARED),
+
+    Transition(
+        transition_id = 'reset4',
+        title = _('Reset to clearance started'),
+        msg = _("Reset to 'clearance started'"),
+        source = CLEARED,
+        destination = CLEARANCE),
+
+    Transition(
+        transition_id = 'pay_first_school_fee',
+        title = _('Pay school fee'),
+        msg = _('First school fee payment made'),
+        source = CLEARED,
+        destination = PAID),
+
+    Transition(
+        transition_id = 'approve_first_school_fee',
+        title = _('Approve payment'),
+        msg = _('First school fee payment approved'),
+        source = CLEARED,
+        destination = PAID),
+
+    Transition(
+        transition_id = 'reset5',
+        title = _('Reset to cleared'),
+        msg = _("Reset to 'cleared'"),
+        source = PAID,
+        destination = CLEARED),
+
+    Transition(
+        transition_id = 'pay_school_fee',
+        title = _('Pay school fee'),
+        msg = _('School fee payment made'),
+        source = RETURNING,
+        destination = PAID),
+
+    Transition(
+        transition_id = 'pay_pg_fee',
+        title = _('Pay PG school fee'),
+        msg = _('PG school fee payment made'),
+        source = PAID,
+        destination = PAID),
+
+    Transition(
+        transition_id = 'approve_school_fee',
+        title = _('Approve school fee payment'),
+        msg = _('School fee payment approved'),
+        source = RETURNING,
+        destination = PAID),
+
+    Transition(
+        transition_id = 'approve_pg_fee',
+        title = _('Approve PG school fee payment'),
+        msg = _('PG school fee payment approved'),
+        source = PAID,
+        destination = PAID),
+
+    Transition(
+        transition_id = 'reset6',
+        title = _('Reset to returning'),
+        msg = _("Reset to 'returning'"),
+        source = PAID,
+        destination = RETURNING),
+
+    Transition(
+        transition_id = 'register_courses',
+        title = _('Register courses'),
+        msg = _('Courses registered'),
+        source = PAID,
+        destination = REGISTERED),
+
+    Transition(
+        transition_id = 'reset7',
+        title = _('Reset to school fee paid'),
+        msg = _("Reset to 'school fee paid'"),
+        source = REGISTERED,
+        destination = PAID),
+
+    Transition(
+        transition_id = 'validate_courses',
+        title = _('Validate courses'),
+        msg = _('Courses validated'),
+        source = REGISTERED,
+        destination = VALIDATED),
+
+    Transition(
+        transition_id = 'bypass_validation',
+        title = _('Return and bypass validation'),
+        msg = _("Course validation bypassed"),
+        source = REGISTERED,
+        destination = RETURNING),
+
+    Transition(
+        transition_id = 'reset8',
+        title = _('Reset to school fee paid'),
+        msg = _("Reset to 'school fee paid'"),
+        source = VALIDATED,
+        destination = PAID),
+
+    Transition(
+        transition_id = 'return',
+        title = _('Return'),
+        msg = _("Returned"),
+        source = VALIDATED,
+        destination = RETURNING),
+
+    Transition(
+        transition_id = 'reset9',
+        title = _('Reset to courses validated'),
+        msg = _("Reset to 'courses validated'"),
+        source = RETURNING,
+        destination = VALIDATED),
+    )
+
+
+IMPORTABLE_TRANSITIONS = [i.transition_id for i in REGISTRATION_TRANSITIONS]
+
+registration_workflow = IkobaWorkflow(REGISTRATION_TRANSITIONS)
+
+class RegistrationWorkflowState(WorkflowState, grok.Adapter):
+    """An adapter to adapt Customer objects to workflow states.
+    """
+    grok.context(ICustomer)
+    grok.provides(IWorkflowState)
+
+    state_key = 'wf.registration.state'
+    state_id = 'wf.registration.id'
+
+class RegistrationWorkflowInfo(IkobaWorkflowInfo, grok.Adapter):
+    """Adapter to adapt Customer objects to workflow info objects.
+    """
+    grok.context(ICustomer)
+    grok.provides(IIkobaWorkflowInfo)
+
+    def __init__(self, context):
+        self.context = context
+        self.wf = registration_workflow
+
+@grok.subscribe(ICustomer, IWorkflowTransitionEvent)
+def handle_customer_transition_event(obj, event):
+    """Append message to customer history and log file when transition happened.
+
+    Lock and unlock clearance form.
+    Trigger actions after school fee payment.
+    """
+
+    msg = event.transition.user_data['msg']
+    history = IObjectHistory(obj)
+    history.addMessage(msg)
+    try:
+        customers_container = grok.getSite()['customers']
+        customers_container.logger.info('%s - %s' % (obj.customer_id,msg))
+    except (TypeError, AttributeError):
+        pass
+    return
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/interfaces.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/interfaces.py	(revision 11957)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/interfaces.py	(revision 11958)
@@ -101,6 +101,4 @@
     (_('courses validated'), VALIDATED),
     (_('returning'), RETURNING),
-    (_('graduated'), GRADUATED),
-    (_('transcript requested'), TRANSCRIPT),
     )
 
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/permissions.py
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/permissions.py	(revision 11957)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/permissions.py	(revision 11958)
@@ -156,4 +156,8 @@
                      'waeup.manageReports',
                      'waeup.manageJobs',
+                     'waeup.viewCustomer', 'waeup.viewCustomers',
+                     'waeup.manageCustomer', 'waeup.viewCustomersContainer',
+                     'waeup.payCustomer', 'waeup.uploadCustomerFile',
+                     'waeup.viewCustomersTab'
                      )
 
@@ -177,4 +181,8 @@
                      'waeup.manageReports',
                      #'waeup.manageJobs',
+                     'waeup.viewCustomer', 'waeup.viewCustomers',
+                     'waeup.manageCustomer', 'waeup.viewCustomersContainer',
+                     'waeup.payCustomer', 'waeup.uploadCustomerFile',
+                     'waeup.viewCustomersTab'
                      )
 
Index: main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/permissions.txt
===================================================================
--- main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/permissions.txt	(revision 11957)
+++ main/waeup.kofa/branches/henrik-regista/src/waeup/ikoba/permissions.txt	(revision 11958)
@@ -29,5 +29,5 @@
 
     >>> sorted(list(get_all_roles()))
-    [(u'waeup.DataCenterManager', <waeup.ikoba.permissions.DataCenterManager object at 0x...]
+    [(u'waeup.Customer', <waeup.ikoba.customers.permissions.CustomerRole object at 0x...]
 
 :func:`get_waeup_roles`
@@ -39,8 +39,8 @@
     >>> from waeup.ikoba.permissions import get_waeup_roles
     >>> len(list(get_waeup_roles()))
-    9
+    13
 
     >>> len(list(get_waeup_roles(also_local=True)))
-    10
+    15
 
 
@@ -53,5 +53,9 @@
     >>> from waeup.ikoba.permissions import get_waeup_role_names
     >>> list(get_waeup_role_names())
-    [u'waeup.DataCenterManager',
+    [u'waeup.Customer',
+    u'waeup.CustomerImpersonator',
+    u'waeup.CustomersManager',
+    u'waeup.CustomersOfficer',
+    u'waeup.DataCenterManager',
     u'waeup.ExportManager',
     u'waeup.ImportManager',
