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

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

Update docs to reflect actual data produced. Do we really need a
starttime value?

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