Index: main/waeup.kofa/trunk/src/waeup/kofa/accesscodes/browser.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/accesscodes/browser.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/accesscodes/browser.py	(revision 9217)
@@ -23,5 +23,5 @@
 from hurry.workflow.interfaces import InvalidTransitionError
 from waeup.kofa.browser.resources import datatable
-from waeup.kofa.browser import KofaPage, KofaAddFormPage, NullValidator
+from waeup.kofa.browser.layout import KofaPage, KofaAddFormPage, NullValidator
 from waeup.kofa.browser.breadcrumbs import Breadcrumb
 from waeup.kofa.browser.viewlets import (
Index: main/waeup.kofa/trunk/src/waeup/kofa/app.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/app.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/app.py	(revision 9217)
@@ -18,14 +18,13 @@
 import grok
 from zope.authentication.interfaces import IAuthentication
-from zope.component import getUtilitiesFor
+from zope.component import getUtility, getUtilitiesFor
 from zope.component.interfaces import ObjectEvent
 from zope.pluggableauth import PluggableAuthentication
 from waeup.kofa.authentication import setup_authentication
 from waeup.kofa.datacenter import DataCenter
-from waeup.kofa.students.container import StudentsContainer
-from waeup.kofa.hostels.container import HostelsContainer
 from waeup.kofa.mandates.container import MandatesContainer
 from waeup.kofa.interfaces import (
-    IUniversity, IKofaPluggable, IObjectUpgradeEvent, )
+    IUniversity, IKofaPluggable, IObjectUpgradeEvent, IJobManager,
+    VIRT_JOBS_CONTAINER_NAME)
 from waeup.kofa.userscontainer import UsersContainer
 from waeup.kofa.utils.logger import Logger
@@ -55,4 +54,7 @@
         the like.
         """
+        from waeup.kofa.students.container import StudentsContainer
+        from waeup.kofa.hostels.container import HostelsContainer
+
         self['users'] = UsersContainer()
         self['datacenter'] = DataCenter()
@@ -69,4 +71,9 @@
             plugin.setup(self, name, self.logger)
         return
+
+    def traverse(self, name):
+        if name == VIRT_JOBS_CONTAINER_NAME:
+            return getUtility(IJobManager)
+        return None
 
     def updatePlugins(self):
Index: main/waeup.kofa/trunk/src/waeup/kofa/applicants/applicant.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/applicants/applicant.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/applicants/applicant.py	(revision 9217)
@@ -29,10 +29,9 @@
 from zope.schema.interfaces import RequiredMissing, ConstraintNotSatisfied
 from hurry.workflow.interfaces import IWorkflowInfo, IWorkflowState
-from waeup.kofa.app import University
 from waeup.kofa.image import KofaImageFile
 from waeup.kofa.imagestorage import DefaultFileStoreHandler
 from waeup.kofa.interfaces import (
     IObjectHistory, IFileStoreHandler, IFileStoreNameChooser, IKofaUtils,
-    IExtFileStore, IPDF, IUserAccount)
+    IExtFileStore, IPDF, IUserAccount, IUniversity)
 from waeup.kofa.interfaces import MessageFactory as _
 from waeup.kofa.students.vocabularies import RegNumNotInSource
@@ -189,5 +188,5 @@
     """A catalog indexing :class:`Applicant` instances in the ZODB.
     """
-    grok.site(University)
+    grok.site(IUniversity)
     grok.name('applicants_catalog')
     grok.context(IApplicant)
Index: main/waeup.kofa/trunk/src/waeup/kofa/applicants/browser.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/applicants/browser.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/applicants/browser.py	(revision 9217)
@@ -38,6 +38,8 @@
     INITIALIZED, STARTED, PAID, SUBMITTED, ADMITTED)
 from waeup.kofa.browser import (
-    KofaPage, KofaEditFormPage, KofaAddFormPage, KofaDisplayFormPage,
+#    KofaPage, KofaEditFormPage, KofaAddFormPage, KofaDisplayFormPage,
     DEFAULT_PASSPORT_IMAGE_PATH)
+from waeup.kofa.browser.layout import (
+    KofaPage, KofaEditFormPage, KofaAddFormPage, KofaDisplayFormPage)
 from waeup.kofa.browser.interfaces import ICaptchaManager
 from waeup.kofa.browser.breadcrumbs import Breadcrumb
Index: main/waeup.kofa/trunk/src/waeup/kofa/applicants/interfaces.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/applicants/interfaces.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/applicants/interfaces.py	(revision 9217)
@@ -27,4 +27,5 @@
 from zope.schema.interfaces import (
     ValidationError, ISource, IContextSourceBinder)
+from waeup.kofa.browser.interfaces import IApplicantBase
 from waeup.kofa.schema import TextLineChoice, FormattedDate
 from waeup.kofa.interfaces import (
@@ -311,5 +312,5 @@
     'year'].order =  IApplicantsContainer['year'].order
 
-class IApplicantBaseData(IKofaObject):
+class IApplicantBaseData(IApplicantBase):
     """The data for an applicant.
 
Index: main/waeup.kofa/trunk/src/waeup/kofa/async.pt
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/async.pt	(revision 9217)
+++ main/waeup.kofa/trunk/src/waeup/kofa/async.pt	(revision 9217)
@@ -0,0 +1,53 @@
+<html>
+  <head>
+    <title>Async testing</title>
+    <script type="text/javascript">
+$(document).ready(function() {
+  var myLoadingDiv = $("#progressbar");
+  var mySpinner = $("#loadspinner");
+  myLoadingDiv.progressbar({disabled:true});
+  function update() {
+  $.getJSON("status?",  {jobid: "4",}, function(data) {
+    if (data == null) {
+      myLoadingDiv.progressbar("disable");
+      myLoadingDiv.progressbar("value", 0);
+      mySpinner.html('No Data');
+      $("#msg").html("Syncing...");
+      clearInterval(intervalID);
+      //alert('No Data'):
+      }
+    else {
+      myLoadingDiv.progressbar("enable");
+      myLoadingDiv.progressbar("value", data.percent);
+      $("#msg").html(data.msg + ' (' + data.status + ') ');
+      //alert(data.msg);
+      if (data.percent == 100) {
+        mySpinner.html('Job finished.')
+        clearInterval(intervalID);
+        }
+      }
+  });}
+  intervalID = setInterval(update, 500);
+  //update();
+});
+    </script>
+  </head>
+  <body>
+    <h1>Async stuff</h1>
+    <div>Dispatcher: <span tal:content="view/dispatcher"></span></div>
+    <div>Queue: <span tal:content="view/queue"></span></div>
+    <div>Jobs: <span tal:content="structure view/job_status"></span></div>
+    <form method="POST">
+      <input type="submit" name="reload" value="reload" />
+      <input type="submit" name="start" value="start up" />
+    </form>
+    <div id="progressbar"
+	 class="ui-progressbar ui-widget ui-widget-content ui-corner-all">
+    </div>
+    <div id="msg">Hi there</div>
+    <div id="loadspinner">
+      <img src="" alt="Please wait..."
+	   tal:attributes="src view/spinner_url" />
+    </div>
+  </body>
+</html>
Index: main/waeup.kofa/trunk/src/waeup/kofa/async.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/async.py	(revision 9217)
+++ main/waeup.kofa/trunk/src/waeup/kofa/async.py	(revision 9217)
@@ -0,0 +1,274 @@
+## $Id$
+##
+## Copyright (C) 2012 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
+##
+"""Components for asynchronous job (task) handling, mainly based on
+:mod:`zc.async`.
+"""
+import grok
+import time
+import transaction
+import zc.async.dispatcher
+import zc.async.utils
+import zope.component
+from zc.async.job import Job as AsyncJob
+from zc.async.queue import getDefaultQueue
+from zc.async.interfaces import IJob, ObjectAdded
+from ZODB.utils import u64
+from zope.component.hooks import getSite
+from zope.event import notify
+from waeup.kofa.interfaces import (
+    IJobManager, IProgressable, IJobContainer, WAEUP_KEY,
+    )
+
+def compute(num, duration=60):
+    """A convenience function to test asynchronous jobs.
+
+    `duration` gives the seconds, this job should (artificially) need
+    for completing.
+    """
+    start = time.time()
+    end = start + duration
+    print "MyJob starts computation at ", start
+    myjob = zc.async.local.getJob()
+    print "MyJob's job: ", zc.async.local.getJob()
+    print "MyJob's queue: ", zc.async.local.getQueue(), list(
+        zc.async.local.getQueue())
+    while True:
+	if time.time() > end:
+	    break
+	time.sleep(1)
+        percent = (time.time() - start) * 100.0 / duration
+        if percent > 100.0:
+            percent = 100.0
+        print "MyJob percent: ", percent
+        zc.async.local.setLiveAnnotation('percent', percent)
+	print "MyJob does something at %s of %s" % (
+            time.time() - start, duration)
+        print "MyJob's annotations: %r" % dict(myjob.annotations)
+        print "MyJob's percent: %r" % zc.async.local.getLiveAnnotation(
+            'percent')
+    zc.async.local.setLiveAnnotation('percent', 100.0)
+    return num * 2
+
+#
+# Content components
+#
+class ProgressableJob(AsyncJob):
+    """A job that can indicate its progress via a `percent` attribute.
+    """
+    grok.implements(IJob, IProgressable)
+    grok.provides(IJob)
+    percent = None
+
+
+def get_job_id(persisted_job):
+    """Get the object id of an already persisted job.
+
+    The `persisted_job` must provide a `_p_oid` attribute.
+    """
+    job_id = u64(persisted_job._p_oid)
+    return "%s" % job_id
+
+class JobContainer(grok.Container):
+    """A container for :class:`IKofa` jobs.
+    """
+    grok.implements(IJobContainer)
+
+
+class JobManager(grok.GlobalUtility):
+    """A manager for asynchronous running jobs (tasks).
+
+    Registered as a global utility for the
+    `waeup.kofa.interfaces.IJobManager` interface.
+
+    This is the central location for managing asynchronous running
+    jobs/tasks.
+
+    It works roughly like this: for usual tasks it looks up some
+    JobContainer installed in a ZODB database root (the installation
+    can happen during startup; see the respective installer classes
+    and functions in this module) and then interacts with this
+    JobContainer.
+
+    The optional `site` parameter for most methods in here serves for
+    finding the databases' roots. It is sufficient to pass any
+    persisted object (or more precisely: some object with a valid
+    ``_p_jar__`` attribte). As long as some site was already set (for
+    instance during regular requests), the site is looked up
+    automatically and you don't have to pass the `site` parameter
+    then. So, in most cases you won't have to give a `site` parameter.
+    """
+    grok.implements(IJobManager)
+    grok.provides(IJobManager)
+
+    def _get_site(self, site):
+        if site is None:
+            site = getSite()
+            if site is None:
+                raise LookupError('cannot find a valid site')
+        return site
+
+    def _get_jobs_container(self, site):
+        # in fact we need _some_ persisted object, not necessarily a site
+        return site._p_jar.root()[WAEUP_KEY]
+
+    def put(self, job, site=None):
+        """Start the `job` and store it in local `site`.
+
+        The `job` must be an `IJob` instance.
+
+        It will be put into the default queue and then stored in local
+        site. The status of the job can be seen immediately in
+        `job.status`.
+
+        Please specify special treatments like `begin_after` or
+        `begin_by` by setting the respectives attributes of the job
+        itself.
+        """
+        site = self._get_site(site)
+        container = self._get_jobs_container(site)
+        transaction.begin()
+        queue = getDefaultQueue(site)
+        new_job = queue.put(job)
+        job_id = get_job_id(new_job)
+        container[job_id] = new_job
+        transaction.commit()
+        return job_id
+
+    def get(self, job_id, site=None):
+        """Get the job with `job_id` from local `site`.
+
+        If `job_id` cannot be found, ``None`` is returned. This
+        suitable e.g. when used with a traverser.
+        """
+        site = self._get_site(site)
+        container = self._get_jobs_container(site)
+        return container.get(job_id, None)
+
+    def jobs(self, site=None):
+        """Get all stored jobs as an iterable.
+
+        Result provides tuples (JOB_ID, JOB_OBJECT).
+        """
+        site = self._get_site(site)
+        container = self._get_jobs_container(site)
+        for job_id, job in container.items():
+            yield (job_id, job)
+
+    def remove(self, job_id, site=None):
+        """Remove job with `job_id` from local job container.
+
+        If no such job can be found this is silently ignored.
+
+        Please note: removing a job from the job container does not
+        mean to stop its execution (if it wasn't started yet or is
+        currently running).
+        """
+        site = self._get_site(site)
+        container = self._get_jobs_container(site)
+        if job_id in container.keys():
+            del container[job_id]
+        return
+
+    def start_test_job(self, duration=60, site=None):
+        """Start a test job.
+
+        A method for testing the general asynchronous functionality of
+        waeup.kofa. The test job started here executes the local
+        :func:`compute` function with ``23`` as argument.
+        """
+        job = AsyncJob(compute, 23, duration)
+        job_id = self.put(job, site=site)
+        return job_id
+
+class JobManagerTraverser(grok.Traverser):
+    """A traverser for the global ``IJobManager``.
+
+    Looks up jobs by job_id and returns the respective job if it
+    can be found.
+    """
+    grok.context(IJobManager)
+
+    def traverse(self, name):
+        # ``None`` if name cannot be found.
+        return self.context.get(name)
+
+##
+## configuration (permissions, subscribers, etc.) ...
+##
+
+class ViewJobs(grok.Permission):
+    grok.name('waeup.viewJobs')
+
+class ManageJobs(grok.Permission):
+    grok.name('waeup.manageJobs')
+
+class JobContainerInstaller(object):
+    """Install a JobContainer in root of given DB.
+
+    Instances of this installer can be called when a Zope instance
+    comes up (i.e. when an IDatabaseOpenedEvent was triggered).
+
+    It looks for some database named as in `db_name` and installs a
+    job container in the root of this database (``None`` by default)
+    if it does not exist already.
+    """
+    def __init__(self, db_name=None):
+        # This IDatabaseOpenedEvent will be from zope.app.appsetup if that
+        # package is around
+        zope.component.adapter(zc.async.interfaces.IDatabaseOpenedEvent)(self)
+        self.db_name = db_name
+        return
+
+    def __call__(self, ev):
+        db = ev.database
+        tm = transaction.TransactionManager()
+        conn = db.open(transaction_manager=tm)
+        tm.begin()
+        try:
+            try:
+                root = conn.root()
+                if WAEUP_KEY in root:
+                    return
+                if self.db_name is not None:
+                    other = conn.get_connection(self.db_name)
+                    container = other.root()[WAEUP_KEY] = JobContainer()
+                    other.add(container)
+                else:
+                    container = JobContainer()
+                root[WAEUP_KEY] = container
+                notify(ObjectAdded(container, root, WAEUP_KEY))
+                tm.commit()
+                zc.async.utils.log.info('job container added')
+            except:
+                tm.abort()
+                raise
+        finally:
+            conn.close()
+        return
+
+#: Can be used as event subscriber from ZCML; installs a job container
+#: in default database (named ``''``) root.
+job_container_installer = JobContainerInstaller()
+
+#: An installer instance that installs a job container in a ZODB
+#: called ``async`` - this name is used in several components of the
+#: `zc.async` package we don't want to reimplement here.
+#:
+#: To use this installer as an event subscriber by ZCML, make sure the
+#: instance provides also a ZODB called `async`.
+multidb_job_container_installer = JobContainerInstaller(db_name='async')
Index: main/waeup.kofa/trunk/src/waeup/kofa/async_multidb.zcml
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/async_multidb.zcml	(revision 9217)
+++ main/waeup.kofa/trunk/src/waeup/kofa/async_multidb.zcml	(revision 9217)
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configure xmlns="http://namespaces.zope.org/zope">
+  <subscriber handler=".async.multidb_job_container_installer" />
+</configure>
Index: main/waeup.kofa/trunk/src/waeup/kofa/async_single.zcml
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/async_single.zcml	(revision 9217)
+++ main/waeup.kofa/trunk/src/waeup/kofa/async_single.zcml	(revision 9217)
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configure xmlns="http://namespaces.zope.org/zope">
+  <subscriber handler=".async.job_container_installer" />
+</configure>
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/__init__.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/__init__.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/__init__.py	(revision 9217)
@@ -1,8 +1,8 @@
 import os
 
