Index: main/waeup.ikoba/trunk/src/waeup/ikoba/app.txt
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/app.txt	(revision 11967)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/app.txt	(revision 11971)
@@ -1,4 +1,4 @@
 :mod:`waeup.ikoba.app` -- central components for Ikoba
-****************************************************
+******************************************************
 
 .. :doctest:
@@ -25,5 +25,5 @@
 
 Creating `Company` instances
-===============================
+============================
 
 As :class:`Company` instances make use of the Zope Component
@@ -69,5 +69,5 @@
 
 Ikoba plugins
-============
+=============
 
 waeup.ikoba provides an API to 'plugin' components. Things that should
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/browser/templates/default_waeup_edit_form.pt
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/browser/templates/default_waeup_edit_form.pt	(revision 11967)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/browser/templates/default_waeup_edit_form.pt	(revision 11971)
@@ -5,4 +5,5 @@
     <tbody>
       <tal:widgets content="structure provider:widgets" />
+      <tal:files content="structure provider:files" />
     </tbody>
   </table>
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser.py
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser.py	(revision 11967)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser.py	(revision 11971)
@@ -33,5 +33,7 @@
 from hurry.workflow.interfaces import IWorkflowInfo, IWorkflowState
 from waeup.ikoba.interfaces import MessageFactory as _
