Index: main/waeup.ikoba/trunk/src/waeup/ikoba/products/batching.py
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/products/batching.py	(revision 12073)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/products/batching.py	(revision 12073)
@@ -0,0 +1,99 @@
+## $Id$
+##
+## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+"""Batch processing components for product objects.
+
+Batch processors eat CSV files to add, update or remove large numbers
+of certain kinds of objects at once.
+"""
+import grok
+import unicodecsv as csv  # XXX: csv ops should move to dedicated module.
+from time import time
+from datetime import datetime
+from zope.i18n import translate
+from zope.interface import Interface
+from zope.schema import getFields
+from zope.component import queryUtility, getUtility, createObject
+from zope.event import notify
+from zope.catalog.interfaces import ICatalog
+from hurry.workflow.interfaces import IWorkflowState, IWorkflowInfo
+from waeup.ikoba.interfaces import (
+    IBatchProcessor, FatalCSVError, IObjectConverter, IUserAccount,
+    IObjectHistory, IGNORE_MARKER)
+from waeup.ikoba.interfaces import IIkobaUtils
+from waeup.ikoba.interfaces import MessageFactory as _
+from waeup.ikoba.products.interfaces import IProduct
+from waeup.ikoba.utils.batching import BatchProcessor
+
+
+class ProductProcessor(BatchProcessor):
+    """A batch processor for IProduct objects.
+    """
+    grok.implements(IBatchProcessor)
+    grok.provides(IBatchProcessor)
+    grok.context(Interface)
+    util_name = 'productprocessor'
+    grok.name(util_name)
+
+    name = _('Product Processor')
+    iface = IProduct
+
+    location_fields = ['product_id',]
+    factory_name = 'waeup.Product'
+
+    mode = None
+
+    def parentsExist(self, row, site):
+        return 'products' in site.keys()
+
+    def entryExists(self, row, site):
+        return row['product_id'] in site['products'].keys()
+
+    def getParent(self, row, site):
+        return site['products']
+
+    def getEntry(self, row, site):
+        if not self.entryExists(row, site):
+            return None
+        parent = self.getParent(row, site)
+        return parent.get(row['product_id'])
+
+    def addEntry(self, obj, row, site):
+        parent = self.getParent(row, site)
+        parent.addProduct(obj)
+        return
+
+    def delEntry(self, row, site):
+        product = self.getEntry(row, site)
+        parent = self.getParent(row, site)
+        if product is not None:
+            grok.getSite().logger.info(
+                '%s - Product removed' % product.product_id)
+            del parent[product.product_id]
+        return
+
+    def updateEntry(self, obj, row, site, filename):
+        """Update obj to the values given in row.
+        """
+        items_changed = super(ProductProcessor, self).updateEntry(
+            obj, row, site, filename)
+        # Log actions...
+        location_field = self.location_fields[0]
+        grok.getSite().logger.info(
+            '%s - %s - %s - updated: %s'
+            % (self.name, filename, row[location_field], items_changed))
+        return
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/products/export.py
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/products/export.py	(revision 12073)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/products/export.py	(revision 12073)
@@ -0,0 +1,25 @@
+## $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
+##
+"""Exporters for products.
+"""
+import grok
+from waeup.ikoba.interfaces import ICSVExporter
+from waeup.ikoba.interfaces import MessageFactory as _
+from waeup.ikoba.utils.batching import ExporterBase
+from waeup.ikoba.utils.helpers import iface_names
+from waeup.ikoba.products.interfaces import IProduct
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/products/tests/sample_product_data.csv
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/products/tests/sample_product_data.csv	(revision 12073)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/products/tests/sample_product_data.csv	(revision 12073)
@@ -0,0 +1,5 @@
+product_id,title
+ABC,ABC title
+CDE,CDE title
+EFG,EFG title
+ABC,Duplicate product
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/products/tests/sample_product_data_update.csv
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/products/tests/sample_product_data_update.csv	(revision 12073)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/products/tests/sample_product_data_update.csv	(revision 12073)
@@ -0,0 +1,3 @@
+product_id,title
+ABC,ABC new title
+XYZ,Non-existing product
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/products/tests/test_batching.py
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/products/tests/test_batching.py	(revision 12073)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/products/tests/test_batching.py	(revision 12073)
@@ -0,0 +1,190 @@
+# -*- coding: utf-8 -*-
+## $Id: test_batching.py 12057 2014-11-25 13:15:27Z henrik $
+##
+## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+"""Unit tests for product data processors.
+"""
+import os
+import shutil
+import tempfile
+import unittest
+import datetime
+import grok
+from time import time
+from zope.event import notify
+from zope.component import createObject, queryUtility
+from zope.component.hooks import setSite, clearSite
+from zope.catalog.interfaces import ICatalog
+from zope.interface.verify import verifyClass, verifyObject
+
+from waeup.ikoba.app import Company
+from waeup.ikoba.interfaces import IBatchProcessor, FatalCSVError, IUserAccount
+from waeup.ikoba.products.batching import ProductProcessor
+from waeup.ikoba.products.product import Product
+from waeup.ikoba.testing import FunctionalLayer, FunctionalTestCase
+
+PRODUCT_SAMPLE_DATA = open(
+    os.path.join(os.path.dirname(__file__), 'sample_product_data.csv'),
+    'rb').read()
+
+PRODUCT_HEADER_FIELDS = PRODUCT_SAMPLE_DATA.split(
+    '\n')[0].split(',')
+
+PRODUCT_SAMPLE_DATA_UPDATE = open(
+    os.path.join(os.path.dirname(__file__), 'sample_product_data_update.csv'),
+    'rb').read()
+
+PRODUCT_HEADER_FIELDS_UPDATE = PRODUCT_SAMPLE_DATA_UPDATE.split(
+    '\n')[0].split(',')
+
+class ProductImportExportSetup(FunctionalTestCase):
+
+    layer = FunctionalLayer
+
+    def setUp(self):
+        super(ProductImportExportSetup, self).setUp()
+        self.dc_root = tempfile.mkdtemp()
+        self.workdir = tempfile.mkdtemp()
+        app = Company()
+        app['datacenter'].setStoragePath(self.dc_root)
+        self.getRootFolder()['app'] = app
+        self.app = self.getRootFolder()['app']
+        setSite(app)
+
+        self.logfile = os.path.join(
+            self.app['datacenter'].storage, 'logs', 'main.log')
+        return
+
+    def tearDown(self):
+        super(ProductImportExportSetup, self).tearDown()
+        shutil.rmtree(self.workdir)
+        shutil.rmtree(self.dc_root)
+        clearSite()
+        return
+
+    def setup_for_export(self):
+        product = Product()
+        product.product_id = u'LIC'
+        self.app['products'][product.product_id] = self.product = product
+        self.outfile = os.path.join(self.workdir, 'myoutput.csv')
+        return
+
+
+class ProductProcessorTest(ProductImportExportSetup):
+
+    layer = FunctionalLayer
+
+    def setUp(self):
+        super(ProductProcessorTest, self).setUp()
+
+        # Add product with subobjects
+        product = Product()
+        self.app['products'].addProduct(product)
+        product.title = u'Our Product'
+        notify(grok.ObjectModifiedEvent(product))
+        self.product = self.app['products'][product.product_id]
+
+        self.processor = ProductProcessor()
+        self.csv_file = os.path.join(self.workdir, 'sample_product_data.csv')
+        self.csv_file_update = os.path.join(
+            self.workdir, 'sample_product_data_update.csv')
+        open(self.csv_file, 'wb').write(PRODUCT_SAMPLE_DATA)
+        open(self.csv_file_update, 'wb').write(PRODUCT_SAMPLE_DATA_UPDATE)
+
+    def test_interface(self):
+        # Make sure we fulfill the interface contracts.
+        assert verifyObject(IBatchProcessor, self.processor) is True
+        assert verifyClass(
+            IBatchProcessor, ProductProcessor) is True
+
+    def test_parentsExist(self):
+        self.assertFalse(self.processor.parentsExist(None, dict()))
+        self.assertTrue(self.processor.parentsExist(None, self.app))
+
+    def test_entryExists(self):
+        assert self.processor.entryExists(
+            dict(product_id='ID_NONE'), self.app) is False
+        assert self.processor.entryExists(
+            dict(product_id='NA'), self.app) is True
+
+    def test_getParent(self):
+        parent = self.processor.getParent(None, self.app)
+        assert parent is self.app['products']
+
+    def test_getEntry(self):
+        assert self.processor.getEntry(
+            dict(product_id='ID_NONE'), self.app) is None
+        assert self.processor.getEntry(
+            dict(product_id=self.product.product_id), self.app) is self.product
+
+    def test_addEntry(self):
+        new_product = Product()
+        new_product.product_id = u'NEW'
+        self.processor.addEntry(
+            new_product, dict(), self.app)
+        assert len(self.app['products'].keys()) == 2
+
+    def test_checkConversion(self):
+        errs, inv_errs, conv_dict = self.processor.checkConversion(
+            dict(product_id='ABC'))
+        self.assertEqual(len(errs),0)
+        return
+
+    def test_delEntry(self):
+        assert self.product.product_id in self.app['products'].keys()
+        self.processor.delEntry(
+            dict(product_id=self.product.product_id), self.app)
+        assert self.product.product_id not in self.app['products'].keys()
+
+    def test_import(self):
+        num, num_warns, fin_file, fail_file = self.processor.doImport(
+            self.csv_file, PRODUCT_HEADER_FIELDS)
+        self.assertEqual(num_warns,1)
+        self.assertEqual(len(self.app['products']), 4)
+        self.assertEqual(self.app['products']['ABC'].title,'ABC title')
+        logcontent = open(self.logfile).read()
+        # Logging message from updateEntry
+        self.assertTrue(
+            'INFO - system - Product Processor - sample_product_data - '
+            'EFG - updated: product_id=EFG, title=EFG title\n'
+            in logcontent)
+        failcontent = open(fail_file).read()
+        self.assertTrue(
+            'ABC,Duplicate product,This object already exists.'
+            in failcontent)
+        shutil.rmtree(os.path.dirname(fin_file))
+
+    def test_import_update(self):
+        num, num_warns, fin_file, fail_file = self.processor.doImport(
+            self.csv_file, PRODUCT_HEADER_FIELDS)
+        shutil.rmtree(os.path.dirname(fin_file))
+        num, num_warns, fin_file, fail_file = self.processor.doImport(
+            self.csv_file_update, PRODUCT_HEADER_FIELDS_UPDATE, 'update')
+        self.assertEqual(num_warns,1)
+        # title has changed
+        self.assertEqual(self.app['products']['ABC'].title,'ABC new title')
+        logcontent = open(self.logfile).read()
+        # Logging message from updateEntry
+        self.assertTrue(
+            'INFO - system - Product Processor - sample_product_data_update - '
+            'ABC - updated: product_id=ABC, title=ABC new title\n'
+            in logcontent)
+        failcontent = open(fail_file).read()
+        self.assertTrue(
+            'XYZ,Non-existing product,Cannot update: no such entry'
+            in failcontent)
+        shutil.rmtree(os.path.dirname(fin_file))
Index: main/waeup.ikoba/trunk/src/waeup/ikoba/products/tests/test_browser.py
===================================================================
--- main/waeup.ikoba/trunk/src/waeup/ikoba/products/tests/test_browser.py	(revision 12072)
+++ main/waeup.ikoba/trunk/src/waeup/ikoba/products/tests/test_browser.py	(revision 12073)
@@ -178,9 +178,10 @@
 
     def test_manage_access(self):
-        # Managers can access the view page of products
+        # Managers can access the view pages of products
         # and can perform actions
         self.browser.addHeader('Authorization', 'Basic mgr:mgrpw')
         self.browser.open(self.product_path)
         self.assertEqual(self.browser.headers['Status'], '200 Ok')
+        # There is a Manage button
         self.browser.getLink("Manage").click()
         self.assertEqual(self.browser.headers['Status'], '200 Ok')
@@ -199,4 +200,7 @@
                 'waeup.local.ProductManager')[0],
             self.product)
+        # There is a View button wich directs to the index page
+        self.browser.getLink("View").click()
+        self.assertEqual(self.browser.url, self.product_path + '/index')
         # If products is deleted also the local role disappears
         self.browser.open(self.manage_container_path)
