Index: /main/waeup.kofa/trunk/src/waeup/kofa/reports.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/reports.py	(revision 9344)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/reports.py	(revision 9344)
@@ -0,0 +1,288 @@
+## $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 report generation.
+"""
+import grok
+import zc.async.interfaces
+from persistent.list import PersistentList
+from zope.component import getUtility, getUtilitiesFor
+from zope.component.hooks import setSite
+from zope.interface import implementer
+from zope.interface import Interface, Attribute
+from waeup.kofa.async import AsyncJob
+from waeup.kofa.interfaces import (
+    IJobManager, JOB_STATUS_MAP,)
+from waeup.kofa.utils.helpers import now
+
+class IReport(Interface):
+    """A report.
+    """
+    creation_dt = Attribute(
+        """Datetime when a report was created. The datetime should """
+        """reflect the point of time when the data was fetched, """
+        """not when any output was created.""")
+
+    def create_pdf():
+        """Generate a PDF copy.
+        """
+
+class IReportGenerator(Interface):
+    """A report generator.
+    """
+    def generate(site):
+        """Generate a report.
+        """
+        return Report()
+
+class IReportJob(zc.async.interfaces.IJob):
+    def __init__(site, exporter_name):
+        pass
+
+class IReportJobContainer(Interface):
+    """A component that contains (maybe virtually) report jobs.
+    """
+    def start_report_job(report_generator_name, user_id, args=[], kw={}):
+        """Start asynchronous report job.
+
+        `report_generator_name`
+            is the name of a report generator utility to be used.
+
+        `user_id`
+            is the ID of the user that triggers the report generation.
+
+        `args` and `kw`
+            args and keywords passed to the generators `generate()`
+            method.
+
+        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_report_jobs(user_id=None):
+        """Get report jobs for user with `user_id` as list of tuples.
+
+        Each tuples holds ``<job_id>, <generator_name>, <user_id>`` in
+        that order. The ``<generator_name>`` is the utility name of the
+        used report generator.
+
+        If `user_id` is ``None``, all running report jobs are returned.
+        """
+
+    def get_report_jobs_status(user_id=None):
+        """Get running/completed report jobs for `user_id` as list of tuples.
+
+        Each tuple holds ``<raw status>, <status translated>,
+        <generator title>`` in that order, where ``<status
+        translated>`` and ``<generator title>`` are translated strings
+        representing the status of the job and the human readable
+        title of the report generator used.
+        """
+
+    def delete_report_entry(entry):
+        """Delete the report job denoted by `entry`.
+
+        Removes `entry` from the local `running_report_jobs` list and
+        also removes the regarding job via the local job manager.
+
+        `entry` is a tuple ``(<job id>, <generator name>, <user id>)``
+        as created by :meth:`start_report_job` or returned by
+        :meth:`get_running_report_jobs`.
+        """
+
+    def report_entry_from_job_id(job_id):
+        """Get entry tuple for `job_id`.
+
+        Returns ``None`` if no such entry can be found.
+        """
+
+def get_generators():
+    """Get available report generators.
+
+    Returns an iterator of tuples ``<NAME, GENERATOR>`` with ``NAME``
+    being the name under which the respective generator was
+    registered.
+    """
+    for name, util in getUtilitiesFor(IReportGenerator):
+        yield name, util
+    pass
+
+@implementer(IReport)
+class Report(object):
+    creation_dt = None
+
+    def __init__(self):
+        self.creation_dt = now()
+
+    def create_pdf(self):
+        raise NotImplemented
+
+@implementer(IReportGenerator)
+class ReportGenerator(object):
+    def generate(self, site):
+        result = Report()
+        return result
+
+def report_job(site, generator_name, args=[], kw={}):
+    """Get a generator and perform report creation.
+
+    `site`
+        is the site for which the report should be created.
+    `generator_name`
+        the global utility name under which the desired generator is
+        registered.
+    `args` and `kw`
+        Arguments and keywords to be passed to the `generate()` method of
+        the desired generator. While `args` should be a list, `kw` should
+        be a dictionary.
+    """
+    setSite(site)
+    generator = getUtility(IReportGenerator, name=generator_name)
+    report = generator.generate(site, *args, **kw)
+    return report
+
+@implementer(IReportJob)
+class AsyncReportJob(AsyncJob):
+    """An IJob that creates reports.
+
+    `AsyncReportJob` instances are regular `AsyncJob` instances with a
+    different constructor API. Instead of a callable to execute, you
+    must pass a `site`, some `generator_name`, and additional args and
+    keywords to create a report.
+
+    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 `generator_name` must be the name under which an IReportGenerator
+    utility was registered with the ZCA.
+
+    The `site` must be a valid site  or ``None``.
+
+    The result of an `AsyncReportJob` is an IReport object.
+    """
+    def __init__(self, site, generator_name, args=[], kw={}):
+        super(AsyncReportJob, self).__init__(
+            report_job, site, generator_name, *args, **kw)
+
+@implementer(IReportJobContainer)
+class ReportJobContainer(object):
+    """A mix-in that provides functionality for asynchronous report jobs.
+    """
+    running_report_jobs = PersistentList()
+
+    def start_report_job(self, generator_name, user_id, args=[], kw={}):
+        """Start asynchronous export job.
+
+        `generator_name`
+            is the name of a report generator utility to be used.
+
+        `user_id`
+            is the ID of the user that triggers the report generation.
+
+        `args` and `kw`
+            args and keywords passed to the generators `generate()`
+            method.
+
+        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 = AsyncReportJob(site, generator_name, args=args, kw=kw)
+        job_id = manager.put(job)
+        # Make sure that the persisted list is stored in ZODB
+        self.running_report_jobs = PersistentList(self.running_report_jobs)
+        self.running_report_jobs.append((job_id, generator_name, user_id))
+        return job_id
+
+    def get_running_report_jobs(self, user_id=None):
+        """Get report jobs for user with `user_id` as list of tuples.
+
+        Each tuples holds ``<job_id>, <generator_name>, <user_id>`` in
+        that order. The ``<generator_name>`` is the utility name of the
+        used report generator.
+
+        If `user_id` is ``None``, all running report jobs are returned.
+        """
+        entries = []
+        to_delete = []
+        manager = getUtility(IJobManager)
+        for entry in self.running_report_jobs:
+            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_report_jobs = PersistentList(
+                [x for x in self.running_report_jobs if x not in to_delete])
+        return entries
+
+    def get_report_jobs_status(self, user_id=None):
+        """Get running/completed report jobs for `user_id` as list of tuples.
+
+        Each tuple holds ``<raw status>, <status translated>,
+        <generator title>`` in that order, where ``<status
+        translated>`` and ``<generator title>`` are translated strings
+        representing the status of the job and the human readable
+        title of the report generator used.
+        """
+        entries = self.get_running_report_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]
+            generator = getUtility(IReportGenerator, name=entry[1])
+            generator_name = getattr(generator, 'title', 'unnamed')
+            result.append((status, status_translated, generator_name))
+        return result
+
+    def delete_report_entry(self, entry):
+        """Delete the report job denoted by `entry`.
+
+        Removes `entry` from the local `running_report_jobs` list and
+        also removes the regarding job via the local job manager.
+
+        `entry` is a tuple ``(<job id>, <generator name>, <user id>)``
+        as created by :meth:`start_report_job` or returned by
+        :meth:`get_running_report_jobs`.
+        """
+        manager = getUtility(IJobManager)
+        manager.remove(entry[0], self)
+        new_entries = [x for x in self.running_report_jobs
+                       if x != entry]
+        self.running_report_jobs = PersistentList(new_entries)
+        return
+
+    def report_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_report_jobs:
+            if entry[0] == job_id:
+                return entry
+        return None
Index: /main/waeup.kofa/trunk/src/waeup/kofa/tests/test_reports.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/tests/test_reports.py	(revision 9344)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/tests/test_reports.py	(revision 9344)
@@ -0,0 +1,260 @@
+import datetime
+import grok
+import pytz
+import unittest
+from zc.async.interfaces import IJob
+from zope.component import getGlobalSiteManager
+from zope.interface.verify import verifyClass, verifyObject
+from waeup.kofa.interfaces import IJobManager
+from waeup.kofa.reports import (
+    IReport, IReportGenerator, IReportJob, IReportJobContainer,)
+from waeup.kofa.reports import (
+    Report, ReportGenerator, get_generators, report_job, AsyncReportJob,
+    ReportJobContainer,)
+from waeup.kofa.testing import FakeJob, FakeJobManager
+
+class FakeReportGenerator(ReportGenerator):
+    # fake report generator for tests.
+
+    def __init__(self, name=None, perm_create=None, perm_view=None):
+        self.name = name
+        self.perm_create = perm_create
+        self.perm_view = perm_view
+        return
+
+    def __eq__(self, obj):
+        return self.name == obj.name
+
+    def generate(self, site):
+        return Report()
+
+class ReportTests(unittest.TestCase):
+
+    def test_iface(self):
+        # make sure we fullfill the promised contracts
+        obj = Report()
+        verifyClass(IReport, Report)
+        verifyObject(IReport, obj)
+        return
+
+    def test_creation_dt(self):
+        # a report sets a datetime timestamp when created
+        report = Report()
+        self.assertTrue(hasattr(report, 'creation_dt'))
+        self.assertTrue(isinstance(report.creation_dt, datetime.datetime))
+        # the datetime is set with UTC timezone info
+        self.assertEqual(report.creation_dt.tzinfo, pytz.utc)
+        return
+
+class ReportGeneratorTest(unittest.TestCase):
+    def setUp(self):
+        grok.testing.grok('waeup.kofa.reports') # register utils
+
+    def test_iface(self):
+        # make sure we fullfill the promised contracts
+        obj = ReportGenerator()
+        verifyClass(IReportGenerator, ReportGenerator)
+        verifyObject(IReportGenerator, obj)
+        return
+
+
+class HelpersTests(unittest.TestCase):
+    # Tests for helper functions
+
+    def setUp(self):
+        grok.testing.grok('waeup.kofa.reports') # register utils
+        self.registered = [] # report utils registrations
+
+    def tearDown(self):
+        # unregister any previously registered report types
+        gsm = getGlobalSiteManager()
+        for report, name in self.registered:
+            gsm.unregisterUtility(report, IReportGenerator, name=name)
+        return
+
+    def register_generator(self, name, perm_create=None, perm_view=None):
+        # helper to register report generators as utils
+        gsm = getGlobalSiteManager()
+        generator = FakeReportGenerator(name)
+        gsm.registerUtility(generator, provided=IReportGenerator, name=name)
+        self.registered.append((generator, name),)
+        return generator
+
+    def test_get_generators_none(self):
+        # we get no generators if none was registered
+        result = list(get_generators())
+        self.assertEqual(result, [])
+        return
+
+    def test_get_generators_simple(self):
+        # we get a single generator if one is registered
+        self.register_generator('report1')
+        result = list(get_generators())
+        self.assertEqual(
+            result, [('report1', FakeReportGenerator('report1'))])
+        return
+
+    def test_get_generators_multiple(self):
+        # we also get multiple generators if available
+        self.register_generator('report1')
+        self.register_generator('report2')
+        result = list(get_generators())
+        self.assertEqual(
+            result,
+            [('report1', FakeReportGenerator('report1')),
+             ('report2', FakeReportGenerator('report2'))])
+        return
+
+class ReportJobTests(HelpersTests):
+    # Test asynchronous report functionality (simple cases)
+
+    def test_report_job_func(self):
+        # the report_job func really creates reports...
+        self.register_generator('report1')
+        report = report_job(None, 'report1')
+        self.assertTrue(IReport.providedBy(report))
+        self.assertTrue(isinstance(report, Report))
+
+    def test_report_job_interfaces(self):
+        # the AsyncReportJob implements promised interfaces correctly...
+        job = AsyncReportJob(None, None)
+        verifyClass(IJob, AsyncReportJob)
+        verifyObject(IJob, job)
+        verifyClass(IReportJob, AsyncReportJob)
+        verifyObject(IReportJob, job)
+        return
+
+
+class FakeJobWithResult(FakeJob):
+
+    def __init__(self):
+        #self.dir_path = tempfile.mkdtemp()
+        #self.result = os.path.join(self.dir_path, 'fake.csv')
+        #open(self.result, 'wb').write('a fake result')
+        self.result = Report()
+        return
+
+class ReportJobContainerTests(unittest.TestCase):
+    # Test ReportJobContainer
+
+    def setUp(self):
+        # register a suitable ICSVExporter as named utility
+        self.generator = FakeReportGenerator('report1')
+        self.job_manager = FakeJobManager()
+        self.gsm = getGlobalSiteManager()
+        self.gsm.registerUtility(
+            self.generator, IReportGenerator, name='report1')
+        self.gsm.registerUtility(
+            self.job_manager, IJobManager)
+
+    def tearDown(self):
+        self.gsm.unregisterUtility(
+            self.generator, IReportGenerator, name='report1')
+        self.gsm.unregisterUtility(self.job_manager, IJobManager)
+
+    def test_report_job_interfaces(self):
+        # the ExportJobContainer implements promised interfaces correctly...
+        container = ReportJobContainer()
+        verifyClass(IReportJobContainer, ReportJobContainer)
+        verifyObject(IReportJobContainer, container)
+        return
+
+    def test_start_report_job(self):
+        # we can start jobs
+        container = ReportJobContainer()
+        container.start_report_job('report3', 'bob')
+        result = self.job_manager._jobs.values()[0]
+        self.assertTrue(IJob.providedBy(result))
+        self.assertEqual(
+            container.running_report_jobs,
+            [('1', 'report3', 'bob')]
+            )
+        return
+
+    def test_get_running_report_jobs_all(self):
+        # we can get report jobs of all users
+        container = ReportJobContainer()
+        container.start_report_job('report3', 'bob')
+        container.start_report_job('report3', 'alice')
+        result = container.get_running_report_jobs()
+        self.assertEqual(
+            result,
+            [('1', 'report3', 'bob'),
+             ('2', 'report3', 'alice')]
+            )
+        return
+
+    def test_get_running_report_jobs_user(self):
+        # we can get the report jobs running for a certain user
+        container = ReportJobContainer()
+        container.start_report_job('report3', 'bob')
+        container.start_report_job('report3', 'alice')
+        result1 = container.get_running_report_jobs(user_id='alice')
+        result2 = container.get_running_report_jobs(user_id='foo')
+        self.assertEqual(
+            result1, [('2', 'report3', 'alice')])
+        self.assertEqual(
+            result2, [])
+        return
+
+    def test_get_running_report_jobs_only_if_exist(self):
+        # we get only jobs that are accessible through the job manager...
+        container = ReportJobContainer()
+        container.start_report_job('report3', 'bob')
+        container.start_report_job('report3', 'bob')
+        self.assertTrue(
+            ('2', 'report3', 'bob') in container.running_report_jobs)
+        # we remove the second entry from job manager
+        del self.job_manager._jobs['2']
+        result = container.get_running_report_jobs(user_id='bob')
+        self.assertEqual(
+            result, [('1', 'report3', 'bob')])
+        self.assertTrue(
+            ('2', 'report3', 'bob') not in container.running_report_jobs)
+        return
+
+    def test_get_report_job_status(self):
+        # we can get the stati of jobs...
+        container = ReportJobContainer()
+        container.start_report_job('report1', 'alice')
+        container.start_report_job('report1', 'bob')
+        container.start_report_job('report1', 'bob')
+        result = container.get_report_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'unnamed'),
+             ('completed', u'completed', u'unnamed')]
+            )
+        return
+
+    def test_delete_report_entry(self):
+        # we can remove report entries in local lists and the job
+        # manager as well...
+        container = ReportJobContainer()
+        container.start_report_job('report3', 'bob')
+        entry = container.running_report_jobs[0]
+        container.delete_report_entry(entry)
+        # both, running_report_jobs list and job manager are empty now
+        self.assertEqual(
+            container.running_report_jobs, [])
+        self.assertEqual(
+            self.job_manager._jobs, {})
+        return
+
+    def test_report_entry_from_job_id(self):
+        # we can get an report entry for a job_id if the id exists
+        container = ReportJobContainer()
+        entry = ('4', 'report3', 'bob')
+        container.running_report_jobs = [entry]
+        fake_job = FakeJobWithResult()
+        self.job_manager._jobs['4'] = fake_job
+        result1 = container.report_entry_from_job_id(None)
+        result2 = container.report_entry_from_job_id('4')
+        result3 = container.report_entry_from_job_id('23')
+        self.assertEqual(result1, None)
+        self.assertEqual(result2, ('4', 'report3', 'bob'))
+        self.assertEqual(result3, None)
+        #shutil.rmtree(fake_job.dir_path)
+        return