-from waeup.kofa.browser.layout import (
-    KofaPage, KofaForm, KofaLayout, KofaDisplayFormPage, KofaEditFormPage,
-    KofaAddFormPage, NullValidator)
-from waeup.kofa.browser.pages import ContactAdminForm
+#from waeup.kofa.browser.layout import (
+#    KofaPage, KofaForm, KofaLayout, KofaDisplayFormPage, KofaEditFormPage,
+#    KofaAddFormPage, NullValidator)
+#from waeup.kofa.browser.pages import ContactAdminForm
 
 IMAGE_PATH = os.path.join(
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/async.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/async.py	(revision 9217)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/async.py	(revision 9217)
@@ -0,0 +1,151 @@
+import grok
+from zc.async.interfaces import COMPLETED, NEW
+from zope.interface import Interface
+from waeup.kofa.async import IJob, IJobManager
+from waeup.kofa.browser.layout import KofaPage
+from waeup.kofa.browser.resources import jquery, loadbar
+from waeup.kofa.interfaces import IKofaObject, IDataCenter
+from waeup.kofa.interfaces import MessageFactory as _
+
+grok.templatedir('templates')
+grok.context(IKofaObject)
+
+loadingbar_template = '''
+<input id="status_url" type="hidden" name="status_url" value="%s" />
+<div id="loadbar">%%s</div>
+'''
+
+class LoadingBar(grok.ViewletManager):
+    grok.name('loadingbar')
+
+class LoadingBarViewlet(grok.Viewlet):
+    grok.context(Interface)
+    grok.viewletmanager(LoadingBar)
+
+    _running_exports = None
+
+    def getRunningExports(self):
+        """Returns running exports as list of tuples.
+
+        Only exports triggered by the current user (identified by
+        principal.id) are returned.
+
+        Each tuple has the form (<STATUS>, <STATUS_TITLE>, <EXPORTER_NAME>).
+
+        ``STATUS``:
+           the status as machine readable string (something like
+           ``'completed'``)
+
+        ``STATUS_TITLE``:
+           status of export as translated string.
+
+        ``EXPORTER_NAME``:
+           string representing the exporter title used when triggering
+           the export job.
+        """
+        result = self.context.get_export_jobs_status(self.user_id)
+        return result
+
+    def update(self):
+        self.user_id = self.request.principal.id
+        self.exports = self.getRunningExports()
+        self.uncompleted = [x for x in self.exports if x[0] != 'completed']
+        loadbar.need()
+
+    def render(self):
+        status_url = self.view.url('status')
+        template = loadingbar_template % (status_url,)
+        content = ''
+        return template % content
+
+class DataCenterJSON(grok.JSON):
+    grok.context(IDataCenter)
+
+    def status(self):
+        """Get status of currently handled export jobs for users.
+
+        The result is a set of valus useful for JavaScript handlers to
+        update some page handling data center exports.
+
+        Returns a mapping::
+
+          {html=<string>, interval=<int>, reload=<bool>}
+
+        where `html` is some HTML code that might be shown in a
+        loading bar section.
+
+        `interval` is the time in microseconds after which the loading
+        bar should be reloaded (by a new JSON call). If the value is
+        zero, then the loading bar should not be renewed and further
+        JSON calls be canceled.
+
+        `reload` is a hint to tell, whether the complete page should
+        be reloaded or not. Reloading is neccessary, if the export
+        file creation finished and download buttons or similar should
+        appear.
+        """
+        user_id = self.request.principal.id
+        jobs = self.context.get_export_jobs_status(user_id)
+        uncompleted = [x for x in jobs if x[0] != 'completed']
+        result = dict(
+            html='', interval=0, reload=False)
+        if len(uncompleted):
+            html = '<em>%s</em>' % (_("Please wait..."),)
+            result.update(interval=500, html=html)
+        else:
+            if len(jobs):
+                result.update(reload=True)
+        return result
+
+
+class JobManagerView(KofaPage):
+    """The main view for the job container.
+    """
+    grok.name('index.html')
+    grok.context(IJobManager)
+    grok.template('virtjobscontainerindex')
+    grok.require('waeup.manageJobs')
+
+    def jobs(self):
+        for job_id, job in sorted(self.context.jobs(),
+                                  cmp=lambda x, y: cmp(int(x[0]), int(y[0]))):
+            link = self.url(self.context, job_id)
+            removable = job.status in (COMPLETED, NEW)
+            yield dict(
+                job_id = job_id,
+                job = job,
+                link = link,
+                status = job.status,
+                begin_after = job.begin_after,
+                begin_by = job.begin_by,
+                annotations = '%r' % [
+                    (x, y) for x, y in job.annotations.items()],
+                removable = removable,
+                failure = '%r' % getattr(job, 'traceback', None),
+                )
+
+    def update(self, START_NEW=None, REMOVE=None, job_id=None):
+        jquery.need()
+        if REMOVE and job_id:
+            self.context.remove(job_id)
+            self.flash('Removed job "%s"' % job_id)
+        if START_NEW:
+            self.context.start_test_job()                    #pragma NO COVER
+            self.flash('Started new test job "%s"' % job_id) #pragma NO COVER
+        return
+
+class JobView(grok.View):
+    grok.name('index.html')
+    grok.context(IJob)
+    grok.template('jobindex')
+    grok.require('waeup.manageJobs')
+
+    def percent(self):
+        value = self.context.annotations.get('percent', None)
+        if not value:
+            return
+        value = int(value+0.5)
+        return min(value, 100)
+
+    def update(self):
+        pass
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/breadcrumbs.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/breadcrumbs.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/breadcrumbs.py	(revision 9217)
@@ -26,7 +26,10 @@
     IConfigurationContainer, ISessionConfiguration)
 from waeup.kofa.interfaces import MessageFactory as _
-from waeup.kofa.browser import interfaces
-from waeup.kofa.browser.interfaces import (IBreadcrumb,
-    IBreadcrumbIgnorable, IBreadcrumbContainer)
+from waeup.kofa.browser.interfaces import (
+    IBreadcrumb, IBreadcrumbIgnorable, IBreadcrumbContainer, IKofaObject,
+    IUniversity, IFacultiesContainer, IUsersContainer, IDataCenter, IFaculty,
+    IDepartment, ICourse, ICertificate, ICoursesContainer, ICertificateCourse,
+    ICertificatesContainer,
+    )
 
 class Breadcrumb(grok.Adapter):
@@ -34,5 +37,5 @@
     """
     grok.provides(IBreadcrumb)
-    grok.context(interfaces.IKofaObject)
+    grok.context(IKofaObject)
     grok.name('index')
 
@@ -87,5 +90,5 @@
     """A breadcrumb for university index pages.
     """
-    grok.context(interfaces.IUniversity)
+    grok.context(IUniversity)
     title = _(u'Home')
     parent = None
@@ -100,5 +103,5 @@
     itself is bound to.
     """
-    grok.context(interfaces.IUniversity)
+    grok.context(IUniversity)
     grok.name('manage')
     title = _(u'Portal Settings')
@@ -113,5 +116,5 @@
     """A breadcrumb for faculty containers.
     """
-    grok.context(interfaces.IFacultiesContainer)
+    grok.context(IFacultiesContainer)
     title = _(u'Academics')
 
@@ -119,5 +122,5 @@
     """A breadcrumb for administration areas of University instances.
     """
-    grok.context(interfaces.IUniversity)
+    grok.context(IUniversity)
     grok.name('administration')
     title = _(u'Administration')
@@ -145,5 +148,5 @@
     """A breadcrumb for user containers.
     """
-    grok.context(interfaces.IUsersContainer)
+    grok.context(IUsersContainer)
     title = _(u'Portal Users')
     parent_viewname = 'administration'
@@ -152,5 +155,5 @@
     """A breadcrumb for data centers.
     """
-    grok.context(interfaces.IDataCenter)
+    grok.context(IDataCenter)
     title = _(u'Data Center')
     parent_viewname = 'administration'
@@ -159,5 +162,5 @@
     """A breadcrumb for faculties.
     """
-    grok.context(interfaces.IFaculty)
+    grok.context(IFaculty)
 
     @property
@@ -168,20 +171,20 @@
     """A breadcrumb for departments.
     """
-    grok.context(interfaces.IDepartment)
+    grok.context(IDepartment)
 
 class CourseBreadcrumb(FacultyBreadcrumb):
     """A breadcrumb for courses.
     """
-    grok.context(interfaces.ICourse)
+    grok.context(ICourse)
 
 class CertificateBreadcrumb(FacultyBreadcrumb):
     """A breadcrumb for certificates.
     """
-    grok.context(interfaces.ICertificate)
+    grok.context(ICertificate)
 
 class CoursesContainerBreadcrumb(Breadcrumb):
     """ We don't want course container breadcrumbs.
     """
-    grok.context(interfaces.ICoursesContainer)
+    grok.context(ICoursesContainer)
     grok.implements(IBreadcrumbIgnorable)
 
@@ -189,5 +192,5 @@
     """ We don't want course container breadcrumbs.
     """
-    grok.context(interfaces.ICertificatesContainer)
+    grok.context(ICertificatesContainer)
     grok.implements(IBreadcrumbIgnorable)
 
@@ -195,5 +198,5 @@
     """ We don't want course container breadcrumbs.
     """
-    grok.context(interfaces.ICertificateCourse)
+    grok.context(ICertificateCourse)
     @property
     def title(self):
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/captcha.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/captcha.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/captcha.py	(revision 9217)
@@ -28,5 +28,6 @@
 from zope.interface import Interface
 from zope.publisher.interfaces.http import IHTTPRequest