-from waeup.ikoba.interfaces import IContactForm, IObjectHistory, IIkobaObject
+from waeup.ikoba.interfaces import (
+    IContactForm, IObjectHistory, IIkobaObject, IIkobaUtils,
+    IPasswordValidator, IUserAccount)
 from waeup.ikoba.browser.layout import (
     IkobaPage, IkobaEditFormPage, IkobaAddFormPage, IkobaDisplayFormPage,
@@ -39,4 +41,5 @@
 from waeup.ikoba.browser.pages import ContactAdminForm
 from waeup.ikoba.browser.breadcrumbs import Breadcrumb
+from waeup.ikoba.browser.interfaces import ICaptchaManager
 from waeup.ikoba.utils.helpers import get_current_principal, to_timezone, now
 from waeup.ikoba.customers.interfaces import (
@@ -485,5 +488,5 @@
             return
 
-        kofa_utils = getUtility(IKofaUtils)
+        kofa_utils = getUtility(IIkobaUtils)
         password = kofa_utils.genPassword()
         mandate = PasswordMandate()
@@ -523,2 +526,65 @@
         self.customer_id = customer_id
         return
+
+class CustomerFilesUploadPage(IkobaPage):
+    """ View to upload files by customer
+    """
+    grok.context(ICustomer)
+    grok.name('change_portrait')
+    grok.require('waeup.uploadCustomerFile')
+    grok.template('filesuploadpage')
+    label = _('Upload files')
+    pnav = 4
+
+    def update(self):
+        PWCHANGE_STATES = getUtility(ICustomersUtils).PWCHANGE_STATES
+        if self.context.customer.state not in PWCHANGE_STATES:
+            emit_lock_message(self)
+            return
+        super(CustomerFilesUploadPage, self).update()
+        return
+
+# Pages for customers only
+
+class CustomerBaseEditFormPage(IkobaEditFormPage):
+    """ View to edit customer base data
+    """
+    grok.context(ICustomer)
+    grok.name('edit_base')
+    grok.require('waeup.handleCustomer')
+    form_fields = grok.AutoFields(ICustomer).select(
+        'email', 'phone')
+    label = _('Edit base data')
+    pnav = 4
+
+    @action(_('Save'), style='primary')
+    def save(self, **data):
+        msave(self, **data)
+        return
+
+class CustomerChangePasswordPage(IkobaEditFormPage):
+    """ View to edit customer passords
+    """
+    grok.context(ICustomer)
+    grok.name('change_password')
+    grok.require('waeup.handleCustomer')
+    grok.template('change_password')
+    label = _('Change password')
+    pnav = 4
+
+    @action(_('Save'), style='primary')
+    def save(self, **data):
+        form = self.request.form
+        password = form.get('change_password', None)
+        password_ctl = form.get('change_password_repeat', None)
+        if password:
+            validator = getUtility(IPasswordValidator)
+            errors = validator.validate_password(password, password_ctl)
+            if not errors:
+                IUserAccount(self.context).setPassword(password)
+                self.context.writeLogMessage(self, 'saved: password')
+                self.flash(_('Password changed.'))
+            else:
+                self.flash( ' '.join(errors), type="warning")
+        return
+
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/basemanagepage.pt
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/basemanagepage.pt	(revision 11971)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/basemanagepage.pt	(revision 11971)
@@ -0,0 +1,55 @@
+<form action="." tal:attributes="action request/URL" method="post"
+      i18n:domain="waeup.kofa" enctype="multipart/form-data"
+      autocomplete="off">
+
+  <ul class="tabs nav nav-tabs" data-tabs="tabs">
+    <li class="active">
+      <a href="#tab1" data-toggle="tab">
+      <span i18n:translate="">Base Data</span></a>
+    </li>
+    <li>
+      <a href="#tab2" data-toggle="tab">
+        <span i18n:translate="">Passport Picture</span>
+      </a>
+    </li>
+  </ul>
+
+  <div class="tab-content">
+    <div id="tab1" class="active tab-pane">
+      <br />
+      <table class="form-table">
+        <tbody>
+          <tal:widgets content="structure provider:widgets" />
+          <tr>
+            <td class="fieldname" i18n:translate="">Password:</td>
+            <td>
+              <input name="password" class="form-control" type="password"  />
+            </td>
+          </tr>
+          <tr>
+            <td class="fieldname" i18n:translate="">Retype Password:</td>
+            <td>
+              <input name="control_password" class="form-control" type="password" />
+            </td>
+          </tr>
+
+        </tbody>
+      </table>
+
+      <div tal:condition="view/availableActions">
+        <input tal:repeat="action view/actions"
+               tal:replace="structure action/render"
+               />
+      </div>
+    </div>
+
+    <div id="tab2" class="tab-pane">
+      <br />
+      <table class="form-table">
+        <tbody>
+          <tal:files content="structure provider:files" />
+        </tbody>
+      </table>
+    </div>
+  </div>
+</form>
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/basepage.pt
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/basepage.pt	(revision 11967)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/basepage.pt	(revision 11971)
@@ -19,4 +19,5 @@
       </td>
     </tr>
+    <tal:files content="structure provider:files" />
   </tbody>
   <tfoot>
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/change_password.pt
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/change_password.pt	(revision 11971)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/change_password.pt	(revision 11971)
@@ -0,0 +1,29 @@
+<form action="." tal:attributes="action request/URL" method="post"
+      i18n:domain="waeup.kofa" enctype="multipart/form-data">
+
+  <input type="hidden" name="student_id" value=""
+         tal:attributes="value context/student_id" />
+
+  <table class="form-table">
+    <tbody>
+      <tr>
+        <td class="fieldname" i18n:translate="">Password:</td>
+        <td>
+          <input name="change_password" class="form-control" type="password"  />
+        </td>
+      </tr>
+      <tr>
+        <td class="fieldname" i18n:translate="">Retype Password:</td>
+        <td>
+          <input name="change_password_repeat" class="form-control" type="password" />
+        </td>
+      </tr>
+    </tbody>
+  </table>
+
+  <div tal:condition="view/availableActions">
+    <input tal:repeat="action view/actions"
+           tal:replace="structure action/render"
+           />
+  </div>
+</form>
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/filesuploadpage.pt
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/filesuploadpage.pt	(revision 11971)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/filesuploadpage.pt	(revision 11971)
@@ -0,0 +1,8 @@
+<form action="." tal:attributes="action request/URL" method="post"
+      enctype="multipart/form-data">
+  <table class="form-table">
+    <tbody>
+      <tal:files content="structure provider:files" />
+    </tbody>
+  </table>
+</form>
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/imagedisplay.pt
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/imagedisplay.pt	(revision 11971)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/imagedisplay.pt	(revision 11971)
@@ -0,0 +1,10 @@
+<tr>
+  <td class="fieldname">
+        <span tal:replace="viewlet/label">FILENAME</span>:
+  </td>
+  <td>
+      <img height="125px"
+         tal:attributes="src python: view.url(context, viewlet.download_name)" />
+      <br />
+  </td>
+</tr>
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/imageupload.pt
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/imageupload.pt	(revision 11971)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/customers/browser_templates/imageupload.pt	(revision 11971)
@@ -0,0 +1,33 @@
+<tr i18n:domain="waeup.kofa">
+  <td class="fieldname">
+    <span tal:replace="viewlet/label">FILENAME</span>:
+  </td>
+  <td>
+    <img align="middle" height="125px"
+         tal:attributes="src python: view.url(context, viewlet.download_name)" />
+    &nbsp;
+    <input type="submit" tal:condition="viewlet/file_exists"
+     tal:attributes="id python:('delete_%s' % viewlet.input_name);
+        name python:('delete_%s' % viewlet.input_name)"
+     value="Delete" class="btn btn-danger" />
+    <br /><br />
+
+		<div class="input-group">
+			<div class="input-group-btn">
+				<div class="btn btn-default btn-file">
+          Select new file&hellip;
+          <input type="file" tal:attributes="name viewlet/input_name" />
+        </div>
+			</div>
+			<input type="text" class="form-control" readonly>
+      <div class="input-group-btn">
+        <input type="submit"
+               tal:attributes="id python:('upload_%s' % viewlet.input_name);
+                name python:('upload_%s' % viewlet.input_name);
+                value viewlet/upload_button"
+               class="btn btn-primary" />
+        (<span tal:replace="viewlet/max_upload_size">10 kB</span> max.)
+      </div>
+    </div>
+  </td>
+</tr>
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/customers/files.py
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/customers/files.py	(revision 11971)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/customers/files.py	(revision 11971)
@@ -0,0 +1,245 @@
+## $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 os
+import grok
+from zope.component import getUtility
+from zope.interface import Interface
+from waeup.ikoba.interfaces import (
+    IIkobaObject, IExtFileStore, IFileStoreNameChooser)
+from waeup.ikoba.interfaces import MessageFactory as _
+from waeup.ikoba.browser import DEFAULT_IMAGE_PATH
+from waeup.ikoba.utils.helpers import string_from_bytes, file_size
+from waeup.ikoba.browser.layout import (
+    default_filedisplay_template,
+    default_fileupload_template)
+
+from waeup.ikoba.customers.interfaces import ICustomer, ICustomersUtils
+from waeup.ikoba.customers.browser import (
+    CustomerBaseDisplayFormPage, CustomerBaseManageFormPage,
+    CustomerFilesUploadPage)
+
+grok.context(IIkobaObject) # Make IIkobaObject the default context
+grok.templatedir('browser_templates')
+
+def handle_file_delete(context, view, download_name):
+    """Handle deletion of customer file.
+
+    """
+    store = getUtility(IExtFileStore)
+    store.deleteFileByContext(context, attr=download_name)
+    context.writeLogMessage(view, 'deleted: %s' % download_name)
+    view.flash(_('${a} deleted.', mapping = {'a':download_name}))
+    return
+
+def handle_file_upload(upload, context, view, max_size, download_name=None):
+    """Handle upload of customer file.
+
+    Returns `True` in case of success or `False`.
+
+    Please note that file pointer passed in (`upload`) most probably
+    points to end of file when leaving this function.
+    """
+    # Check some file requirements first
+    size = file_size(upload)
+    if size > max_size:
+        view.flash(_('Uploaded file is too big.'), type="danger")
+        return False
+    upload.seek(0) # file pointer moved when determining size
+    dummy,ext = os.path.splitext(upload.filename)
+    # fpm files are expected to be fingerprint minutiae, file
+    # format is not yet checked
+    if ext == '.fpm':
+        file_format = 'fpm'
+    else:
+        file_format = get_fileformat(None, upload.read(512))
+        upload.seek(0) # same here
+    if file_format is None:
+        view.flash(_('Could not determine file type.'), type="danger")
+        return False
+    basename, expected_ext = os.path.splitext(download_name)
+    if expected_ext:
+        if '.' + file_format != expected_ext:
+            view.flash(_('${a} file extension expected.',
+                mapping = {'a':expected_ext[1:]}), type="danger")
+            return False
+    else:
+        if not file_format in ALLOWED_FILE_EXTENSIONS:
+            view.flash(
+                _('Only the following extensions are allowed: ${a}',
+                mapping = {'a':', '.join(ALLOWED_FILE_EXTENSIONS)}),
+                type="danger")
+            return False
+        download_name += '.' + file_format
+    store = getUtility(IExtFileStore)
+    file_id = IFileStoreNameChooser(context).chooseName(attr=download_name)
+    store.createFile(file_id, upload)
+    context.writeLogMessage(view, 'uploaded: %s (%s)' % (
+        download_name,upload.filename))
+    view.flash(_('File ${a} uploaded.', mapping = {'a':download_name}))
+    return True
+
+class FileManager(grok.ViewletManager):
+    """Viewlet manager for uploading files, preferably scanned images.
+    """
+    grok.name('files')
+
+class FileDisplay(grok.Viewlet):
+    """Base file display viewlet.
+    """
+    grok.baseclass()
+    grok.context(ICustomer)
+    grok.viewletmanager(FileManager)
+    grok.view(CustomerBaseDisplayFormPage)
+    template = default_filedisplay_template
+    grok.order(1)
+    grok.require('waeup.viewCustomer')
+    label = _(u'File')
+    title = _(u'Scan')
+    download_name = u'filename.jpg'
+
+    @property
+    def file_exists(self):
+        image = getUtility(IExtFileStore).getFileByContext(
+            self.context, attr=self.download_name)
+        if image:
+            return True
+        else:
+            return False
+
+class FileUpload(FileDisplay):
+    """Base upload viewlet.
+    """
+    grok.baseclass()
+    grok.context(ICustomer)
+    grok.viewletmanager(FileManager)
+    grok.view(CustomerBaseManageFormPage)
+    template = default_fileupload_template
+    grok.require('waeup.uploadCustomerFile')
+    tab_redirect = '#tab2-top'
+    mus = 1024 * 150
+    upload_button =_('Upload selected file')
+    delete_button = _('Delete')
+
+    @property
+    def show_viewlet(self):
+        customers_utils = getUtility(ICustomersUtils)
+        if self.__name__ in customers_utils.SKIP_UPLOAD_VIEWLETS:
+            return False
+        return True
+
+    @property
+    def input_name(self):
+        return "%s" % self.__name__
+
+    def update(self):
+        self.max_upload_size = string_from_bytes(self.mus)
+        delete_button = self.request.form.get(
+            'delete_%s' % self.input_name, None)
+        upload_button = self.request.form.get(
+            'upload_%s' % self.input_name, None)
+        if delete_button:
+            handle_file_delete(
+                context=self.context, view=self.view,
+                download_name=self.download_name)
+            self.view.redirect(
+                self.view.url(
+                    self.context, self.view.__name__) + self.tab_redirect)
+            return
+        if upload_button:
+            upload = self.request.form.get(self.input_name, None)
+            if upload:
+                # We got a fresh upload
+                handle_file_upload(upload,
+                    self.context, self.view, self.mus, self.download_name)
+                self.view.redirect(
+                    self.view.url(
+                        self.context, self.view.__name__) + self.tab_redirect)
+            else:
+                self.view.flash(_('No local file selected.'), type="danger")
+                self.view.redirect(
+                    self.view.url(
+                        self.context, self.view.__name__) + self.tab_redirect)
+        return
+
+class PassportDisplay(FileDisplay):
+    """Passport display viewlet.
+    """
+    grok.order(1)
+    grok.context(ICustomer)
+    grok.require('waeup.viewCustomer')
+    grok.template('imagedisplay')
+    label = _(u'Passport Picture')
+    download_name = u'passport.jpg'
+
+class PassportUploadManage(FileUpload):
+    """Passport upload viewlet for officers.
+    """
+    grok.order(1)
+    grok.context(ICustomer)
+    grok.require('waeup.manageCustomer')
+    grok.template('imageupload')
+    label = _(u'Passport Picture (jpg only)')
+    mus = 1024 * 50
+    download_name = u'passport.jpg'
+    tab_redirect = '#tab2'
+
+class PassportUploadEdit(PassportUploadManage):
+    """Passport upload viewlet for customers.
+    """
+    grok.view(CustomerFilesUploadPage)
+    grok.require('waeup.uploadCustomerFile')
+
+class Image(grok.View):
+    """Renders images for customers.
+    """
+    grok.baseclass()
+    grok.name('none.jpg')
+    grok.context(ICustomer)
+    grok.require('waeup.viewCustomer')
+    download_name = u'none.jpg'
+
+    def render(self):
+        # A filename chooser turns a context into a filename suitable
+        # for file storage.
+        image = getUtility(IExtFileStore).getFileByContext(
+            self.context, attr=self.download_name)
+        if image is None:
+            # show placeholder image
+            self.response.setHeader('Content-Type', 'image/jpeg')
+            return open(DEFAULT_IMAGE_PATH, 'rb').read()
+        dummy,ext = os.path.splitext(image.name)
+        if ext == '.jpg':
+            self.response.setHeader('Content-Type', 'image/jpeg')
+        elif ext == '.fpm':
+            self.response.setHeader('Content-Type', 'application/binary')
+        elif ext == '.png':
+            self.response.setHeader('Content-Type', 'image/png')
+        elif ext == '.pdf':
+            self.response.setHeader('Content-Type', 'application/pdf')
+        elif ext == '.tif':
+            self.response.setHeader('Content-Type', 'image/tiff')
+        return image
+
+class Passport(Image):
+    """Renders jpeg passport picture.
+    """
+    grok.name('passport.jpg')
+    download_name = u'passport.jpg'
+    grok.context(ICustomer)
+
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/customers/tests/test_browser.py
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/customers/tests/test_browser.py	(revision 11971)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/customers/tests/test_browser.py	(revision 11971)
@@ -0,0 +1,449 @@
+## $Id: test_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
+##
+"""
+Test the customer-related UI components.
+"""
+import shutil
+import tempfile
+import pytz
+import base64
+from datetime import datetime, timedelta, date
+from StringIO import StringIO
+import os
+import grok
+from zc.async.testing import wait_for_result
+from zope.event import notify
+from zope.component import createObject, queryUtility, getUtility
+from zope.component.hooks import setSite, clearSite
+from zope.catalog.interfaces import ICatalog
+from zope.security.interfaces import Unauthorized
+from zope.securitypolicy.interfaces import IPrincipalRoleManager
+from zope.testbrowser.testing import Browser
+from hurry.workflow.interfaces import IWorkflowInfo, IWorkflowState
+from waeup.ikoba.testing import FunctionalLayer, FunctionalTestCase
+from waeup.ikoba.app import Company
+from waeup.ikoba.customers.interfaces import ICustomersUtils
+from waeup.ikoba.customers.customer import Customer
+from waeup.ikoba.interfaces import IUserAccount, IJobManager
+from waeup.ikoba.authentication import LocalRoleSetEvent
+from waeup.ikoba.tests.test_async import FunctionalAsyncTestCase
+
+PH_LEN = 15911  # Length of placeholder file
+
+SAMPLE_IMAGE = os.path.join(os.path.dirname(__file__), 'test_image.jpg')
+SAMPLE_IMAGE_BMP = os.path.join(os.path.dirname(__file__), 'test_image.bmp')
+
+def lookup_submit_value(name, value, browser):
+    """Find a button with a certain value."""
+    for num in range(0, 100):
+        try:
+            button = browser.getControl(name=name, index=num)
+            if button.value.endswith(value):
+                return button
+        except IndexError:
+            break
+    return None
+
+class CustomersFullSetup(FunctionalTestCase):
+    # A test case that only contains a setup and teardown
+    #
+    # Complete setup for customers handlings is rather complex and
+    # requires lots of things created before we can start. This is a
+    # setup that does all this, creates a university, creates PINs,
+    # etc.  so that we do not have to bother with that in different
+    # test cases.
+
+    layer = FunctionalLayer
+
+    def setUp(self):
+        super(CustomersFullSetup, self).setUp()
+
+        # Setup a sample site for each test
+        app = Company()
+        self.dc_root = tempfile.mkdtemp()
+        app['datacenter'].setStoragePath(self.dc_root)
+
+        # Prepopulate the ZODB...
+        self.getRootFolder()['app'] = app
+        # we add the site immediately after creation to the
+        # ZODB. Catalogs and other local utilities are not setup
+        # before that step.
+        self.app = self.getRootFolder()['app']
+        # Set site here. Some of the following setup code might need
+        # to access grok.getSite() and should get our new app then
+        setSite(app)
+
+        # Add customer with subobjects
+        customer = createObject('waeup.Customer')
+        customer.firstname = u'Anna'
+        customer.lastname = u'Tester'
+        customer.reg_number = u'123'
+        customer.sex = u'm'
+        customer.email = 'aa@aa.ng'
+        customer.phone = u'1234'
+        customer.date_of_birth = date(1981, 2, 4)
+        self.app['customers'].addCustomer(customer)
+        self.customer_id = customer.customer_id
+        self.customer = self.app['customers'][self.customer_id]
+
+        # Set password
+        IUserAccount(
+            self.app['customers'][self.customer_id]).setPassword('spwd')
+
+        self.login_path = 'http://localhost/app/login'
+        self.container_path = 'http://localhost/app/customers'
+        self.manage_container_path = self.container_path + '/@@manage'
+        self.add_customer_path = self.container_path + '/addcustomer'
+        self.customer_path = self.container_path + '/' + self.customer_id
+        self.manage_customer_path = self.customer_path + '/manage_base'
+        self.trigtrans_path = self.customer_path + '/trigtrans'
+        self.history_path = self.customer_path + '/history'
+
+        self.app['configuration'].carry_over = True
+        configuration = createObject('waeup.SessionConfiguration')
+        self.app['configuration'].addSessionConfiguration(configuration)
+
+        # Update the catalog
+        notify(grok.ObjectModifiedEvent(self.customer))
+
+        # Put the prepopulated site into test ZODB and prepare test
+        # browser
+        self.browser = Browser()
+        self.browser.handleErrors = False
+
+    def tearDown(self):
+        super(CustomersFullSetup, self).tearDown()
+        clearSite()
+        shutil.rmtree(self.dc_root)
+
+
+
+class CustomersContainerUITests(CustomersFullSetup):
+    # Tests for CustomersContainer class views and pages
+
+    layer = FunctionalLayer
+
+    def test_anonymous_access(self):
+        # Anonymous users can't access customers containers
+        self.assertRaises(
+            Unauthorized, self.browser.open, self.container_path)
+        self.assertRaises(
+            Unauthorized, self.browser.open, self.manage_container_path)
+        return
+
+    def test_manage_access(self):
+        # Managers can access the view page of customers
+        # containers and can perform actions
+        self.browser.addHeader('Authorization', 'Basic mgr:mgrpw')
+        self.browser.open(self.container_path)
+        self.assertEqual(self.browser.headers['Status'], '200 Ok')
+        self.assertEqual(self.browser.url, self.container_path)
+        self.browser.getLink("Manage customer section").click()
+        self.assertEqual(self.browser.headers['Status'], '200 Ok')
+        self.assertEqual(self.browser.url, self.manage_container_path)
+        return
+
+    def test_add_search_delete_customers(self):
+        # Managers can add search and remove customers
+        self.browser.addHeader('Authorization', 'Basic mgr:mgrpw')
+        self.browser.open(self.manage_container_path)
+        self.browser.getLink("Add customer").click()
+        self.assertEqual(self.browser.headers['Status'], '200 Ok')
+        self.assertEqual(self.browser.url, self.add_customer_path)
+        self.browser.getControl(name="form.firstname").value = 'Bob'
+        self.browser.getControl(name="form.lastname").value = 'Tester'
+        self.browser.getControl(name="form.reg_number").value = '123'
+        self.browser.getControl("Create customer record").click()
+        self.assertTrue('Registration number exists already'
+            in self.browser.contents)
+        self.browser.getControl(name="form.reg_number").value = '1234'
+        self.browser.getControl("Create customer record").click()
+        self.assertTrue('Customer record created' in self.browser.contents)
+
+        # Registration must be unique
+        self.browser.getLink("Manage").click()
+        self.browser.getControl(name="form.reg_number").value = '123'
+        self.browser.getControl("Save").click()
+        self.assertMatches('...Registration number exists...',
+                           self.browser.contents)
+
+        # We can find a customer with a certain customer_id
+        self.browser.open(self.container_path)
+        self.browser.getControl("Find customer(s)").click()
+        self.assertTrue('Empty search string' in self.browser.contents)
+        self.browser.getControl(name="searchtype").value = ['customer_id']
+        self.browser.getControl(name="searchterm").value = self.customer_id
+        self.browser.getControl("Find customer(s)").click()
+        self.assertTrue('Anna Tester' in self.browser.contents)
+
+        # We can find a customer by searching for all kind of name parts
+        self.browser.open(self.manage_container_path)
+        self.browser.getControl("Find customer(s)").click()
+        self.assertTrue('Empty search string' in self.browser.contents)
+        self.browser.getControl(name="searchtype").value = ['fullname']
+        self.browser.getControl(name="searchterm").value = 'Anna Tester'
+        self.browser.getControl("Find customer(s)").click()
+        self.assertTrue('Anna Tester' in self.browser.contents)
+        self.browser.open(self.manage_container_path)
+        self.browser.getControl(name="searchtype").value = ['fullname']
+        self.browser.getControl(name="searchterm").value = 'Anna'
+        self.browser.getControl("Find customer(s)").click()
+        self.assertTrue('Anna Tester' in self.browser.contents)
+        self.browser.open(self.manage_container_path)
+        self.browser.getControl(name="searchtype").value = ['fullname']
+        self.browser.getControl(name="searchterm").value = 'Tester'
+        self.browser.getControl("Find customer(s)").click()
+        self.assertTrue('Anna Tester' in self.browser.contents)
+        self.browser.open(self.manage_container_path)
+        self.browser.getControl(name="searchtype").value = ['fullname']
+        self.browser.getControl(name="searchterm").value = 'An'
+        self.browser.getControl("Find customer(s)").click()
+        self.assertFalse('Anna Tester' in self.browser.contents)
+        self.browser.open(self.manage_container_path)
+        self.browser.getControl(name="searchtype").value = ['fullname']
+        self.browser.getControl(name="searchterm").value = 'An*'
+        self.browser.getControl("Find customer(s)").click()
+        self.assertTrue('Anna Tester' in self.browser.contents)
+        self.browser.open(self.manage_container_path)
+        self.browser.getControl(name="searchtype").value = ['fullname']
+        self.browser.getControl(name="searchterm").value = 'tester'
+        self.browser.getControl("Find customer(s)").click()
+        self.assertTrue('Anna Tester' in self.browser.contents)
+        self.browser.open(self.manage_container_path)
+        self.browser.getControl(name="searchtype").value = ['fullname']
+        self.browser.getControl(name="searchterm").value = 'Tester Ana'
+        self.browser.getControl("Find customer(s)").click()
+        self.assertFalse('Anna Tester' in self.browser.contents)
+        self.browser.open(self.manage_container_path)
+        self.browser.getControl(name="searchtype").value = ['fullname']
+        self.browser.getControl(name="searchterm").value = 'Tester Anna'
+        self.browser.getControl("Find customer(s)").click()
+        self.assertTrue('Anna Tester' in self.browser.contents)
+        # The old searchterm will be used again
+        self.browser.getControl("Find customer(s)").click()
+        self.assertTrue('Anna Tester' in self.browser.contents)
+
+        # We can find suspended customers
+        self.customer.suspended = True
+        notify(grok.ObjectModifiedEvent(self.customer))
+        self.browser.open(self.manage_container_path)
+        self.browser.getControl(name="searchtype").value = ['suspended']
+        self.browser.getControl("Find customer(s)").click()
+        self.assertTrue('Anna Tester' in self.browser.contents)
+
+        # Removed customers won't be found
+        ctrl = self.browser.getControl(name='entries')
+        ctrl.getControl(value=self.customer_id).selected = True
+        self.browser.getControl("Remove selected", index=0).click()
+        self.assertTrue('Successfully removed' in self.browser.contents)
+        self.browser.getControl(name="searchtype").value = ['customer_id']
+        self.browser.getControl(name="searchterm").value = self.customer_id
+        self.browser.getControl("Find customer(s)").click()
+        self.assertTrue('No customer found' in self.browser.contents)
+        return
+
+class OfficerUITests(CustomersFullSetup):
+    # Tests for Customer class views and pages
+
+    def test_basic_auth(self):
+        self.browser.addHeader('Authorization', 'Basic mgr:mgrpw')
+        self.browser.open('http://localhost/app')
+        self.browser.getLink("Logout").click()
+        self.assertTrue('You have been logged out' in self.browser.contents)
+        # But we are still logged in since we've used basic authentication here.
+        # Wikipedia says: Existing browsers retain authentication information
+        # until the tab or browser is closed or the user clears the history.
+        # HTTP does not provide a method for a server to direct clients to
+        # discard these cached credentials. This means that there is no
+        # effective way for a server to "log out" the user without closing
+        # the browser. This is a significant defect that requires browser
+        # manufacturers to support a "logout" user interface element ...
+        self.assertTrue('Manager' in self.browser.contents)
+
+    def test_basic_auth_base64(self):
+        auth_token = base64.b64encode('mgr:mgrpw')
+        self.browser.addHeader('Authorization', 'Basic %s' % auth_token)
+        self.browser.open(self.manage_container_path)
+        self.assertEqual(self.browser.headers['Status'], '200 Ok')
+
+    def test_manage_access(self):
+        # Managers can access the pages of customers
+        # and can perform actions
+        self.browser.addHeader('Authorization', 'Basic mgr:mgrpw')
+        self.browser.open(self.customer_path)
+        self.assertEqual(self.browser.headers['Status'], '200 Ok')
+        self.assertEqual(self.browser.url, self.customer_path)
+        self.browser.getLink("Trigger").click()
+        self.assertEqual(self.browser.headers['Status'], '200 Ok')
+        # Managers can trigger transitions
+        self.browser.getControl(name="transition").value = ['start']
+        self.browser.getControl("Save").click()
+        # Managers can edit base
+        self.browser.open(self.customer_path)
+        self.browser.getLink("Manage").click()
+        self.assertEqual(self.browser.url, self.manage_customer_path)
+        self.assertEqual(self.browser.headers['Status'], '200 Ok')
+        self.browser.getControl(name="form.firstname").value = 'John'
+        self.browser.getControl(name="form.lastname").value = 'Tester'
+        self.browser.getControl(name="form.reg_number").value = '345'
+        self.browser.getControl(name="password").value = 'secret'
+        self.browser.getControl(name="control_password").value = 'secret'
+        self.browser.getControl("Save").click()
+        self.assertMatches('...Form has been saved...',
+                           self.browser.contents)
+
+    def test_manage_contact_customer(self):
+        # Managers can contact customer
+        self.browser.addHeader('Authorization', 'Basic mgr:mgrpw')
+        self.customer.email = None
+        self.browser.open(self.customer_path)
+        self.browser.getLink("Send email").click()
+        self.browser.getControl(name="form.subject").value = 'Important subject'
+        self.browser.getControl(name="form.body").value = 'Hello!'
+        self.browser.getControl("Send message now").click()
+        self.assertTrue('An smtp server error occurred' in self.browser.contents)
+        self.customer.email = 'xx@yy.zz'
+        self.browser.getControl("Send message now").click()
+        self.assertTrue('Your message has been sent' in self.browser.contents)
+        return
+
+    def xxx_test_manage_upload_file(self):
+        # Managers can upload a file via the CustomerClearanceManageFormPage
+        # The image is stored even if form has errors
+        self.browser.addHeader('Authorization', 'Basic mgr:mgrpw')
+        self.browser.open(self.manage_clearance_path)
+        # No birth certificate has been uploaded yet
+        # Browsing the link shows a placerholder image
+        self.browser.open('birth_certificate')
+        self.assertEqual(
+            self.browser.headers['content-type'], 'image/jpeg')
+        self.assertEqual(len(self.browser.contents), PH_LEN)
+        # Create a pseudo image file and select it to be uploaded in form
+        # as birth certificate
+        self.browser.open(self.manage_clearance_path)
+        image = open(SAMPLE_IMAGE, 'rb')
+        ctrl = self.browser.getControl(name='birthcertificateupload')
+        file_ctrl = ctrl.mech_control
+        file_ctrl.add_file(image, filename='my_birth_certificate.jpg')
+        # The Save action does not upload files
+        self.browser.getControl("Save").click() # submit form
+        self.assertFalse(
+            '<a target="image" href="birth_certificate">'
+            in self.browser.contents)
+        # ... but the correct upload submit button does
+        image = open(SAMPLE_IMAGE)
+        ctrl = self.browser.getControl(name='birthcertificateupload')
+        file_ctrl = ctrl.mech_control
+        file_ctrl.add_file(image, filename='my_birth_certificate.jpg')
+        self.browser.getControl(
+            name='upload_birthcertificateupload').click()
+        # There is a correct <img> link included
+        self.assertTrue(
+            'href="http://localhost/app/customers/K1000000/birth_certificate"'
+            in self.browser.contents)
+        # Browsing the link shows a real image
+        self.browser.open('birth_certificate')
+        self.assertEqual(
+            self.browser.headers['content-type'], 'image/jpeg')
+        self.assertEqual(len(self.browser.contents), 2787)
+        # We can't reupload a file. The existing file must be deleted first.
+        self.browser.open(self.manage_clearance_path)
+        self.assertFalse(
+            'upload_birthcertificateupload' in self.browser.contents)
+        # File must be deleted first
+        self.browser.getControl(name='delete_birthcertificateupload').click()
+        self.assertTrue(
+            'birth_certificate deleted' in self.browser.contents)
+        # Uploading a file which is bigger than 150k will raise an error
+        big_image = StringIO(open(SAMPLE_IMAGE, 'rb').read() * 75)
+        ctrl = self.browser.getControl(name='birthcertificateupload')
+        file_ctrl = ctrl.mech_control
+        file_ctrl.add_file(big_image, filename='my_birth_certificate.jpg')
+        self.browser.getControl(
+            name='upload_birthcertificateupload').click()
+        self.assertTrue(
+            'Uploaded file is too big' in self.browser.contents)
+        # we do not rely on filename extensions given by uploaders
+        image = open(SAMPLE_IMAGE, 'rb') # a jpg-file
+        ctrl = self.browser.getControl(name='birthcertificateupload')
+        file_ctrl = ctrl.mech_control
+        # tell uploaded file is bmp
+        file_ctrl.add_file(image, filename='my_birth_certificate.bmp')
+        self.browser.getControl(
+            name='upload_birthcertificateupload').click()
+        self.assertTrue(
+            # jpg file was recognized
+            'File birth_certificate.jpg uploaded.' in self.browser.contents)
+        # Delete file again
+        self.browser.getControl(name='delete_birthcertificateupload').click()
+        self.assertTrue(
+            'birth_certificate deleted' in self.browser.contents)
+        # File names must meet several conditions
+        bmp_image = open(SAMPLE_IMAGE_BMP, 'rb')
+        ctrl = self.browser.getControl(name='birthcertificateupload')
+        file_ctrl = ctrl.mech_control
+        file_ctrl.add_file(bmp_image, filename='my_birth_certificate.bmp')
+        self.browser.getControl(
+            name='upload_birthcertificateupload').click()
+        self.assertTrue('Only the following extensions are allowed'
+            in self.browser.contents)
+
+        # Managers can upload a file via the CustomerBaseManageFormPage
+        self.browser.open(self.manage_customer_path)
+        image = open(SAMPLE_IMAGE_BMP, 'rb')
+        ctrl = self.browser.getControl(name='passportuploadmanage')
+        file_ctrl = ctrl.mech_control
+        file_ctrl.add_file(image, filename='my_photo.bmp')
+        self.browser.getControl(
+            name='upload_passportuploadmanage').click()
+        self.assertTrue('jpg file extension expected'
+            in self.browser.contents)
+        ctrl = self.browser.getControl(name='passportuploadmanage')
+        file_ctrl = ctrl.mech_control
+        image = open(SAMPLE_IMAGE, 'rb')
+        file_ctrl.add_file(image, filename='my_photo.jpg')
+        self.browser.getControl(
+            name='upload_passportuploadmanage').click()
+        self.assertTrue(
+            'src="http://localhost/app/customers/K1000000/passport.jpg"'
+            in self.browser.contents)
+        # We remove the passport file again
+        self.browser.open(self.manage_customer_path)
+        self.browser.getControl('Delete').click()
+        self.browser.open(self.customer_path + '/clearance_slip.pdf')
+        self.assertEqual(self.browser.headers['Status'], '200 Ok')
+        self.assertEqual(self.browser.headers['Content-Type'],
+                         'application/pdf')
+
+    def test_manage_workflow(self):
+        # Managers can pass through the whole workflow
+        self.browser.addHeader('Authorization', 'Basic mgr:mgrpw')
+        customer = self.app['customers'][self.customer_id]
+        self.browser.open(self.trigtrans_path)
+        self.browser.getControl(name="transition").value = ['start']
+        self.browser.getControl("Save").click()
+        self.browser.getControl(name="transition").value = ['request']
+        self.browser.getControl("Save").click()
+        self.browser.getControl(name="transition").value = ['approve']
+        self.browser.getControl("Save").click()
+        self.browser.getControl(name="transition").value = ['reset1']
+        self.browser.getControl("Save").click()
+        return
+
+
+
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/customers/utils.py
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/customers/utils.py	(revision 11967)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/customers/utils.py	(revision 11971)
@@ -19,4 +19,5 @@
 """
 import grok
+from waeup.ikoba.interfaces import STARTED
 from waeup.ikoba.customers.interfaces import ICustomersUtils
 
@@ -34,2 +35,4 @@
     #: start with this string. The default is 'K'.
     CUSTOMER_ID_PREFIX = u'K'
+
+    PWCHANGE_STATES = (STARTED,)
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/customers/workflow.py
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/customers/workflow.py	(revision 11967)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/customers/workflow.py	(revision 11971)
@@ -66,4 +66,11 @@
 
     Transition(
+        transition_id = 'reject',
+        title = _('Reject customer'),
+        msg = _('Customer registration rejected'),
+        source = REQUESTED,
+        destination = STARTED),
+
+    Transition(
         transition_id = 'reset1',
         title = _('Reset customer'),
