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

Last change on this file since 9638 was 9638, checked in by uli, 12 years ago

ReportContainers? should provide more usable infos about jobs.

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