-from waeup.kofa.browser import KofaPage, resources
+from waeup.kofa.browser import resources
+from waeup.kofa.browser.layout import KofaPage
 from waeup.kofa.browser.interfaces import (
     ICaptchaRequest, ICaptchaResponse, ICaptcha, ICaptchaConfig,
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/interfaces.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/interfaces.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/interfaces.py	(revision 9217)
@@ -21,5 +21,6 @@
 from zope.interface import Interface, Attribute
 from waeup.kofa.interfaces import (
-    IKofaObject, IUniversity, IUsersContainer, IDataCenter)
+    IKofaObject, IUniversity, IUsersContainer, IDataCenter, validate_email)
+from waeup.kofa.interfaces import MessageFactory as _
 from waeup.kofa.university.interfaces import (
     IFacultiesContainer, IFaculty, IFacultyAdd, IDepartment, IDepartmentAdd,
@@ -196,2 +197,33 @@
         If no `title` is given, nothing will be rendered.
         """
+
+class IChangePassword(IKofaObject):
+    """Interface needed for change pasword page.
+
+    """
+    identifier = schema.TextLine(
+        title = _(u'Unique Identifier'),
+        description = _(
+            u'User Name, Student or Applicant Id, Matriculation or '
+            u'Registration Number'),
+        required = True,
+        readonly = False,
+        )
+
+    email = schema.ASCIILine(
+        title = _(u'Email Address'),
+        required = True,
+        constraint=validate_email,
+        )
+
+class IStudentNavigationBase(IKofaObject):
+    """Objects that provide student navigation (whatever it is) should
+       implement this interface.
+    """
+    student = Attribute('''Some student object that has a '''
+                        ''' `display_fullname` attribute.''')
+
+class IApplicantBase(IKofaObject):
+    """Some Applicant.
+    """
+    display_fullname = Attribute('''Fullname.''')
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/layout.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/layout.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/layout.py	(revision 9217)
@@ -36,8 +36,7 @@
 from waeup.kofa.interfaces import MessageFactory as _
 from waeup.kofa.utils.helpers import to_timezone
-from waeup.kofa.browser.interfaces import ITheme
+from waeup.kofa.browser.interfaces import (
+    ITheme, IStudentNavigationBase, IApplicantBase)
 from waeup.kofa.browser.theming import get_all_themes, KofaThemeBase
-from waeup.kofa.students.interfaces import IStudentNavigation
-from waeup.kofa.applicants.interfaces import IApplicant
 from waeup.kofa.authentication import get_principal_role_manager
 
@@ -288,5 +287,5 @@
         """Return the student name.
         """
-        if IStudentNavigation.providedBy(self.context):
+        if IStudentNavigationBase.providedBy(self.context):
             return self.context.student.display_fullname
         return
@@ -295,5 +294,6 @@
         """Return the applicant name.
         """
-        if IApplicant.providedBy(self.context):
+        if IApplicantBase.providedBy(self.context):
+            # XXX: shouldn't that be `display_fullname???`
             return self.context.fullname
         return
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/pages.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/pages.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/pages.py	(revision 9217)
@@ -18,5 +18,4 @@
 """ Viewing components for Kofa objects.
 """
-import copy
 import csv
 import grok
@@ -24,6 +23,4 @@
 import re
 import sys
-import time
-import re
 from urllib import urlencode
 from zope import schema
@@ -35,11 +32,9 @@
     getUtilitiesFor,
     )
-#from zope.component.interfaces import Invalid
 from zope.event import notify
-from zope.securitypolicy.interfaces import (
-    IPrincipalRoleManager, IPrincipalRoleMap)
+from zope.securitypolicy.interfaces import IPrincipalRoleManager
 from zope.session.interfaces import ISession
 from zope.password.interfaces import IPasswordManager
-from waeup.kofa.browser import (
+from waeup.kofa.browser.layout import (
     KofaPage, KofaForm, KofaEditFormPage, KofaAddFormPage,
     KofaDisplayFormPage, NullValidator)
@@ -48,7 +43,8 @@
     IDepartment, IDepartmentAdd, ICourse, ICourseAdd, ICertificate,
     ICertificateAdd, ICertificateCourse, ICertificateCourseAdd,
-    ICaptchaManager)
+    ICaptchaManager, IChangePassword)
 from waeup.kofa.browser.layout import jsaction, action, UtilityView
-from waeup.kofa.browser.resources import warning, datepicker, tabs, datatable
+from waeup.kofa.browser.resources import (
+    warning, tabs, datatable)
 from waeup.kofa.interfaces import MessageFactory as _
 from waeup.kofa.interfaces import(
@@ -56,16 +52,14 @@
     IKofaXMLImporter, IKofaXMLExporter, IBatchProcessor,
     ILocalRolesAssignable, DuplicationError, IConfigurationContainer,
-    ISessionConfiguration, ISessionConfigurationAdd,
-    IPasswordValidator, IContactForm, IKofaUtils, ICSVExporter,
-    IChangePassword)
+    ISessionConfiguration, ISessionConfigurationAdd, IJobManager,
+    IPasswordValidator, IContactForm, IKofaUtils, ICSVExporter,)
 from waeup.kofa.permissions import (
     get_users_with_local_roles, get_all_roles, get_all_users)
+
 from waeup.kofa.students.catalog import search as searchstudents
 from waeup.kofa.university.catalog import search
 from waeup.kofa.university.vocabularies import course_levels
 from waeup.kofa.authentication import LocalRoleSetEvent
-#from waeup.kofa.widgets.restwidget import ReSTDisplayWidget
 from waeup.kofa.widgets.htmlwidget import HTMLDisplayWidget
-from waeup.kofa.authentication import get_principal_role_manager
 from waeup.kofa.utils.helpers import get_user_account
 from waeup.kofa.mandates.mandate import PasswordMandate
@@ -797,5 +791,4 @@
     def delFiles(self, **data):
         form = self.request.form
-        logger = self.context.logger
         if form.has_key('val_id'):
             child_id = form['val_id']
@@ -1196,8 +1189,4 @@
         return False
 
-    @property
-    def nextstep(self):
-        return self.url(self.context, '@@import4')
-
     def update(self, headerfield=None, back2=None, cancel=None, proceed=None):
         datatable.need()
@@ -1390,5 +1379,6 @@
     label = _('Download portal data as CSV file')
     pnav = 0
-    export_button = _(u'Download')
+    export_button = _(u'Create CSV file')
+    _running_exports = None
 
     def getExporters(self):
@@ -1398,9 +1388,57 @@
         return sorted(title_name_tuples)
 
-    def update(self, export=None, exporter=None):
-        if None in (export, exporter):
-            return
-        self.redirect(
-            self.url(self.context, 'export.csv') + '?exporter=%s' % exporter)
+    def job_finished(self, status):
+        return status == 'completed'
+
+    def getRunningExports(self):
+        """Returns running exports as list of tuples.
+
+        Only exports triggered by the current user (identified by
+        principal.id) are returned.
+
+        Each tuple has the form (<STATUS>, <STATUS_TITLE>, <EXPORTER_NAME>).
+
+        ``STATUS``:
+           the status as machine readable string (something like
+           ``'completed'``)
+
+        ``STATUS_TITLE``:
+           status of export as translated string.
+
+        ``EXPORTER_NAME``:
+           string representing the exporter title used when triggering
+           the export job.
+        """
+        if self._running_exports is None:
+            self._running_exports = self._getRunningExports()
+        return self._running_exports
+
+    def _getRunningExports(self):
+        result = self.context.get_export_jobs_status(self.user_id)
+        return result
+
+    def update(self, export=None, start_export=None, exporter=None,
+               discard=None, download=None):
+        self.user_id = self.request.principal.id
+        if discard:
+            myjobs = self.context.get_running_export_jobs(self.user_id)
+            for entry in myjobs:
+                self.context.delete_export_entry(entry)
+                self.flash(_('Discarded export result'))
+            return
+        if download:
+            myjobs = self.context.get_running_export_jobs(self.user_id)
+            if not len(myjobs):
+                self.flash(_('This export was already discarded.'))
+                return
+            job_id = myjobs[0][0]
+            self.redirect(
+                self.url(self.context, 'export.csv') + '?job_id=%s' % job_id)
+            return
+        if None in (start_export, exporter):
+            return
+        job_id = self.context.start_export_job(
+            exporter, self.request.principal.id)
+        self.redirect(self.url(self.context, 'export'))
         return
 
@@ -1410,20 +1448,28 @@
     grok.require('waeup.manageDataCenter')
 
-    def render(self, exporter=None):
-        if exporter is None:
-            return
-        ob_class = self.__implemented__.__name__.replace('waeup.kofa.','')
-        self.context.logger.info(
-            '%s - exported: %s' % (ob_class, exporter))
-        exporter = getUtility(ICSVExporter, name=exporter)
-        csv_data = exporter.export_all(grok.getSite())
-        #csv_data.seek(0)
+    def render(self, exporter=None, job_id=None):
+
+        manager = getUtility(IJobManager)
+        job = manager.get(job_id)
+        if job is None:
+            return
+        if hasattr(job.result, 'traceback'):
+            # XXX: Some error happened. Do something more approriate here...
+            return
+        path = job.result
+        if not os.path.exists(path):
+            # XXX: Do something more appropriate here...
+            return
+        result = open(path, 'rb').read()
+        acronym = grok.getSite()['configuration'].acronym.replace(' ','')
+        filename = "%s_%s" % (acronym, os.path.basename(path))
         self.response.setHeader(
             'Content-Type', 'text/csv; charset=UTF-8')
-        acronym = grok.getSite()['configuration'].acronym.replace(' ','')
-        filename = "%s%s.csv" % (acronym, exporter.title.title().replace(' ',''))
         self.response.setHeader(
-            'Content-Disposition:', 'attachment; filename="%s' % filename)
-        return csv_data
+            'Content-Disposition', 'attachment; filename="%s' % filename)
+        # remove job and running_exports entry from context
+        self.context.delete_export_entry(
+            self.context.entry_from_job_id(job_id))
+        return result
 
 class ExportXMLPage(grok.View):
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/resources.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/resources.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/resources.py	(revision 9217)
@@ -302,2 +302,13 @@
     waeup_kofa, 'recaptcha_white.js'
     )
+
+#: A page reloader (reloads after 3 secs)
+page_reloader = ResourceInclusion(
+    waeup_kofa, 'page_reloader.js', depends=[jquery])
+
+#: Disables page reloader (which is triggered by a JavaScript timer).
+page_not_reloader = ResourceInclusion(
+    waeup_kofa, 'page_not_reloader.js', depends=[jquery])
+
+loadbar = ResourceInclusion(
+    waeup_kofa, 'loadbar.js', depends=[jquery])
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/static/loadbar.js
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/static/loadbar.js	(revision 9217)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/static/loadbar.js	(revision 9217)
@@ -0,0 +1,23 @@
+
+var loadbar_interval = null;
+$(document).ready(
+    function update() {
+	$.getJSON(
+	    $("#status_url").attr("value"),
+	    {},
+	    function(data) {
+		$('input[name="reload"]').remove();
+		clearInterval(loadbar_interval);
+		$("#loadbar").html(data.html);
+		if (data.reload == true) {
+		    if ($('input[name="download"]').attr("value") == null) {
+			location.reload();
+		    }
+		}
+		if (data.interval != 0) {
+		    loadbar_interval = setInterval(update, data.interval);
+		}
+	    }
+	)
+    }
+)
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/static/page_not_reloader.js
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/static/page_not_reloader.js	(revision 9217)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/static/page_not_reloader.js	(revision 9217)
@@ -0,0 +1,8 @@
+$(document).ready(function() {
+    if(window.top==window) {
+	// you're not in a frame so you reload the site
+	window.clearTimeout('location.reload()'); //reloads after 1.5 seconds
+    } else {
+	//you're inside a frame, so you stop reloading
+    }
+});
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/static/page_reloader.js
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/static/page_reloader.js	(revision 9217)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/static/page_reloader.js	(revision 9217)
@@ -0,0 +1,8 @@
+$(document).ready(function() {
+    if(window.top==window) {
+	// you're not in a frame so you reload the site
+	window.setTimeout('location.reload()', 1500); //reloads after 1.5 seconds
+    } else {
+	//you're inside a frame, so you stop reloading
+    }
+});
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/templates/datacenterexportpage.pt
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/templates/datacenterexportpage.pt	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/templates/datacenterexportpage.pt	(revision 9217)
@@ -1,34 +1,82 @@
 <div i18n:domain="waeup.kofa" class="row">
-  <div class="span4 columns">
   <div class="span8 columns">
-    <p i18n:translate="">Here you can download parts of portal data.</p>
-    <p i18n:translate="">
-      Please pick the type of objects you want to export from the
-      selection below.
-    </p>
-  </div>
+    <div class="span8 columns">
+      <p i18n:translate="">
+	Here you can create CSV files from parts of portal data.
+      </p>
+      <p i18n:translate="">
+	Please pick the type of objects you want to export from the
+	selection below.
+      </p>
+      <p i18n:translate="">
+	The file will be generated and then be made available for you
+	to download. You must download or discard any existing export
+	file before creating a new one.
+      </p>
+    </div>
 
-  <form  method="POST">
-    <fieldset>
+    <form  method="POST">
+      <fieldset>
 	<div class="clearfix">
-	  <label for="exporter">Data Type:</label>
-	  <div class="input">
-	    <select name="exporter">
-	      <span tal:repeat="items view/getExporters" tal:omit-tag="">
-		<option
-		    tal:define="name python: items[1]; title python: items[0]"
-		    tal:attributes="value name">
-		  <span tal:replace="title">TITLE</span>
-		</option>
+	  <label for="running_exports"
+		 tal:condition="view/getRunningExports">Running Export:</label>
+	  <div class="input span6">
+	    <div tal:repeat="items view/getRunningExports">
+	      <div>
+		Data type:
+		<span tal:content="python: items[2]">exporter</span><br />
+		Status:
+		<span tal:content="python: items[1]">status</span><br />
+		<br /><br />
+	      </div>
+	      <div>
+		<span tal:condition="not: python: view.job_finished(items[0])">
+		  <img src=""
+		       tal:attributes="src static/ajax-loader.gif"
+		       alt="Loading..."
+		       class="spinner" />
+		  <input type="submit" name="reload" value="Reload"
+			 class="btn primary" />
+		</span>
+		<div>
+		  <span tal:condition="python: view.job_finished(items[0])">
+		    <input type="submit" name="download" value="Download"
+			   class="btn primary" />
+		    <input type="submit" name="discard" value="Discard"
+			 class="btn secondary" />
+		  </span>
+		</div>
+	      </div>
+	    </div>
+
+	    <div>
+	      <tal:loading_bar content="structure provider:loadingbar" />
+	    </div>
+	  </div>
+
+	  <div class="clearfix"
+	       tal:condition="not: view/getRunningExports">
+	    <label for="exporter">Data Type:</label>
+	    <div class="input">
+	      <select name="exporter">
+		<span tal:repeat="items view/getExporters" tal:omit-tag="">
+		  <option
+		      tal:define="name python: items[1]; title python: items[0]"
+		      tal:attributes="value name">
+		    <span tal:replace="title">TITLE</span>
+		  </option>
+		</span>
+	      </select>
+	      <span class="help-inline" i18n:translate="">
+		Type of objects to export
 	      </span>
-	    </select>
-	    <span class="help-inline" i18n:translate="">
-	      Type of objects to export
-	    </span>
+	    </div>
 	  </div>
-	</div>
-	<div class="input">
-	  <input i18n:translate="" type="submit" class="btn primary"
-		 name="export" tal:attributes="value view/export_button" />
+	  <div class="input"
+	       tal:condition="not: view/getRunningExports">
+	    <input i18n:translate="" type="submit" class="btn primary"
+		   name="start_export"
+		   tal:attributes="value view/export_button" />
+	  </div>
 	</div>
       </fieldset>
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/templates/jobindex.pt
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/templates/jobindex.pt	(revision 9217)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/templates/jobindex.pt	(revision 9217)
@@ -0,0 +1,21 @@
+<html>
+  <head>
+  </head>
+  <body>
+    <div>
+      Job: <span tal:content="python:view.context.__repr__()">JOB</span>
+    </div>
+    <div>
+      Status: <span tal:content="view/context/status">status</span>
+    </div>
+    <div>
+      Begin after: <span tal:content="view/context/begin_after">after</span>
+    </div>
+    <div>
+      Begin by: <span tal:content="view/context/begin_by">by</span>
+    </div>
+    <div>
+      Percent done: <span tal:content="view/percent">percent</span>
+    </div>
+  </body>
+</html>
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/templates/virtjobscontainerindex.pt
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/templates/virtjobscontainerindex.pt	(revision 9217)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/templates/virtjobscontainerindex.pt	(revision 9217)
@@ -0,0 +1,45 @@
+<h1>Running and Completed Jobs</h1>
+<div>
+  <form method="POST">
+    <input type="submit" class="btn primary" name="RELOAD" value="reload" />
+    <input type="submit" class="btn secondary"
+	   name="START_NEW" value="start new test job" />
+  </form>
+</div>
+<hr />
+
+<div tal:repeat="job view/jobs">
+  <div>
+    JOB
+    <a href="" tal:attributes="href job/link">
+      <span tal:replace="job/job_id">12</span>
+    </a>
+    status: <span tal:replace="job/status">new</span>
+    <div>
+      &nbsp;&nbsp;
+      <span class="span3">begin by:</span>
+      <span tal:content="job/begin_by"></span>
+    </div>
+    <div>
+      &nbsp;&nbsp;
+      <span class="span3">begin after:</span>
+      <span tal:content="job/begin_after"></span>
+    </div>
+    <div>
+      &nbsp;&nbsp;
+      <span class="span3">annotations:</span>
+      <span tal:content="job/annotations"></span>
+    </div>
+    <div>
+      &nbsp;&nbsp;
+      <span class="span3">failure:</span>
+      <span tal:content="job/failure"></span>
+    </div>
+  </div>
+  <form tal:condition="job/removable" method="POST">
+    <input type="submit" class="btn secondary" name="REMOVE" value="Remove" />
+    <input type="hidden" name="job_id"
+	   tal:attributes="value job/job_id" />
+  </form>
+  <hr />
+</div>
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/tests/test_async.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/tests/test_async.py	(revision 9217)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/tests/test_async.py	(revision 9217)
@@ -0,0 +1,174 @@
+import os
+import shutil
+import tempfile
+import transaction
+from zc.async.testing import wait_for_result
+from zope.component import getUtility
+from zope.component.hooks import setSite
+from zope.security.interfaces import Unauthorized
+from zope.testbrowser.testing import Browser
+from waeup.kofa.app import University
+from waeup.kofa.async import AsyncJob, get_job_id
+from waeup.kofa.interfaces import IJobManager
+from waeup.kofa.testing import FunctionalTestCase, FunctionalLayer
+from waeup.kofa.tests.test_async import FunctionalAsyncTestCase
+
+def fake_get_export_jobs_status(user_id=None):
+    return [('completed', 'Completed', 'my_exporter'),]
+
+def dummy_func():
+    return 42
+
+class AsyncBrowserTests(FunctionalAsyncTestCase):
+
+    layer = FunctionalLayer
+
+    def setUp(self):
+        super(AsyncBrowserTests, self).setUp()
+        self.workdir = tempfile.mkdtemp()
+        self.storage = os.path.join(self.workdir, 'storage')
+        os.mkdir(self.storage)
+        app = University()
+        app['datacenter'].setStoragePath(self.storage)
+
+        # 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)
+        self.browser = Browser()
+        self.browser.handleErrors = False
+        self.datacenter_path = 'http://localhost/app/datacenter'
+
+    def tearDown(self):
+        super(AsyncBrowserTests, self).tearDown()
+        shutil.rmtree(self.workdir)
+
+class JobViewTests(AsyncBrowserTests):
+    # Tests for the view of single jobs
+    overview_path = 'http://localhost/app/jobs'
+
+    def setUp(self):
+        super(JobViewTests, self).setUp()
+        self.browser.addHeader('Authorization', 'Basic mgr:mgrpw')
+
+    def start_job(self):
+        # helper that starts a job in background and returns the job
+        manager = getUtility(IJobManager)
+        job = AsyncJob(dummy_func)
+        manager.put(job, site=self.getRootFolder())
+        return job
+
+    def test_get_job_view(self):
+        # we can get a job on a view
+        self.browser.open(self.overview_path)
+        job = self.start_job()
+        wait_for_result(job)
+        self.browser.open(self.overview_path)
+        job_id = get_job_id(job)
+        link = self.browser.getLink(job_id)
+        expected_url = 'http://localhost/app/jobs/%s' % job_id
+        self.assertEqual(link.url, expected_url)
+        link.click()
+        self.assertEqual(self.browser.headers['status'], '200 Ok')
+        return
+
+    def test_anonymous_forbidden(self):
+        # we need some permissions to get a job view
+        transaction.commit() # make sure the 'app' is stored in ZODB
+        job = self.start_job()
+        wait_for_result(job)
+        job_id = get_job_id(job)
+        link_url = 'http://localhost/app/jobs/%s' % job_id
+
+        # get a new browser, where we're not authenticated
+        browser = Browser()
+        browser.handleErrors = False
+        self.assertRaises(Unauthorized, browser.open, link_url)
+        return
+
+class JobOverviewTests(AsyncBrowserTests):
+    # Tests for the job overview page of universities
+    overview_path = 'http://localhost/app/jobs'
+
+    def setUp(self):
+        super(JobOverviewTests, self).setUp()
+        self.browser.addHeader('Authorization', 'Basic mgr:mgrpw')
+
+    def start_job(self):
+        # helper that start a job in background and returns the job
+        manager = getUtility(IJobManager)
+        job = AsyncJob(dummy_func)
+        manager.put(job, site=self.getRootFolder())
+        return job
+
+    def test_job_get(self):
+        # we can get the overview page
+        self.browser.open(self.overview_path)
+        self.assertTrue("Running and Completed Jobs" in self.browser.contents)
+        self.assertEqual(self.browser.headers['status'], '200 Ok')
+        return
+
+    def test_anonymous_forbidden(self):
+        # anonymous user will be kept out
+        browser = Browser() # get a browser w/o credentials set
+        browser.handleErrors = False
+        self.assertRaises(Unauthorized, browser.open, self.overview_path)
+        return
+
+    def test_job_reload(self):
+        # we can reload the overview page
+        self.browser.open(self.overview_path)
+        self.browser.getControl('reload').click()
+        self.assertTrue("Running and Completed Jobs" in self.browser.contents)
+        self.assertEqual(self.browser.headers['status'], '200 Ok')
+        return
+
+    def test_jobs_appear(self):
+        # any job running will appear
+        self.browser.open(self.overview_path)
+        job = self.start_job()
+        wait_for_result(job)
+        self.browser.getControl('reload').click()
+        self.assertEqual(self.browser.headers['status'], '200 Ok')
+        self.assertTrue('status: Completed' in self.browser.contents)
+        return
+
+    def test_job_removal(self):
+        # we can remove jobs via the overview page
+        self.browser.open(self.overview_path)
+        job = self.start_job()
+        wait_for_result(job)
+        self.browser.getControl('reload').click()
+        self.assertEqual(len(list(getUtility(IJobManager).jobs())), 1)
+        self.browser.getControl('Remove').click()
+        self.assertEqual(self.browser.headers['status'], '200 Ok')
+        self.assertTrue('Removed job' in self.browser.contents)
+        self.assertEqual(len(list(getUtility(IJobManager).jobs())), 0)
+        return
+
+class DataCenterJSONTests(AsyncBrowserTests):
+
+    def test_status_plain(self):
+        # if no job was triggered, no need to update something.
+        path = '%s/status' % self.datacenter_path
+        self.browser.open(path)
+        self.assertEqual(self.browser.headers['content-type'],
+                         'application/json')
+        self.assertEqual(self.browser.contents,
+                         '{"reload": false, "interval": 0, "html": ""}')
+        return
+
+    def test_status_completed(self):
+        # if all jobs are completed, we should reload the page
+        self.app[
+            'datacenter'].get_export_jobs_status = fake_get_export_jobs_status
+        path = '%s/status' % self.datacenter_path
+        self.browser.open(path)
+        self.assertEqual(self.browser.contents,
+                         '{"reload": true, "interval": 0, "html": ""}')
+        return
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/tests/test_browser.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/tests/test_browser.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/tests/test_browser.py	(revision 9217)
@@ -21,19 +21,14 @@
 import shutil
 import tempfile
-import pytz
-from datetime import datetime, timedelta
-from StringIO import StringIO
 import os
-import grok
-from zope.event import notify
-from zope.component import createObject, queryUtility
+from zc.async.testing import wait_for_result
+from zope.component import createObject, 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.kofa.testing import FunctionalLayer, FunctionalTestCase
 from waeup.kofa.app import University
+from waeup.kofa.interfaces import IJobManager
+from waeup.kofa.tests.test_async import FunctionalAsyncTestCase
 from waeup.kofa.university.faculty import Faculty
 from waeup.kofa.university.department import Department
@@ -106,5 +101,4 @@
         shutil.rmtree(self.dc_root)
 
-
 class DataCenterUITests(UniversitySetup):
     # Tests for DataCenter class views and pages
@@ -149,5 +143,27 @@
         return
 
-    def test_export(self):
+
+class DataCenterUIExportTests(UniversitySetup, FunctionalAsyncTestCase):
+    # Tests for DataCenter class views and pages
+
+    layer = FunctionalLayer
+
+    def wait_for_export_job_completed(self):
+        # helper function waiting until the current export job is completed
+        manager = getUtility(IJobManager)
+        job_id = self.app['datacenter'].running_exports[0][0]
+        job = manager.get(job_id)
+        wait_for_result(job)
+        return job_id
+
+    def stored_in_datacenter(self, job_id):
+        # tell whether job_id is stored in datacenter's running jobs list
+        for entry in list(self.app['datacenter'].running_exports):
+            if entry[0] == job_id:
+                return True
+        return False
+
+    def test_export_start(self):
+        # we can trigger export file creation
         self.browser.addHeader('Authorization', 'Basic mgr:mgrpw')
         self.browser.open(self.datacenter_path)
@@ -156,18 +172,52 @@
         self.browser.getLink("Export data").click()
         self.browser.getControl(name="exporter").value = ['faculties']
+        self.browser.getControl("Create CSV file").click()
+        self.assertEqual(self.browser.headers['Status'], '200 Ok')
+        return
+
+    def test_export_download(self):
+        # we can download a generated export result
+        self.test_export_start()
+        # while the export file is created, we get a reload button
+        # (or a loading bar if javascript is enabled)...
+        self.browser.getControl("Reload").click()
+        # ...which is displayed as long as the job is not finished.
+        # When the job is finished and we reload the page...
+        job_id = self.wait_for_export_job_completed()
+        try:
+            self.browser.getControl("Reload").click()
+        except LookupError:
+            # if the job completed very fast, we will get the download
+            # link immediately
+            pass
+        # ...we can download the result
         self.browser.getControl("Download").click()
-        self.assertEqual(self.browser.headers['Status'], '200 Ok')
-        self.assertEqual(self.browser.headers['Content-Type'],
+        self.assertEqual(self.browser.headers['content-type'],
                          'text/csv; charset=UTF-8')
-        self.assertTrue ('WAeUP.KofaFaculties.csv' in
-            self.browser.headers['content-disposition'])
+        self.assertEqual(self.browser.headers['content-disposition'],
+                         'attachment; filename="WAeUP.Kofa_faculties.csv')
         self.assertEqual(self.browser.contents,
             'code,title,title_prefix,users_with_local_roles\r\n'
             'fac1,Unnamed Faculty,faculty,[]\r\n')
-        logfile = os.path.join(
-            self.app['datacenter'].storage, 'logs', 'datacenter.log')
-        logcontent = open(logfile).read()
-        self.assertTrue('zope.mgr - browser.pages.ExportCSVView - '
-                        'exported: faculties' in logcontent)
+
+        # after download, the job and the result file are removed
+        manager = getUtility(IJobManager)
+        result = manager.get(job_id)
+        self.assertEqual(result, None)
+        self.assertEqual(self.stored_in_datacenter(job_id), False)
+        #logfile = os.path.join(
+        #    self.app['datacenter'].storage, 'logs', 'datacenter.log')
+        #logcontent = open(logfile).read()
+        #self.assertTrue('zope.mgr - browser.pages.ExportCSVView - '
+        #                'exported: faculties' in logcontent)
+        return
+
+    def test_export_discard(self):
+        # we can discard a generated export result
+        self.test_export_start()
+        self.wait_for_export_job_completed()
+        self.browser.open(self.datacenter_path + '/@@export')
+        self.browser.getControl("Discard").click()
+        self.assertTrue('Discarded export result' in self.browser.contents)
         return
 
Index: main/waeup.kofa/trunk/src/waeup/kofa/catalog.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/catalog.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/catalog.py	(revision 9217)
@@ -19,6 +19,4 @@
 """
 import grok
-from grok import index
-from hurry.query import Eq
 from hurry.query.interfaces import IQuery
 from hurry.query.query import Query
@@ -26,6 +24,4 @@
 from zope.component import getUtility
 from zope.intid.interfaces import IIntIds
-
-from waeup.kofa.app import University
 from waeup.kofa.interfaces import IQueryResultItem
 
Index: main/waeup.kofa/trunk/src/waeup/kofa/configure.zcml
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/configure.zcml	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/configure.zcml	(revision 9217)
@@ -3,19 +3,8 @@
            xmlns:i18n="http://namespaces.zope.org/i18n">
 
-  <!-- This will load and configure most packages we need. -->
+  <include package="waeup.kofa" file="locales.zcml" />
   <include package="grok" />
-
-  <!-- Meta configurations. -->
-  <include package="zope.sendmail" file="meta.zcml" />
   <include package="waeup.kofa" file="meta.zcml" />
-
-  <!-- Extra packages we have to configure -->
-  <include package="grokcore.message" />
-  <include package="grokui.admin" />
-  <include package="hurry.zoperesource" />
-  <include package="megrok.layout" />
-  <include package="zc.sourcefactory" />
-  <include package="zope.app.authentication" />
-  <include package="zope.sendmail" />
+  <includeDependencies package="." />
 
   <!-- Comment the following line if you don't want dolmen.beaker,
@@ -26,11 +15,7 @@
        be found by the includeDependencies directive. -->
   <include package="dolmen.beaker" file="configure.zcml" />
+  <grok:grok package="." />
 
-  <include package="waeup.kofa" file="locales.zcml" />
-  <grok:grok package="waeup.kofa" />
-  <!-- The following configurations are large (or expected to become
-       large) and therefore 'outsourced' - the only ethical way to do
-       outsourcing ;-)
-  -->
+  <!-- include package="." file="async.zcml" / -->
   <includeOverrides package="waeup.kofa.widgets" file="overrides.zcml" />
   <includeOverrides package="waeup.kofa.utils" file="overrides.zcml" />
