source: main/waeup.kofa/trunk/src/waeup/kofa/reports.py @ 9641

Last change on this file since 9641 was 9641, checked in by Henrik Bettermann, 13 years ago

Rework pagetemplates.

File size: 17.8 KB
RevLine 
[9344]1## $Id$
2##
3## Copyright (C) 2012 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
18"""Components for report generation.
19"""
20import grok
21import zc.async.interfaces
22from persistent.list import PersistentList
[9510]23from zope import schema
24from zope.component import getUtility, getUtilitiesFor, queryUtility
[9344]25from zope.component.hooks import setSite
26from zope.interface import implementer
27from zope.interface import Interface, Attribute
28from waeup.kofa.async import AsyncJob
29from waeup.kofa.interfaces import (
[9510]30    IJobManager, JOB_STATUS_MAP, IKofaPluggable, IKofaObject)
31from waeup.kofa.interfaces import MessageFactory as _
[9344]32from waeup.kofa.utils.helpers import now
33
[9639]34#: A status map that reflects the really interesting types of status
35#: for reports.
36#:
37#: For reports we want to know whether a job was finished
38#: and/or whether it failed. All the other possible states ('new',
39#: etc.) are not really interesting in that regard.
40STATUS_MAP = {
41    'unknown': _('unknown'),
42    'running': _('running'),
43    'finished': _('finished'),
44    'failed': _('FAILED'),
45    }
46
[9344]47class IReport(Interface):
48    """A report.
49    """
[9510]50    args = Attribute("""The args passed to constructor""")
51
52    kwargs = Attribute("""The keywords passed to constructor""")
53
[9344]54    creation_dt = Attribute(
55        """Datetime when a report was created. The datetime should """
56        """reflect the point of time when the data was fetched, """
57        """not when any output was created.""")
58
[9633]59    title = schema.TextLine(
60        title = u"A human readable short description for a report.",
61        default = u'Untitled',
62        )
63
64    description = schema.Text(
65        title = u"A human readable text describing a report.",
66        default = u'No description'
67        )
68
[9344]69    def create_pdf():
70        """Generate a PDF copy.
71        """
72
[9510]73class IReportGenerator(IKofaObject):
[9344]74    """A report generator.
75    """
[9510]76    title = Attribute("""Human readable description of report type.""")
77    def generate(site, args=[], kw={}):
[9344]78        """Generate a report.
[9510]79
80        `args` and `kw` are the parameters needed to create a specific
81        report (if any).
[9344]82        """
83
84class IReportJob(zc.async.interfaces.IJob):
85
[9510]86    finished = schema.Bool(
87        title = u'`True` if the job finished.`',
88        default = False,
89        )
90
91    failed = schema.Bool(
[9641]92        title = u"`True` if the job finished and didn't provide a report.",
[9510]93        default = None,
94        )
95
96    description = schema.TextLine(
97        title = u"""Textual representation of arguments and keywords.""",
98        default = u"",
99        )
100
[9639]101    report_status = schema.TextLine(
102        title = u"""Translated status string.""",
103        default = STATUS_MAP['unknown'],
104        )
105
[9510]106    def __init__(site, generator_name):
107        """Create a report job via generator."""
108
[9344]109class IReportJobContainer(Interface):
110    """A component that contains (maybe virtually) report jobs.
111    """
112    def start_report_job(report_generator_name, user_id, args=[], kw={}):
113        """Start asynchronous report job.
114
115        `report_generator_name`
116            is the name of a report generator utility to be used.
117
118        `user_id`
119            is the ID of the user that triggers the report generation.
120
121        `args` and `kw`
122            args and keywords passed to the generators `generate()`
123            method.
124
125        The job_id is stored along with exporter name and user id in a
126        persistent list.
127
128        Returns the job ID of the job started.
129        """
130
131    def get_running_report_jobs(user_id=None):
132        """Get report jobs for user with `user_id` as list of tuples.
133
134        Each tuples holds ``<job_id>, <generator_name>, <user_id>`` in
135        that order. The ``<generator_name>`` is the utility name of the
136        used report generator.
137
138        If `user_id` is ``None``, all running report jobs are returned.
139        """
140
141    def get_report_jobs_status(user_id=None):
142        """Get running/completed report jobs for `user_id` as list of tuples.
143
144        Each tuple holds ``<raw status>, <status translated>,
145        <generator title>`` in that order, where ``<status
146        translated>`` and ``<generator title>`` are translated strings
147        representing the status of the job and the human readable
148        title of the report generator used.
149        """
150
[9633]151    def get_report_jobs_description(user_id=None):
[9638]152        """Get running/completed report jobs fur `user_id` as list of tuples.
[9633]153
[9638]154        The results contain enough information to render a status page
155        or similar. Each tuple holds::
156
157          (``<job_id>, <description>, <status_translated>,
158          <discardable>, <downloadable>``)
159
160        in that order, with
161
162        ``<job_id>``
163            The job id of the represented job. A string.
164
165        ``<description>``
166            A human readable description of the report run.
167
168        ``<status_translated>``
169            The status of report jobs' status (translated)
170
171        ``<discardable>``
172            Boolean indicating whether the job can be discarded.
173            Only completed jobs can be discarded.
174
175        ``<downloadable>``
176            Boolean indicating whether the job result can be
177            downloaded. This is only true if the job finished and
178            didn't raised exceptions.
179
180        If ``user_id`` is ``None``, all jobs are returned.
[9633]181        """
182
[9344]183    def delete_report_entry(entry):
184        """Delete the report job denoted by `entry`.
185
186        Removes `entry` from the local `running_report_jobs` list and
187        also removes the regarding job via the local job manager.
188
189        `entry` is a tuple ``(<job id>, <generator name>, <user id>)``
190        as created by :meth:`start_report_job` or returned by
191        :meth:`get_running_report_jobs`.
192        """
193
194    def report_entry_from_job_id(job_id):
195        """Get entry tuple for `job_id`.
196
197        Returns ``None`` if no such entry can be found.
198        """
199
[9510]200class IReportsContainer(grok.interfaces.IContainer, IReportJobContainer,
201                        IKofaObject):
202    """A grok container that holds report jobs.
203    """
204
205class manageReportsPermission(grok.Permission):
206    """A permission to manage reports.
207    """
208    grok.name('waeup.manageReports')
209
[9344]210def get_generators():
211    """Get available report generators.
212
213    Returns an iterator of tuples ``<NAME, GENERATOR>`` with ``NAME``
214    being the name under which the respective generator was
215    registered.
216    """
217    for name, util in getUtilitiesFor(IReportGenerator):
218        yield name, util
219    pass
220
221@implementer(IReport)
222class Report(object):
223    creation_dt = None
224
[9633]225    @property
226    def title(self):
227        return _(u'A report')
228
229    @property
230    def description(self):
231        return _(u'A dummy report')
232
[9510]233    def __init__(self, args=[], kwargs={}):
234        self.args = args
235        self.kwargs = kwargs
[9344]236        self.creation_dt = now()
237
238    def create_pdf(self):
[9510]239        raise NotImplementedError()
[9344]240
[9633]241    def __repr__(self):
242        return 'Report(args=%r, kwargs=%r)' % (self.args, self.kwargs)
243
244
[9344]245@implementer(IReportGenerator)
246class ReportGenerator(object):
[9510]247
248    title = _("Unnamed Report")
249    def generate(self, site, args=[], kw={}):
[9344]250        result = Report()
251        return result
252
253def report_job(site, generator_name, args=[], kw={}):
254    """Get a generator and perform report creation.
255
256    `site`
257        is the site for which the report should be created.
258    `generator_name`
259        the global utility name under which the desired generator is
260        registered.
261    `args` and `kw`
262        Arguments and keywords to be passed to the `generate()` method of
263        the desired generator. While `args` should be a list, `kw` should
264        be a dictionary.
265    """
266    setSite(site)
267    generator = getUtility(IReportGenerator, name=generator_name)
268    report = generator.generate(site, *args, **kw)
269    return report
270
271@implementer(IReportJob)
272class AsyncReportJob(AsyncJob):
273    """An IJob that creates reports.
274
275    `AsyncReportJob` instances are regular `AsyncJob` instances with a
276    different constructor API. Instead of a callable to execute, you
277    must pass a `site`, some `generator_name`, and additional args and
278    keywords to create a report.
279
280    The real work is done when an instance of this class is put into a
281    queue. See :mod:`waeup.kofa.async` to learn more about
282    asynchronous jobs.
283
284    The `generator_name` must be the name under which an IReportGenerator
285    utility was registered with the ZCA.
286
287    The `site` must be a valid site  or ``None``.
288
289    The result of an `AsyncReportJob` is an IReport object.
290    """
291    def __init__(self, site, generator_name, args=[], kw={}):
[9510]292        self._generator_name = generator_name
[9344]293        super(AsyncReportJob, self).__init__(
[9510]294            report_job, site, generator_name, args=args, kw=kw)
[9344]295
[9510]296    @property
297    def finished(self):
298        """A job is marked `finished` if it is completed.
299
300        Please note: a finished report job does not neccessarily
301        provide an IReport result. See meth:`failed`.
302        """
303        return self.status == zc.async.interfaces.COMPLETED
304
305    @property
306    def failed(self):
307        """A report job is marked failed iff it is finished and the
308        result does not provide IReport.
309
310        While a job is unfinished, the `failed` status is ``None``.
311
312        Failed jobs normally provide a `traceback` to examine reasons.
313        """
314        if not self.finished:
315            return None
[9638]316        if not IReport.providedBy(getattr(self, 'result', None)):
[9510]317            return True
318        return False
319
320    @property
[9639]321    def report_status(self):
322        """The status of a report as translated string.
323        """
324        if not self.finished:
325            return STATUS_MAP['running']
326        if self.failed:
327            return STATUS_MAP['failed']
328        return STATUS_MAP['finished']
329
330    @property
[9510]331    def description(self):
332        """A description gives a representation of the report to generate.
333
334        The description contains the name of the report generator
335        (trying to fetch the `name` attribute of the requested report
336        generator) and the arguments and keywords passed in.
337
338        Please note that this method is expensive!
339        """
340        args = self.kwargs.get('args', [])
341        kw = self.kwargs.get('kw', dict())
342        args = ', '.join(["%r" % x for x in args])
343        kw = ', '.join(['%s=%r' % (key, val) for key, val in kw.items()])
344        if len(args) and len(kw):
345            str_repr = args + ', ' + kw
346        else:
347            str_repr = args + kw
348        str_repr = '(' + str_repr + ')'
349        try:
[9633]350            generator = getUtility(
[9510]351                IReportGenerator, name=self._generator_name)
[9633]352            name = generator.title
[9510]353        except:
[9633]354            name = _('Unregistered Report Generator')
[9510]355        return name + ' ' + str_repr
356
[9344]357@implementer(IReportJobContainer)
358class ReportJobContainer(object):
359    """A mix-in that provides functionality for asynchronous report jobs.
360    """
361    running_report_jobs = PersistentList()
362
363    def start_report_job(self, generator_name, user_id, args=[], kw={}):
364        """Start asynchronous export job.
365
366        `generator_name`
367            is the name of a report generator utility to be used.
368
369        `user_id`
370            is the ID of the user that triggers the report generation.
371
372        `args` and `kw`
373            args and keywords passed to the generators `generate()`
374            method.
375
376        The job_id is stored along with exporter name and user id in a
377        persistent list.
378
379        Returns the job ID of the job started.
380        """
381        site = grok.getSite()
382        manager = getUtility(IJobManager)
383        job = AsyncReportJob(site, generator_name, args=args, kw=kw)
384        job_id = manager.put(job)
385        # Make sure that the persisted list is stored in ZODB
386        self.running_report_jobs = PersistentList(self.running_report_jobs)
[9510]387        self.running_report_jobs.append((job_id, generator_name, user_id),)
[9344]388        return job_id
389
390    def get_running_report_jobs(self, user_id=None):
391        """Get report jobs for user with `user_id` as list of tuples.
392
393        Each tuples holds ``<job_id>, <generator_name>, <user_id>`` in
394        that order. The ``<generator_name>`` is the utility name of the
395        used report generator.
396
397        If `user_id` is ``None``, all running report jobs are returned.
398        """
399        entries = []
400        to_delete = []
401        manager = getUtility(IJobManager)
402        for entry in self.running_report_jobs:
403            if user_id is not None and entry[2] != user_id:
404                continue
405            if manager.get(entry[0]) is None:
406                to_delete.append(entry)
407                continue
408            entries.append(entry)
409        if to_delete:
410            self.running_report_jobs = PersistentList(
411                [x for x in self.running_report_jobs if x not in to_delete])
412        return entries
413
414    def get_report_jobs_status(self, user_id=None):
415        """Get running/completed report jobs for `user_id` as list of tuples.
416
417        Each tuple holds ``<raw status>, <status translated>,
418        <generator title>`` in that order, where ``<status
419        translated>`` and ``<generator title>`` are translated strings
420        representing the status of the job and the human readable
421        title of the report generator used.
422        """
423        entries = self.get_running_report_jobs(user_id)
424        result = []
425        manager = getUtility(IJobManager)
426        for entry in entries:
427            job = manager.get(entry[0])
428            status, status_translated = JOB_STATUS_MAP[job.status]
429            generator = getUtility(IReportGenerator, name=entry[1])
430            generator_name = getattr(generator, 'title', 'unnamed')
431            result.append((status, status_translated, generator_name))
432        return result
433
[9633]434    def get_report_jobs_description(self, user_id=None):
[9638]435        """Get running/completed report jobs fur `user_id` as list of tuples.
[9633]436
[9638]437        The results contain enough information to render a status page
438        or similar. Each tuple holds::
439
440          (``<job_id>, <description>, <status_translated>,
441          <discardable>, <downloadable>``)
442
443        in that order, with
444
445        ``<job_id>``
446            The job id of the represented job. A string.
447
448        ``<description>``
449            A human readable description of the report run.
450
451        ``<status_translated>``
452            The status of report jobs' status (translated)
453
454        ``<discardable>``
455            Boolean indicating whether the job can be discarded.
456            Only completed jobs can be discarded.
457
458        ``<downloadable>``
459            Boolean indicating whether the job result can be
460            downloaded. This is only true if the job finished and
461            didn't raised exceptions.
462
463        If ``user_id`` is ``None``, all jobs are returned.
[9633]464        """
465        entries = self.get_running_report_jobs(user_id)
466        result = []
467        for (job_id, gen_name, user_name) in entries:
468            manager = getUtility(IJobManager)
469            job = manager.get(job_id)
[9639]470            status = job.report_status
[9638]471            discardable = job.finished
472            downloadable = job.finished and not job.failed
[9633]473            if not hasattr(job, 'description'):
474                continue
[9638]475            result.append((job_id, job.description, status,
476                           discardable, downloadable),)
[9633]477        return result
478
[9344]479    def delete_report_entry(self, entry):
480        """Delete the report job denoted by `entry`.
481
482        Removes `entry` from the local `running_report_jobs` list and
483        also removes the regarding job via the local job manager.
484
485        `entry` is a tuple ``(<job id>, <generator name>, <user id>)``
486        as created by :meth:`start_report_job` or returned by
487        :meth:`get_running_report_jobs`.
488        """
489        manager = getUtility(IJobManager)
490        manager.remove(entry[0], self)
491        new_entries = [x for x in self.running_report_jobs
492                       if x != entry]
493        self.running_report_jobs = PersistentList(new_entries)
494        return
495
496    def report_entry_from_job_id(self, job_id):
497        """Get entry tuple for `job_id`.
498
499        Returns ``None`` if no such entry can be found.
500        """
501        for entry in self.running_report_jobs:
502            if entry[0] == job_id:
503                return entry
504        return None
[9510]505
506@implementer(IReportsContainer)
507class ReportsContainer(grok.Container, ReportJobContainer):
508    """A container for reports.
509    """
510
511@implementer(IKofaPluggable)
512class ReportsContainerPlugin(grok.GlobalUtility):
513    """A plugin that updates sites to contain a reports container.
514    """
515
516    grok.name('reports')
517
518    deprecated_attributes = []
519
520    def setup(self, site, name, logger):
521        """Add a reports container for `site`.
522
523        If there is such an object already, we install a fresh one.
524        """
525        if site.get('reports', None) is not None:
526            del site['reports']
527            logger.info('Removed reports container for site "%s"' % name)
528        return self.update(site, name, logger)
529
530    def update(self, site, name, logger):
531        """Install a reports container in `site`.
532
533        If one exists already, do nothing.
534        """
535        if site.get('reports', None) is not None:
536            return
537        site['reports'] = ReportsContainer()
538        logger.info('Added reports container for site "%s"' % name)
539        return
Note: See TracBrowser for help on using the repository browser.