Index: main/waeup.kofa/trunk/src/waeup/kofa/datacenter.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/datacenter.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/datacenter.py	(revision 9217)
@@ -31,4 +31,5 @@
                                    IDataCenterStorageMovedEvent,
                                    IDataCenterConfig)
+from waeup.kofa.utils.batching import ExportJobContainer
 from waeup.kofa.utils.helpers import copy_filesystem_tree, merge_csv_files
 from waeup.kofa.utils.logger import Logger
@@ -37,5 +38,5 @@
 RE_LOGFILE_BACKUP_NAME = re.compile('^.+\.\d+$')
 
-class DataCenter(grok.Container, Logger):
+class DataCenter(grok.Container, Logger, ExportJobContainer):
     """A data center contains CSV files.
     """
Index: main/waeup.kofa/trunk/src/waeup/kofa/ftesting.zcml
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/ftesting.zcml	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/ftesting.zcml	(revision 9217)
@@ -9,4 +9,5 @@
   <includeOverrides package="waeup.kofa" />
   <include package="waeup.kofa" file="mail.zcml" />
+  <include package="waeup.kofa" file="async_single.zcml" />
 
   <!-- Where should the datacenter reside by default? -->
Index: main/waeup.kofa/trunk/src/waeup/kofa/hostels/browser.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/hostels/browser.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/hostels/browser.py	(revision 9217)
@@ -22,5 +22,5 @@
 from zope.i18n import translate
 from zope.component import getUtility
-from waeup.kofa.browser import (
+from waeup.kofa.browser.layout import (
     KofaEditFormPage, KofaAddFormPage, KofaDisplayFormPage,
     NullValidator)
Index: main/waeup.kofa/trunk/src/waeup/kofa/interfaces.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/interfaces.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/interfaces.py	(revision 9217)
@@ -19,4 +19,5 @@
 import re
 import codecs
+import zc.async.interfaces
 import zope.i18nmessageid
 from datetime import datetime
@@ -29,5 +30,6 @@
 from zope.component import getUtility
 from zope.component.interfaces import IObjectEvent
-from zope.container.interfaces import INameChooser
+from zope.configuration.fields import Path
+from zope.container.interfaces import INameChooser, IContainer
 from zope.interface import Interface, Attribute
 from zope.schema.interfaces import IObject
@@ -39,4 +41,6 @@
 DELETION_MARKER = 'XXX'
 IGNORE_MARKER = '<IGNORE>'
+WAEUP_KEY = 'waeup.kofa'
+VIRT_JOBS_CONTAINER_NAME = 'jobs'
 
 CREATED = 'created'
@@ -49,4 +53,15 @@
 REGISTERED = 'courses registered'
 VALIDATED = 'courses validated'
+
+#: A dict giving job status as tuple (<STRING>, <TRANSLATED_STRING>),
+#: the latter for UI purposes.
+JOB_STATUS_MAP = {
+    zc.async.interfaces.NEW: ('new', _('new')),
+    zc.async.interfaces.COMPLETED: ('completed', _('completed')),
+    zc.async.interfaces.PENDING: ('pending', _('pending')),
+    zc.async.interfaces.ACTIVE: ('active', _('active')),
+    zc.async.interfaces.ASSIGNED: ('assigned', _('assigned')),
+    zc.async.interfaces.CALLBACKS: ('callbacks', _('callbacks')),
+    }
 
 #default_rest_frontpage = u'' + codecs.open(os.path.join(
@@ -1082,5 +1097,5 @@
         """
 
-from zope.configuration.fields import Path
+
 class IDataCenterConfig(Interface):
     path = Path(
@@ -1091,20 +1106,103 @@
         )
 
-class IChangePassword(IKofaObject):
-    """Interface needed for change pasword page.
-
-    """
-    identifier = schema.TextLine(
-        title = _(u'Unique Identifier'),
-        description = _(
-            u'User Name, Student or Applicant Id, Matriculation or '
-            u'Registration Number'),
-        required = True,
-        readonly = False,
-        )
-
-    email = schema.ASCIILine(
-        title = _(u'Email Address'),
-        required = True,
-        constraint=validate_email,
-        )
+#
+# Asynchronous job handling and related
+#
+class IJobManager(IKofaObject):
+    """A manager for asynchronous running jobs (tasks).
+    """
+    def put(job, site=None):
+        """Put a job into task queue.
+
+        If no `site` is given, queue job in context of current local
+        site.
+
+        Returns a job_id to identify the put job. This job_id is
+        needed for further references to the job.
+        """
+
+    def jobs(site=None):
+        """Get an iterable of jobs stored.
+        """
+
+    def get(job_id, site=None):
+        """Get the job with id `job_id`.
+
+        For the `site` parameter see :meth:`put`.
+        """
+
+    def remove(job_id, site=None):
+        """Remove job with `job_id` from stored jobs.
+        """
+
+    def start_test_job(site=None):
+        """Start a test job.
+        """
+
+class IProgressable(Interface):
+    """A component that can indicate its progress status.
+    """
+    percent = schema.Float(
+        title = u'Percent of job done already.',
+        )
+
+class IJobContainer(IContainer):
+    """A job container contains IJob objects.
+    """
+
+class IExportJob(zc.async.interfaces.IJob):
+    def __init__(site, exporter_name):
+        pass
+
+class IExportJobContainer(Interface):
+    """A component that contains (maybe virtually) export jobs.
+    """
+    def start_export_job(exporter_name, user_id):
+        """Start asynchronous export job.
+
+        `exporter_name` is the name of an exporter utility to be used.
+
+        `user_id` is the ID of the user that triggers the export.
+
+        The job_id is stored along with exporter name and user id in a
+        persistent list.
+
+        Returns the job ID of the job started.
+        """
+
+    def get_running_export_jobs(user_id=None):
+        """Get export jobs for user with `user_id` as list of tuples.
+
+        Each tuples holds ``<job_id>, <exporter_name>, <user_id>`` in
+        that order. The ``<exporter_name>`` is the utility name of the
+        used exporter.
+
+        If `user_id` is ``None``, all running jobs are returned.
+        """
+
+    def get_export_jobs_status(user_id=None):
+        """Get running/completed export jobs for `user_id` as list of tuples.
+
+        Each tuple holds ``<raw status>, <status translated>,
+        <exporter title>`` in that order, where ``<status
+        translated>`` and ``<exporter title>`` are translated strings
+        representing the status of the job and the human readable
+        title of the exporter used.
+        """
+
+    def delete_export_entry(entry):
+        """Delete the export denoted by `entry`.
+
+        Removes `entry` from the local `running_exports` list and also
+        removes the regarding job via the local job manager.
+
+        `entry` is a tuple ``(<job id>, <exporter name>, <user id>)``
+        as created by :meth:`start_export_job` or returned by
+        :meth:`get_running_export_jobs`.
+        """
+
+    def entry_from_job_id(job_id):
+        """Get entry tuple for `job_id`.
+
+        Returns ``None`` if no such entry can be found.
+        """
Index: main/waeup.kofa/trunk/src/waeup/kofa/startup.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/startup.py	(revision 9217)
+++ main/waeup.kofa/trunk/src/waeup/kofa/startup.py	(revision 9217)
@@ -0,0 +1,105 @@
+## $Id$
+##
+## Copyright (C) 2012 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
+##
+"""WSGI application factories that additionally set environment vars.
+
+The `grokcore.startup` factories for creating WSGI applications
+currently do not support setting of arbitrary environment vars.
+
+The below factories add this feature.
+
+Environment vars can be set in any ``.ini`` file used at startup. In
+the ``[DEFAULT]`` section set the option ``env_vars`` with key/value
+pairs as value like so::
+
+  [DEFAULT]
+  zope_conf = <path-to-zope.conf>
+  env_vars = KEY1 value1
+             KEY2 value2
+
+This would set the env vars ``KEY1`` and ``KEY2`` to the respective
+values on startup on an instance.
+
+To activate these factories, in the ``setup.py`` of your project use::
+
+  [paste.app_factory]
+  main = waeup.kofa.startup:env_app_factory
+  debug = waeup.kofa.startup:env_debug_app_factory
+
+in the entry points section (replacing the references to respective
+`grokcore.startup` factories.
+
+Info for developers: paster on startup delivers the options from
+``[DEFAULT]`` section in `.ini` file as a dictionary in the
+`global_conf`.
+"""
+import os
+from ConfigParser import RawConfigParser
+from grokcore.startup import application_factory, debug_application_factory
+
+def _set_env_vars(global_conf):
+    """Set vars from `global_conf['env_vars']` in `os.environ`.
+    """
+    env_vars = global_conf.get('env_vars', None)
+    if not env_vars:
+        return
+    for line in env_vars.split('\n'):
+        key, val = [x.strip() for x in line.strip().split(' ', 1)]
+        os.environ[key] = val
+    return
+
+def env_app_factory(global_conf, **local_conf):
+    """A WSGI application factory that sets environment vars.
+
+    This app factory provides applications as expected by ``paster``
+    and useable as ``[paste.app_factory]`` plugin in setup.py.
+
+    It's a replacement for the stock app factory provided by
+    `grokcore.startup`.
+
+    Additionally it supports extrapolation of the DEFAULT var
+    ``env_vars`` in .ini files used to configure paster.
+
+    With this factory you can set enviroment vars (as in `os.environ`)
+    via the ``env_vars`` keyword set in some `.ini` file::
+
+      env_vars = MY_KEY some_value
+
+    would set the environment variable ``MY_KEY`` to the value
+    ``some_value`` before creating the actual app.
+
+    You can also set multiple keys/values at once like this::
+
+      env_vars = MY_KEY1  Some value
+                 Another_key Anoter_value
+
+    Note, that keys may not contain whitespaces while values
+    may. Both, keys and values, are stripped before being set.
+    """
+    _set_env_vars(global_conf)
+    return application_factory(global_conf, **local_conf)
+
+def env_debug_app_factory(global_conf, **local_conf):
+    """A debugger application factory.
+
+    This is a wrapper around the real factory from `grokcore.startup`
+    that does the same additional things as :func:`env_app_factory`:
+    it sets environment vars given in `env_vars` option of a
+    configuring .ini file for paster.
+    """
+    _set_env_vars(global_conf)
+    return debug_application_factory(global_conf, **local_conf)
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/browser.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/browser.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/browser.py	(revision 9217)
@@ -33,7 +33,8 @@
     invalidate_accesscode, get_access_code)
 from waeup.kofa.accesscodes.workflow import USED
-from waeup.kofa.browser import (
+from waeup.kofa.browser.layout import (
     KofaPage, KofaEditFormPage, KofaAddFormPage, KofaDisplayFormPage,
-    ContactAdminForm, KofaForm, NullValidator)
+    KofaForm, NullValidator)
+from waeup.kofa.browser.pages import ContactAdminForm
 from waeup.kofa.browser.breadcrumbs import Breadcrumb
 from waeup.kofa.browser.resources import datepicker, datatable, tabs, warning
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/catalog.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/catalog.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/catalog.py	(revision 9217)
@@ -63,13 +63,10 @@
         self.state = context.state
         self.translated_state = context.translated_state
-        #try:
-        #    current_level = course_levels.getTerm(
-        #        context['studycourse'].current_level).title
-        #except LookupError:
-        #    current_level = None
-        #self.current_level = current_level
-
-        self.current_level = context['studycourse'].current_level
-
+        try:
+            current_level = course_levels.getTerm(
+                context['studycourse'].current_level).title
+        except LookupError:
+            current_level = None
+        self.current_level = current_level
         try:
             current_session = academic_sessions_vocab.getTerm(
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/interfaces.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/interfaces.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/interfaces.py	(revision 9217)
@@ -21,4 +21,5 @@
 from zope import schema
 from zc.sourcefactory.contextual import BasicContextualSourceFactory
+from waeup.kofa.browser.interfaces import IStudentNavigationBase
 from waeup.kofa.interfaces import (
     IKofaObject, academic_sessions_vocab, validate_email, ICSVExporter)
@@ -131,5 +132,5 @@
     unique_student_id = Attribute("""A unique student id.""")
 
-class IStudentNavigation(IKofaObject):
+class IStudentNavigation(IStudentNavigationBase):
     """Interface needed for student navigation, logging, etc.
 
Index: main/waeup.kofa/trunk/src/waeup/kofa/tests/test_app.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/tests/test_app.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/tests/test_app.py	(revision 9217)
@@ -23,5 +23,6 @@
 from zope.interface.verify import verifyClass, verifyObject
 from waeup.kofa.app import University
-from waeup.kofa.interfaces import IUniversity
+from waeup.kofa.interfaces import (
+    IUniversity, IJobManager, VIRT_JOBS_CONTAINER_NAME)
 from waeup.kofa.testing import FunctionalLayer, FunctionalTestCase
 
@@ -62,2 +63,9 @@
         self.app.updatePlugins()
         self.assertTrue('accesscodes' in self.app.keys())
+        return
+
+    def test_jobs(self):
+        # We can get the global job manager when traversing to it...
+        result = self.app.traverse(VIRT_JOBS_CONTAINER_NAME)
+        self.assertTrue(IJobManager.providedBy(result))
+        return
Index: main/waeup.kofa/trunk/src/waeup/kofa/tests/test_async.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/tests/test_async.py	(revision 9217)
+++ main/waeup.kofa/trunk/src/waeup/kofa/tests/test_async.py	(revision 9217)
@@ -0,0 +1,348 @@
+# Tests for the async module of waeup.kofa
+import os
+import re
+import shutil
+import tempfile
+import transaction
+import zc.async.configure
+import zc.async.ftesting
+import zc.async.queue
+from zc.async.interfaces import IJob
+from ZODB import DB, FileStorage
+from zope.component import getUtility, provideAdapter
+from zope.component.hooks import setSite
+from zope.container.interfaces import IContainer
+from zope.interface import verify
+from waeup.kofa.interfaces import (
+    WAEUP_KEY, IProgressable, IJobManager, IJobContainer
+    )
+from waeup.kofa.testing import FunctionalTestCase, FunctionalLayer
+from waeup.kofa.async import (
+    get_job_id, AsyncJob, ProgressableJob, JobManager, JobContainer,
+    JobManagerTraverser, JobContainerInstaller,
+    )
+
+def fake_async_func1():
+    return 23
+
+def fake_async_func2():
+    return 42
+
+class FunctionalAsyncTestCase(FunctionalTestCase):
+    """A functional test case that additionally sets up asynchronous
+    machinery.
+
+    These type of test case takes _much_ more time than regular
+    functional tests! So, only use it if really neccessary.
+    """
+
+    def setUp(self):
+        super(FunctionalAsyncTestCase, self).setUp()
+        zc.async.configure.base()
+        provideAdapter(zc.async.queue.getDefaultQueue)
+        connection = self.getRootFolder()._p_jar
+        zc.async.ftesting.setUp(connection=connection)
+        return
+
+    def tearDown(self):
+        zc.async.ftesting.tearDown()
+        super(FunctionalAsyncTestCase, self).tearDown()
+        return
+
+class CommonAsyncFunctionalTests(FunctionalTestCase):
+
+    layer = FunctionalLayer
+
+    def test_iface_async_job(self):
+        # AsyncJob implements the promised interfaces
+        job = AsyncJob(fake_async_func1)
+        verify.verifyClass(IJob, AsyncJob)
+        verify.verifyObject(IJob, job)
+        return
+
+    def test_iface_progressable_job(self):
+        # ProgressableJob implements the promised interfaces
+        job = ProgressableJob(fake_async_func1)
+        verify.verifyClass(IJob, ProgressableJob)
+        verify.verifyClass(IProgressable, ProgressableJob)
+        verify.verifyObject(IJob, job)
+        verify.verifyObject(IProgressable, job)
+        return
+
+    def test_get_job_id(self):
+        # for persisted jobs we can get an oid as job id
+        job = AsyncJob(fake_async_func1)
+        self.getRootFolder()['myjob'] = job
+        transaction.commit()
+        oid = job.__repr__().split(' ')[2][:-1]
+        result = get_job_id(job)
+        self.assertEqual(result, oid)
+        return
+
+class JobContainerTests(FunctionalTestCase):
+
+    layer = FunctionalLayer
+
+    def test_iface_job_container(self):
+        # JobContainers implement the promised interfaces
+        container = JobContainer()
+        verify.verifyClass(IContainer, JobContainer)
+        verify.verifyObject(IContainer, container)
+        verify.verifyClass(IJobContainer, JobContainer)
+        verify.verifyObject(IJobContainer, container)
+        return
+
+class JobManagerTests(FunctionalAsyncTestCase):
+
+    layer = FunctionalLayer
+
+    def setUp(self):
+        super(JobManagerTests, self).setUp()
+        self.root_folder = self.getRootFolder()
+        self.root = self.root_folder._p_jar.root()
+
+    def test_iface_job_manager(self):
+        # JobManager implements the promised interfaces
+        manager = JobManager()
+        verify.verifyClass(IJobManager, JobManager)
+        verify.verifyObject(IJobManager, manager)
+        return
+
+    def test_get_job_manager_as_util(self):
+        # we can get a job manager as global, unnamed utility
+        manager = getUtility(IJobManager)
+        self.assertTrue(isinstance(manager, JobManager))
+        return
+
+    def test_put(self):
+        # we can put jobs into the manager
+        myjob = AsyncJob(fake_async_func1)
+        manager = getUtility(IJobManager)
+        job_id = manager.put(myjob, site=self.root_folder)
+        # the returned job_id should be a string with a bunch of numbers
+        self.assertTrue(re.match('^\d+$', job_id))
+        # the job is also in the (new) jobs container
+        self.assertTrue(job_id in self.root[WAEUP_KEY].keys())
+        # the job is really stored
+        stored_job =self.root[WAEUP_KEY][job_id]
+        self.assertEqual(myjob, stored_job)
+        return
+
+    def test_put_no_site(self):
+        # if no site is around we cannot store jobs
+        myjob = AsyncJob(fake_async_func1)
+        manager = getUtility(IJobManager)
+        self.assertRaises(LookupError, manager.put, myjob)
+        return
+
+    def test_get(self):
+        # we can get a job back with a valid job_id
+        manager = getUtility(IJobManager)
+        myjob = AsyncJob(fake_async_func1)
+        job_id = manager.put(myjob, site=self.root_folder)
+        stored_job = manager.get(job_id, site=self.root_folder)
+        self.assertEqual(myjob, stored_job)
+        return
+
+    def test_get_no_site(self):
+        # if no site is around we cannot retrieve jobs
+        manager = getUtility(IJobManager)
+        self.assertRaises(LookupError, manager.get, 'some_id')
+        return
+
+    def test_get_invalid_jobid(self):
+        # invalid job_ids will result in `None` (if site is valid)
+        manager = getUtility(IJobManager)
+        result = manager.get('not-a-valid-job-id', site=self.root_folder)
+        self.assertEqual(result, None)
+        return
+
+    def test_jobs(self):
+        # we can get all jobs contained
+        manager = getUtility(IJobManager)
+        myjob1 = AsyncJob(fake_async_func1)
+        job_id1 = manager.put(myjob1, site=self.root_folder)
+        myjob2 = AsyncJob(fake_async_func2)
+        job_id2 = manager.put(myjob2, site=self.root_folder)
+        result = sorted(list(manager.jobs(site=self.root_folder)))
+        self.assertEqual(
+            result,
+            [(job_id1, myjob1), (job_id2, myjob2)]
+            )
+        return
+
+    def test_remove(self):
+        # we can remove jobs from the job container
+        manager = getUtility(IJobManager)
+        myjob = AsyncJob(fake_async_func1)
+        job_id = manager.put(myjob, site=self.root_folder)
+        container = self.root[WAEUP_KEY]
+        self.assertEqual(len(container), 1)
+        manager.remove(job_id, site=self.root_folder)
+        self.assertEqual(len(container), 0)
+        return
+
+    def test_start_test_job(self):
+        # we can start a test job
+        manager = getUtility(IJobManager)
+        job_id = manager.start_test_job(duration=1, site=self.root_folder)
+        container = self.root[WAEUP_KEY]
+        self.assertEqual(len(container), 1)
+        self.assertTrue(job_id is not None)
+        return
+
+class JobManagerTraverserTests(FunctionalAsyncTestCase):
+
+    layer = FunctionalLayer
+
+    def setUp(self):
+        super(JobManagerTraverserTests, self).setUp()
+        self.root_folder = self.getRootFolder()
+        self.root = self.root_folder._p_jar.root()
+        self.manager = getUtility(IJobManager)
+        setSite(self.getRootFolder())
+
+    def tearDown(self):
+        setSite(None)
+        super(JobManagerTraverserTests, self).tearDown()
+
+    def test_no_jobs(self):
+        # the traverser returns None if no job is available
+        traverser = JobManagerTraverser(self.manager, None)
+        result = traverser.traverse('123')
+        self.assertTrue(result is None)
+        return
+
+    def test_valid_job(self):
+        # the traverser returns the appropriate job if available
+        myjob = AsyncJob(fake_async_func1)
+        job_id = self.manager.put(myjob, site=self.root_folder)
+        traverser = JobManagerTraverser(self.manager, None)
+        result = traverser.traverse(job_id)
+        self.assertEqual(myjob, result)
+        return
+
+class FakeEvent(object):
+    # A faked DatabaseOpenedEvent as triggered at instance startup.
+    # The only attribute we need here is `database` which should
+    # return a valid ZODB.
+
+    _storage = None
+    _db = None
+
+    def __init__(self, workdir, db_name=None):
+        # workdir is the place to create a ZODB in, db_name its name.
+        self.workdir = workdir
+        self.db_name = db_name
+
+    @property
+    def database(self):
+        if self._storage is None:
+            # storage not created yet. Do it now.
+            path = os.path.join(self.workdir, 'testdb.fs')
+            self._storage = FileStorage.FileStorage(path)
+            self._db = DB(self._storage, database_name=self.db_name)
+        return self._db
+
+class FakeDB(object):
+
+    closed = False
+
+    def open(self, transaction_manager=None):
+        return self
+
+    def close(self):
+        self.closed = True
+        return
+
+class JobContainerInstallerTests(FunctionalTestCase):
+
+    layer = FunctionalLayer
+
+    def setUp(self):
+        super(JobContainerInstallerTests, self).setUp()
+        # setup two ZODBs, one unnamed, one named...
+        self.workdir1 = tempfile.mkdtemp()
+        self.workdir2 = tempfile.mkdtemp()
+        self.fake_event1 = FakeEvent(self.workdir1)
+        self.fake_event2 = FakeEvent(self.workdir2, 'mytestdb')
+        self.tm1 = transaction.TransactionManager()
+        self.tm2 = transaction.TransactionManager()
+        self.db1 = self.fake_event1.database
+        self.db2 = self.fake_event2.database
+        self.conn1 = self.db1.open(self.tm1)
+        self.conn2 = self.db2.open(self.tm2)
+
+    def tearDown(self):
+        self.tm1.abort()
+        self.tm2.abort()
+        shutil.rmtree(self.workdir1)
+        shutil.rmtree(self.workdir2)
+        super(JobContainerInstallerTests, self).tearDown()
+
+    def reopen(self):
+        # reopen connections to the ZODBs (for retrieving fresh data)...
+        self.conn1.close()
+        self.conn2.close()
+        self.conn1 = self.db1.open(self.tm1)
+        self.conn2 = self.db2.open(self.tm2)
+        return
+
+    @property
+    def root1(self):
+        # get root of first (unnamed) database
+        self.reopen()
+        return self.conn1.root()
+
+    @property
+    def root2(self):
+        # get root of second (named 'mytestdb') database
+        self.reopen()
+        return self.conn2.root()
+
+    def test_new_container(self):
+        # we can install a job container if none is available yet
+        installer = JobContainerInstaller()
+        self.assertTrue(WAEUP_KEY not in self.root1)
+        installer(self.fake_event1)
+        self.assertTrue(WAEUP_KEY in self.root1)
+        self.assertTrue(IJobContainer.providedBy(self.root1[WAEUP_KEY]))
+        return
+
+    def test_existing_container(self):
+        # we won't try to install a new container if one is already present
+        installer = JobContainerInstaller()
+        self.assertTrue(WAEUP_KEY not in self.root1)
+        installer(self.fake_event1)
+        self.assertTrue(WAEUP_KEY in self.root1)
+        self.assertTrue(IJobContainer.providedBy(self.root1[WAEUP_KEY]))
+        container = self.root1[WAEUP_KEY]
+        installer(self.fake_event1)
+        self.assertTrue(WAEUP_KEY in self.root1)
+        self.assertTrue(self.root1[WAEUP_KEY] is container)
+        return
+
+    def test_named_db(self):
+        # we can also install containers in named dbs
+        installer = JobContainerInstaller(db_name='mytestdb')
+        self.assertTrue(WAEUP_KEY not in self.root1)
+        self.assertTrue(WAEUP_KEY not in self.root2)
+        installer(self.fake_event2)
+        # now the job container is in database2 (but not database1)
+        self.assertTrue(WAEUP_KEY not in self.root1)
+        self.assertTrue(WAEUP_KEY in self.root2)
+        self.assertTrue(IJobContainer.providedBy(self.root2[WAEUP_KEY]))
+        return
+
+    def test_broken_db(self):
+        # if writing to db fails, we close the connection anyway...
+        installer = JobContainerInstaller()
+        self.conn1.close()
+        # create a fake db
+        db = FakeDB()
+        self.fake_event1._storage = 'dummy'
+        self.fake_event1._db = db
+        self.assertEqual(db.closed, False)
+        self.assertRaises(AttributeError, installer, self.fake_event1)
+        self.assertEqual(db.closed, True)
+        return
Index: main/waeup.kofa/trunk/src/waeup/kofa/tests/test_datacenter.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/tests/test_datacenter.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/tests/test_datacenter.py	(revision 9217)
@@ -10,5 +10,6 @@
 from zope.interface.verify import verifyObject, verifyClass
 from waeup.kofa.datacenter import DataCenter
-from waeup.kofa.interfaces import IDataCenter, IDataCenterConfig
+from waeup.kofa.interfaces import (
+    IDataCenter, IDataCenterConfig, IExportJobContainer)
 
 class DataCenterLogQueryTests(unittest.TestCase):
@@ -180,9 +181,11 @@
         return
 
-    def test_iface(self):
+    def test_ifaces(self):
         # we comply with interfaces
         obj = DataCenter()
         verifyClass(IDataCenter, DataCenter)
+        verifyClass(IExportJobContainer, DataCenter)
         verifyObject(IDataCenter, obj)
+        verifyObject(IExportJobContainer, obj)
         return
 
Index: main/waeup.kofa/trunk/src/waeup/kofa/tests/test_startup.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/tests/test_startup.py	(revision 9217)
+++ main/waeup.kofa/trunk/src/waeup/kofa/tests/test_startup.py	(revision 9217)
@@ -0,0 +1,76 @@
+# Tests for local startup functions.
+import os
+import shutil
+import tempfile
+import unittest
+from zope.app.wsgi import WSGIPublisherApplication
+from waeup.kofa.startup import env_app_factory, env_debug_app_factory
+
+ZOPE_CONF_TEMPL = '''
+site-definition %s
+
+<zodb>
+  <mappingstorage />
+</zodb>
+
+<eventlog>
+  <logfile>
+    path STDOUT
+  </logfile>
+</eventlog>
+'''
+
+class StartUpTests(unittest.TestCase):
+
+    def setUp(self):
+        self.workdir = tempfile.mkdtemp()
+        self.site_zcml = os.path.join(self.workdir, 'site.zcml')
+        open(self.site_zcml, 'w').write('<configure />')
+        self.zope_conf = os.path.join(self.workdir, 'zope.conf')
+        open(self.zope_conf, 'wb').write(ZOPE_CONF_TEMPL % self.site_zcml)
+        return
+
+    def tearDown(self):
+        shutil.rmtree(self.workdir)
+        for name in ('TEST_FOO', 'TEST_BAR'):
+            if name in os.environ:
+                del os.environ[name]
+        return
+
+    def test_env_app_factory(self):
+        # We can create plain WSGI apps
+        factory = env_app_factory({'zope_conf': self.zope_conf})
+        self.assertTrue(
+            isinstance(factory, WSGIPublisherApplication))
+        return
+
+    def test_env_app_factory_single_env_var(self):
+        # We can create WSGI apps with a single env var set
+        factory = env_app_factory({'zope_conf': self.zope_conf,
+                                   'env_vars': 'TEST_FOO value1'})
+        self.assertEqual(os.environ.get('TEST_FOO', None), 'value1')
+        return
+
+    def test_env_app_factory_multiple_env_vars(self):
+        # We can create WSGI apps with multiple env vars set
+        env_vars = 'TEST_FOO  value1   \n  TEST_BAR  value2'
+        factory = env_app_factory({'zope_conf': self.zope_conf,
+                                   'env_vars': env_vars})
+        self.assertEqual(os.environ.get('TEST_FOO', None), 'value1')
+        self.assertEqual(os.environ.get('TEST_BAR', None), 'value2')
+        return
+
+    def test_env_debug_app_factory(self):
+        # We can create debugger WSGI apps
+        factory = env_debug_app_factory({'zope_conf': self.zope_conf})
+        self.assertTrue(
+            isinstance(factory, WSGIPublisherApplication))
+        return
+        pass
+
+    def test_env_debug_app_factory_single_env_var(self):
+        # We can create debugger WSGI apps with a single env var set
+        factory = env_debug_app_factory({'zope_conf': self.zope_conf,
+                                         'env_vars': 'TEST_FOO value1'})
+        self.assertEqual(os.environ.get('TEST_FOO', None), 'value1')
+        return
Index: main/waeup.kofa/trunk/src/waeup/kofa/university/course.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/university/course.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/university/course.py	(revision 9217)
@@ -21,9 +21,6 @@
 from zope.catalog.interfaces import ICatalog
 from zope.interface import implementedBy
-from zope.schema import getFields
-from zope.intid.interfaces import IIntIds
 from zope.component import getUtility
 from zope.component.interfaces import IFactory, ComponentLookupError
-from waeup.kofa.interfaces import IKofaPluggable
 from waeup.kofa.university.interfaces import ICourse, ICourseAdd
 
@@ -40,7 +37,5 @@
                  credits=0,
                  passmark=40,
-                 semester=1,
-                 former_course=False,
-                 **kw):
+                 semester=1, **kw):
         super(Course, self).__init__(**kw)
         self.title = title
@@ -49,5 +44,4 @@
         self.passmark = passmark
         self.semester = semester
-        self.former_course = former_course
 
     def longtitle(self):
@@ -89,39 +83,2 @@
         cert._p_changed = True
     return
-
-class CoursesPlugin(grok.GlobalUtility):
-    """A plugin that updates courses.
-    """
-
-    grok.implements(IKofaPluggable)
-    grok.name('courses')
-
-    deprecated_attributes = []
-
-    def setup(self, site, name, logger):
-        return
-
-    def update(self, site, name, logger):
-        cat = getUtility(ICatalog, name='courses_catalog')
-        results = cat.apply({'code':(None,None)})
-        uidutil = getUtility(IIntIds, context=cat)
-        items = getFields(ICourse).items()
-        for r in results:
-            o = uidutil.getObject(r)
-            # Add new attributes
-            for i in items:
-                if not hasattr(o,i[0]):
-                    setattr(o,i[0],i[1].missing_value)
-                    logger.info(
-                        'CoursesPlugin: %s attribute %s added.' % (
-                        o.code,i[0]))
-            # Remove deprecated attributes
-            for i in self.deprecated_attributes:
-                try:
-                    delattr(o,i)
-                    logger.info(
-                        'CoursesPlugin: %s attribute %s deleted.' % (
-                        o.code,i))
-                except AttributeError:
-                    pass
-        return
Index: main/waeup.kofa/trunk/src/waeup/kofa/university/interfaces.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/university/interfaces.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/university/interfaces.py	(revision 9217)
@@ -168,13 +168,4 @@
         )
 
-    former_course = schema.Bool(
-        title = _(u'Former Course'),
-        description = _(
-            u'If this attribute is being set all certificate courses '
-            'referring to this course will be automatically deleted.'),
-        required = False,
-        default = False,
-        )
-
     def longtitle():
         """
Index: main/waeup.kofa/trunk/src/waeup/kofa/utils/batching.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/utils/batching.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/utils/batching.py	(revision 9217)
@@ -25,14 +25,19 @@
 import datetime
 import os
+import shutil
 import tempfile
 import time
 from cStringIO import StringIO
-from zope.component import createObject
+from persistent.list import PersistentList
+from zope.component import createObject, getUtility
+from zope.component.hooks import setSite
 from zope.interface import Interface
 from zope.schema import getFields
 from zope.event import notify
+from waeup.kofa.async import AsyncJob
 from waeup.kofa.interfaces import (
-    IBatchProcessor, FatalCSVError, IObjectConverter,
-    ICSVExporter, IGNORE_MARKER, DuplicationError)
+    IBatchProcessor, FatalCSVError, IObjectConverter, IJobManager,
+    ICSVExporter, IGNORE_MARKER, DuplicationError, JOB_STATUS_MAP,
+    IExportJobContainer, IExportJob)
 
 class BatchProcessor(grok.GlobalUtility):
@@ -361,5 +366,5 @@
                     self.writeFailedRow(
                         failed_writer, string_row,
-                        "Cannot remove: no such entry")
+                        "Cannot remove: no such entry.")
                     continue
                 self.delEntry(row, site)
@@ -498,2 +503,163 @@
         """
         raise NotImplementedError
+
+
+def export_job(site, exporter_name):
+    """Export all entries delivered by exporter and store it in a temp file.
+
+    `site` gives the site to search. It will be passed to the exporter
+    and also be set as 'current site' as the function is used in
+    asynchronous jobs which run in their own threads and have no site
+    set initially. Therefore `site` must also be a valid value for use
+    with `zope.component.hooks.setSite()`.
+
+    `exporter_name` is the utility name under which the desired
+    exporter was registered with the ZCA.
+
+    The resulting CSV file will be stored in a new temporary directory
+    (using :func:`tempfile.mkdtemp`). It will be named after the
+    exporter used with `.csv` filename extension.
+
+    Returns the path to the created CSV file.
+
+    .. note:: It is the callers responsibility to clean up the used
+              file and its parent directory.
+    """
+    setSite(site)
+    exporter = getUtility(ICSVExporter, name=exporter_name)
+    output_dir = tempfile.mkdtemp()
+    filename = '%s.csv' % exporter_name
+    output_path = os.path.join(output_dir, filename)
+    exporter.export_all(site, filepath=output_path)
+    return output_path
+
+class AsyncExportJob(AsyncJob):
+    """An IJob that exports data to CSV files.
+
+    `AsyncExportJob` instances are regular `AsyncJob` instances with a
+    different constructor API. Instead of a callable to execute, you
+    must pass a `site` and some `exporter_name` to trigger an export.
+
+    The real work is done when an instance of this class is put into a
+    queue. See :mod:`waeup.kofa.async` to learn more about
+    asynchronous jobs.
+
+    The `exporter_name` must be the name under which an ICSVExporter
+    utility was registered with the ZCA.
+
+    The `site` must be a valid site  or ``None``.
+
+    The result of an `AsyncExportJob` is the path to generated CSV
+    file. The file will reside in a temporary directory that should be
+    removed after being used.
+    """
+    grok.implements(IExportJob)
+
+    def __init__(self, site, exporter_name):
+        super(AsyncExportJob, self).__init__(
+            export_job, site, exporter_name)
+
+class ExportJobContainer(object):
+    """A mix-in that provides functionality for asynchronous export jobs.
+    """
+    grok.implements(IExportJobContainer)
+    running_exports = PersistentList()
+
+    def start_export_job(self, exporter_name, user_id):
+        """Start asynchronous export job.
+
+        `exporter_name` is the name of an exporter utility to be used.
+
+        `user_id` is the ID of the user that triggers the export.
+
+        The job_id is stored along with exporter name and user id in a
+        persistent list.
+
+        Returns the job ID of the job started.
+        """
+        site = grok.getSite()
+        manager = getUtility(IJobManager)
+        job = AsyncExportJob(site, exporter_name)
+        job_id = manager.put(job)
+        # Make sure that the persisted list is stored in ZODB
+        self.running_exports = PersistentList(self.running_exports)
+        self.running_exports.append((job_id, exporter_name, user_id))
+        return job_id
+
+    def get_running_export_jobs(self, user_id=None):
+        """Get export jobs for user with `user_id` as list of tuples.
+
+        Each tuples holds ``<job_id>, <exporter_name>, <user_id>`` in
+        that order. The ``<exporter_name>`` is the utility name of the
+        used exporter.
+
+        If `user_id` is ``None``, all running jobs are returned.
+        """
+        entries = []
+        to_delete = []
+        manager = getUtility(IJobManager)
+        for entry in self.running_exports:
+            if user_id is not None and entry[2] != user_id:
+                continue
+            if manager.get(entry[0]) is None:
+                to_delete.append(entry)
+                continue
+            entries.append(entry)
+        if to_delete:
+            self.running_exports = PersistentList(
+                [x for x in self.running_exports if x not in to_delete])
+        return entries
+
+    def get_export_jobs_status(self, user_id=None):
+        """Get running/completed export jobs for `user_id` as list of tuples.
+
+        Each tuple holds ``<raw status>, <status translated>,
+        <exporter title>`` in that order, where ``<status
+        translated>`` and ``<exporter title>`` are translated strings
+        representing the status of the job and the human readable
+        title of the exporter used.
+        """
+        entries = self.get_running_export_jobs(user_id)
+        result = []
+        manager = getUtility(IJobManager)
+        for entry in entries:
+            job = manager.get(entry[0])
+            if job is None:
+                continue
+            status, status_translated = JOB_STATUS_MAP[job.status]
+            exporter_name = getUtility(ICSVExporter, name=entry[1]).title
+            result.append((status, status_translated, exporter_name))
+        return result
+
+    def delete_export_entry(self, entry):
+        """Delete the export denoted by `entry`.
+
+        Removes given entry from the local `running_exports` list and also
+        removes the regarding job via the local job manager.
+
+        `entry` must be a tuple ``(<job id>, <exporter name>, <user
+        id>)`` as created by :meth:`start_export_job` or returned by
+        :meth:`get_running_export_jobs`.
+        """
+        manager = getUtility(IJobManager)
+        job = manager.get(entry[0])
+        if job is not None:
+            # remove created export file
+            if isinstance(job.result, basestring):
+                if os.path.exists(os.path.dirname(job.result)):
+                    shutil.rmtree(os.path.dirname(job.result))
+        manager.remove(entry[0], self)
+        new_entries = [x for x in self.running_exports
+                       if x != entry]
+        self.running_exports = PersistentList(new_entries)
+        return
+
+    def entry_from_job_id(self, job_id):
+        """Get entry tuple for `job_id`.
+
+        Returns ``None`` if no such entry can be found.
+        """
+        for entry in self.running_exports:
+            if entry[0] == job_id:
+                return entry
+        return None
Index: main/waeup.kofa/trunk/src/waeup/kofa/utils/tests/test_batching.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/utils/tests/test_batching.py	(revision 9216)
+++ main/waeup.kofa/trunk/src/waeup/kofa/utils/tests/test_batching.py	(revision 9217)
@@ -23,6 +23,7 @@
 import tempfile
 import unittest
+from zc.async.interfaces import IJob, COMPLETED
 from zope import schema
-from zope.component import provideUtility
+from zope.component import provideUtility, getGlobalSiteManager
 from zope.component.factory import Factory
 from zope.component.hooks import clearSite
@@ -30,7 +31,11 @@
 from zope.interface import Interface, implements, verify
 from waeup.kofa.app import University
-from waeup.kofa.interfaces import ICSVExporter, IBatchProcessor
+from waeup.kofa.interfaces import (
+    ICSVExporter, IBatchProcessor, IExportJobContainer, IJobManager,
+    IExportJob)
 from waeup.kofa.testing import FunctionalLayer, FunctionalTestCase
-from waeup.kofa.utils.batching import ExporterBase
+from waeup.kofa.utils.batching import (
+    ExporterBase, BatchProcessor, export_job, AsyncExportJob,
+    ExportJobContainer)
 
 optionflags = (
@@ -71,12 +76,16 @@
         self.owner = owner
         self.taxpayer = taxpayer
-#Cave = attrs_to_fields(Cave)
 
 stoneville = dict
 
-from waeup.kofa.utils.batching import BatchProcessor
+SAMPLE_DATA = """name,dinoports,owner,taxpayer
+Barneys Home,2,Barney,1
+Wilmas Asylum,1,Wilma,1
+Freds Dinoburgers,10,Fred,0
+Joeys Drive-in,110,Joey,0
+"""
+
 class CaveProcessor(BatchProcessor):
     util_name = 'caveprocessor'
-    #grok.name(util_name)
     name = 'Cave Processor'
     iface = ICave
@@ -136,11 +145,5 @@
         # Provide sample data
         self.newcomers_csv = os.path.join(self.workdir, 'newcomers.csv')
-        open(self.newcomers_csv, 'wb').write(
-            """name,dinoports,owner,taxpayer
-Barneys Home,2,Barney,1
-Wilmas Asylum,1,Wilma,1
-Freds Dinoburgers,10,Fred,0
-Joeys Drive-in,110,Joey,0
-""")
+        open(self.newcomers_csv, 'wb').write(SAMPLE_DATA)
         self.setupLogger()
         self.stoneville = stoneville
@@ -336,2 +339,215 @@
         self.assertEqual(result, None)
         return
+
+
+class CaveExporter(ExporterBase):
+    # A minimal fake exporter suitable to be called by export_jobs
+    fields = ('name', 'dinoports', 'owner', 'taxpayer')
+    title = u'Dummy cave exporter'
+
+    def export_all(self, site, filepath=None):
+        if filepath is None:
+            return SAMPLE_DATA
+        open(filepath, 'wb').write(SAMPLE_DATA)
+        return
+
+class ExportJobTests(unittest.TestCase):
+    # Test asynchronous export functionality (simple cases)
+
+    def setUp(self):
+        # register a suitable ICSVExporter as named utility
+        self.exporter = CaveExporter()
+        self.gsm = getGlobalSiteManager()
+        self.gsm.registerUtility(
+            self.exporter, ICSVExporter, name='cave_exporter')
+
+    def tearDown(self):
+        self.gsm.unregisterUtility(self.exporter)
+
+    def test_export_job_func(self):
+        # the export_job func does really export data...
+        result_path = export_job(None, 'cave_exporter')
+        self.assertTrue(os.path.isfile(result_path))
+        contents = open(result_path, 'rb').read()
+        shutil.rmtree(os.path.dirname(result_path))
+        self.assertEqual(contents, SAMPLE_DATA)
+        return
+
+    def test_export_job_interfaces(self):
+        # the AsyncExportJob implements promised interfaces correctly...
+        job = AsyncExportJob(None, None)
+        verify.verifyClass(IJob, AsyncExportJob)
+        verify.verifyObject(IJob, job)
+        verify.verifyClass(IExportJob, AsyncExportJob)
+        verify.verifyObject(IExportJob, job)
+        return
+
+
+class FakeJob(object):
+
+    status = COMPLETED
+    result = None
+
+class FakeJobWithResult(FakeJob):
+
+    def __init__(self):
+        dir_path = tempfile.mkdtemp()
+        self.result = os.path.join(dir_path, 'fake.csv')
+        open(self.result, 'wb').write('a fake result')
+        return
+
+class FakeJobManager(object):
+
+    _jobs = dict()
+    _curr_num = 1
+
+    def get(self, job_id):
+        if job_id == '3':
+            return FakeJob()
+        return self._jobs.get(job_id, None)
+
+    def put(self, job):
+        num = str(self._curr_num)
+        self._jobs[num] = job
+        self._curr_num += 1
+        return num
+
+    def remove(self, job_id, site):
+        if job_id in self._jobs:
+            del self._jobs[job_id]
+        return
+
+class ExportJobContainerTests(unittest.TestCase):
+    # Test ExportJobContainer
+
+    def setUp(self):
+        # register a suitable ICSVExporter as named utility
+        self.exporter = CaveExporter()
+        self.job_manager = FakeJobManager()
+        self.gsm = getGlobalSiteManager()
+        self.gsm.registerUtility(
+            self.exporter, ICSVExporter, name='cave_exporter')
+        self.gsm.registerUtility(
+            self.job_manager, IJobManager)
+
+    def tearDown(self):
+        self.gsm.unregisterUtility(self.exporter)
+        self.gsm.unregisterUtility(self.job_manager, IJobManager)
+
+    def test_export_job_interfaces(self):
+        # the ExportJobContainer implements promised interfaces correctly...
+        container = ExportJobContainer()
+        verify.verifyClass(IExportJobContainer, ExportJobContainer)
+        verify.verifyObject(IExportJobContainer, container)
+        return
+
+    def test_start_export_job(self):
+        # we can start jobs
+        container = ExportJobContainer()
+        container.start_export_job('cave_exporter', 'bob')
+        result = self.job_manager._jobs.values()[0]
+        self.assertTrue(IJob.providedBy(result))
+        self.assertEqual(
+            container.running_exports,
+            [('1', 'cave_exporter', 'bob')]
+            )
+        return
+
+    def test_get_running_export_jobs_all(self):
+        # we can get export jobs of all users
+        container = ExportJobContainer()
+        container.start_export_job('cave_exporter', 'bob')
+        container.start_export_job('cave_exporter', 'alice')
+        result = container.get_running_export_jobs()
+        self.assertEqual(
+            result,
+            [('1', 'cave_exporter', 'bob'),
+             ('2', 'cave_exporter', 'alice')]
+            )
+        return
+
+    def test_get_running_export_jobs_user(self):
+        # we can get the export jobs running for a certain user
+        container = ExportJobContainer()
+        container.start_export_job('cave_exporter', 'bob')
+        container.start_export_job('cave_exporter', 'alice')
+        result1 = container.get_running_export_jobs(user_id='alice')
+        result2 = container.get_running_export_jobs(user_id='foo')
+        self.assertEqual(
+            result1, [('2', 'cave_exporter', 'alice')])
+        self.assertEqual(
+            result2, [])
+        return
+
+    def test_get_running_export_jobs_only_if_exist(self):
+        # we get only jobs that are accessible through the job manager...
+        container = ExportJobContainer()
+        container.start_export_job('cave_exporter', 'bob')
+        container.start_export_job('cave_exporter', 'bob')
+        self.assertTrue(
+            ('2', 'cave_exporter', 'bob') in container.running_exports)
+        # we remove the second entry from job manager
+        del self.job_manager._jobs['2']
+        result = container.get_running_export_jobs(user_id='bob')
+        self.assertEqual(
+            result, [('1', 'cave_exporter', 'bob')])
+        self.assertTrue(
+            ('2', 'cave_exporter', 'bob') not in container.running_exports)
+        return
+
+    def test_get_export_job_status(self):
+        # we can get the stati of jobs...
+        container = ExportJobContainer()
+        container.start_export_job('cave_exporter', 'alice')
+        container.start_export_job('cave_exporter', 'bob')
+        container.start_export_job('cave_exporter', 'bob')
+        result = container.get_export_jobs_status(user_id='bob')
+        # we'll get the raw value, a translation and the title of the
+        # exporter
+        self.assertEqual(
+            result,
+            [('new', u'new', u'Dummy cave exporter'),
+             ('completed', u'completed', u'Dummy cave exporter')]
+            )
+        return
+
+    def test_delete_export_entry(self):
+        # we can remove export entries in local lists and the job
+        # manager as well...
+        container = ExportJobContainer()
+        container.start_export_job('cave_exporter', 'bob')
+        entry = container.running_exports[0]
+        container.delete_export_entry(entry)
+        # both, running_exports list and job manager are empty now
+        self.assertEqual(
+            container.running_exports, [])
+        self.assertEqual(
+            self.job_manager._jobs, {})
+        return
+
+    def test_delete_export_entry_remove_file(self):
+        # any result files of exports are deleted as well
+        container = ExportJobContainer()
+        entry = ('4', 'cave_exporter', 'bob')
+        container.running_exports = [entry]
+        fake_job = FakeJobWithResult()
+        self.job_manager._jobs['4'] = fake_job
+        self.assertTrue(os.path.isfile(fake_job.result))
+        container.delete_export_entry(entry)
+        self.assertTrue(not os.path.exists(fake_job.result))
+        return
+
+    def test_entry_from_job_id(self):
+        # we can get an entry for a job_id if the id exists
+        container = ExportJobContainer()
+        entry = ('4', 'cave_exporter', 'bob')
+        container.running_exports = [entry]
+        fake_job = FakeJobWithResult()
+        self.job_manager._jobs['4'] = fake_job
+        result1 = container.entry_from_job_id(None)
+        result2 = container.entry_from_job_id('4')
+        result3 = container.entry_from_job_id('23')
+        self.assertEqual(result1, None)
+        self.assertEqual(result2, ('4', 'cave_exporter', 'bob'))
+        self.assertEqual(result3, None)
+        return
