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

Last change on this file since 9649 was 9647, checked in by Henrik Bettermann, 12 years ago

Generate reports for students in a certain session and for groups of study modes.

File size: 18.2 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 for `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>``)
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        If ``user_id`` is ``None``, all jobs are returned.
182        """
183
184    def delete_report_entry(entry):
185        """Delete the report job denoted by `entry`.
186
187        Removes `entry` from the local `running_report_jobs` list and
188        also removes the regarding job via the local job manager.
189
190        `entry` is a tuple ``(<job id>, <generator name>, <user id>)``
191        as created by :meth:`start_report_job` or returned by
192        :meth:`get_running_report_jobs`.
193        """
194
195    def report_entry_from_job_id(job_id):
196        """Get entry tuple for `job_id`.
197
198        Returns ``None`` if no such entry can be found.
199        """
200
201class IReportsContainer(grok.interfaces.IContainer, IReportJobContainer,
202                        IKofaObject):
203    """A grok container that holds report jobs.
204    """
205
206class manageReportsPermission(grok.Permission):
207    """A permission to manage reports.
208    """
209    grok.name('waeup.manageReports')
210
211def get_generators():
212    """Get available report generators.
213
214    Returns an iterator of tuples ``<NAME, GENERATOR>`` with ``NAME``
215    being the name under which the respective generator was
216    registered.
217    """
218    for name, util in getUtilitiesFor(IReportGenerator):
219        yield name, util
220    pass
221
222@implementer(IReport)
223class Report(object):
224    creation_dt = None
225
226    @property
227    def title(self):
228        return _(u'A report')
229
230    @property
231    def description(self):
232        return _(u'A dummy report')
233
234    def __init__(self, args=[], kwargs={}):
235        self.args = args
236        self.kwargs = kwargs
237        self.creation_dt = now()
238
239    def create_pdf(self):
240        raise NotImplementedError()
241
242    def __repr__(self):
243        return 'Report(args=%r, kwargs=%r)' % (self.args, self.kwargs)
244
245
246@implementer(IReportGenerator)
247class ReportGenerator(object):
248
249    title = _("Unnamed Report")
250    def generate(self, site, args=[], kw={}):
251        result = Report()
252        return result
253
254def report_job(site, generator_name, args=[], kw={}):
255    """Get a generator and perform report creation.
256
257    `site`
258        is the site for which the report should be created.
259    `generator_name`
260        the global utility name under which the desired generator is
261        registered.
262    `args` and `kw`
263        Arguments and keywords to be passed to the `generate()` method of
264        the desired generator. While `args` should be a list, `kw` should
265        be a dictionary.
266    """
267    setSite(site)
268    generator = getUtility(IReportGenerator, name=generator_name)
269    report = generator.generate(site, *args, **kw)
270    return report
271
272@implementer(IReportJob)
273class AsyncReportJob(AsyncJob):
274    """An IJob that creates reports.
275
276    `AsyncReportJob` instances are regular `AsyncJob` instances with a
277    different constructor API. Instead of a callable to execute, you
278    must pass a `site`, some `generator_name`, and additional args and
279    keywords to create a report.
280
281    The real work is done when an instance of this class is put into a
282    queue. See :mod:`waeup.kofa.async` to learn more about
283    asynchronous jobs.
284
285    The `generator_name` must be the name under which an IReportGenerator
286    utility was registered with the ZCA.
287
288    The `site` must be a valid site  or ``None``.
289
290    The result of an `AsyncReportJob` is an IReport object.
291    """
292    def __init__(self, site, generator_name, args=[], kw={}):
293        self._generator_name = generator_name
294        super(AsyncReportJob, self).__init__(
295            report_job, site, generator_name, args=args, kw=kw)
296
297    @property
298    def finished(self):
299        """A job is marked `finished` if it is completed.
300
301        Please note: a finished report job does not neccessarily
302        provide an IReport result. See meth:`failed`.
303        """
304        return self.status == zc.async.interfaces.COMPLETED
305
306    @property
307    def failed(self):
308        """A report job is marked failed iff it is finished and the
309        result does not provide IReport.
310
311        While a job is unfinished, the `failed` status is ``None``.
312
313        Failed jobs normally provide a `traceback` to examine reasons.
314        """
315        if not self.finished:
316            return None
317        if not IReport.providedBy(getattr(self, 'result', None)):
318            return True
319        return False
320
321    @property
322    def report_status(self):
323        """The status of a report as translated string.
324        """
325        if not self.finished:
326            return STATUS_MAP['running']
327        if self.failed:
328            return STATUS_MAP['failed']
329        return STATUS_MAP['finished']
330
331    @property
332    def description(self):
333        """A description gives a representation of the report to generate.
334
335        The description contains the name of the report generator
336        (trying to fetch the `name` attribute of the requested report
337        generator) and the arguments and keywords passed in.
338
339        Please note that this method is expensive!
340        """
341        args = self.kwargs.get('args', [])
342        kw = self.kwargs.get('kw', dict())
343        args = ', '.join(["%r" % x for x in args])
344        #kw = ', '.join(['%s = %r' % (key, val) for key, val in kw.items()])
345        kw = ', '.join(['%s' % val for val in kw.values()])
346        if len(args) and len(kw):
347            str_repr = args + ', ' + kw
348        else:
349            str_repr = args + kw
350        str_repr = '(' + str_repr + ')'
351        try:
352            generator = getUtility(
353                IReportGenerator, name=self._generator_name)
354            name = generator.title
355        except:
356            name = _('Unregistered Report Generator')
357        return name + ' ' + str_repr
358
359@implementer(IReportJobContainer)
360class ReportJobContainer(object):
361    """A mix-in that provides functionality for asynchronous report jobs.
362    """
363    running_report_jobs = PersistentList()
364
365    def start_report_job(self, generator_name, user_id, args=[], kw={}):
366        """Start asynchronous export job.
367
368        `generator_name`
369            is the name of a report generator utility to be used.
370
371        `user_id`
372            is the ID of the user that triggers the report generation.
373
374        `args` and `kw`
375            args and keywords passed to the generators `generate()`
376            method.
377
378        The job_id is stored along with exporter name and user id in a
379        persistent list.
380
381        Returns the job ID of the job started.
382        """
383        site = grok.getSite()
384        manager = getUtility(IJobManager)
385        job = AsyncReportJob(site, generator_name, args=args, kw=kw)
386        job_id = manager.put(job)
387        # Make sure that the persisted list is stored in ZODB
388        self.running_report_jobs = PersistentList(self.running_report_jobs)
389        self.running_report_jobs.append((job_id, generator_name, user_id),)
390        return job_id
391
392    def get_running_report_jobs(self, user_id=None):
393        """Get report jobs for user with `user_id` as list of tuples.
394
395        Each tuples holds ``<job_id>, <generator_name>, <user_id>`` in
396        that order. The ``<generator_name>`` is the utility name of the
397        used report generator.
398
399        If `user_id` is ``None``, all running report jobs are returned.
400        """
401        entries = []
402        to_delete = []
403        manager = getUtility(IJobManager)
404        for entry in self.running_report_jobs:
405            if user_id is not None and entry[2] != user_id:
406                continue
407            if manager.get(entry[0]) is None:
408                to_delete.append(entry)
409                continue
410            entries.append(entry)
411        if to_delete:
412            self.running_report_jobs = PersistentList(
413                [x for x in self.running_report_jobs if x not in to_delete])
414        return entries
415
416    def get_report_jobs_status(self, user_id=None):
417        """Get running/completed report jobs for `user_id` as list of tuples.
418
419        Each tuple holds ``<raw status>, <status translated>,
420        <generator title>`` in that order, where ``<status
421        translated>`` and ``<generator title>`` are translated strings
422        representing the status of the job and the human readable
423        title of the report generator used.
424        """
425        entries = self.get_running_report_jobs(user_id)
426        result = []
427        manager = getUtility(IJobManager)
428        for entry in entries:
429            job = manager.get(entry[0])
430            status, status_translated = JOB_STATUS_MAP[job.status]
431            generator = getUtility(IReportGenerator, name=entry[1])
432            generator_name = getattr(generator, 'title', 'unnamed')
433            result.append((status, status_translated, generator_name))
434        return result
435
436    def get_report_jobs_description(self, user_id=None):
437        """Get running/completed report jobs fur `user_id` as list of tuples.
438
439        The results contain enough information to render a status page
440        or similar. Each tuple holds::
441
442          (``<job_id>, <description>, <status_translated>,
443          <discardable>, <downloadable>``)
444
445        in that order, with
446
447        ``<job_id>``
448            The job id of the represented job. A string.
449
450        ``<description>``
451            A human readable description of the report run.
452
453        ``<status_translated>``
454            The status of report jobs' status (translated)
455
456        ``<discardable>``
457            Boolean indicating whether the job can be discarded.
458            Only completed jobs can be discarded.
459
460        ``<downloadable>``
461            Boolean indicating whether the job result can be
462            downloaded. This is only true if the job finished and
463            didn't raised exceptions.
464
465        ``<starttime>``
466            Datetime object indicating when the job was started.
467
468        If ``user_id`` is ``None``, all jobs are returned.
469        """
470        entries = self.get_running_report_jobs(user_id)
471        result = []
472        for (job_id, gen_name, user_name) in entries:
473            manager = getUtility(IJobManager)
474            job = manager.get(job_id)
475            status = job.report_status
476            discardable = job.finished
477            downloadable = job.finished and not job.failed
478            starttime = getattr(job, 'begin_after', None)
479            if starttime:
480                starttime = starttime.astimezone(getUtility(IKofaUtils).tzinfo)
481                starttime = starttime.strftime("%Y-%m-%d %H:%M:%S %Z")
482            if not hasattr(job, 'description'):
483                continue
484            result.append((job_id, job.description, status,
485                           discardable, downloadable, starttime, user_name),)
486        return result
487
488    def delete_report_entry(self, entry):
489        """Delete the report job denoted by `entry`.
490
491        Removes `entry` from the local `running_report_jobs` list and
492        also removes the regarding job via the local job manager.
493
494        `entry` is a tuple ``(<job id>, <generator name>, <user id>)``
495        as created by :meth:`start_report_job` or returned by
496        :meth:`get_running_report_jobs`.
497        """
498        manager = getUtility(IJobManager)
499        manager.remove(entry[0], self)
500        new_entries = [x for x in self.running_report_jobs
501                       if x != entry]
502        self.running_report_jobs = PersistentList(new_entries)
503        return
504
505    def report_entry_from_job_id(self, job_id):
506        """Get entry tuple for `job_id`.
507
508        Returns ``None`` if no such entry can be found.
509        """
510        for entry in self.running_report_jobs:
511            if entry[0] == job_id:
512                return entry
513        return None
514
515@implementer(IReportsContainer)
516class ReportsContainer(grok.Container, ReportJobContainer):
517    """A container for reports.
518    """
519
520@implementer(IKofaPluggable)
521class ReportsContainerPlugin(grok.GlobalUtility):
522    """A plugin that updates sites to contain a reports container.
523    """
524
525    grok.name('reports')
526
527    deprecated_attributes = []
528
529    def setup(self, site, name, logger):
530        """Add a reports container for `site`.
531
532        If there is such an object already, we install a fresh one.
533        """
534        if site.get('reports', None) is not None:
535            del site['reports']
536            logger.info('Removed reports container for site "%s"' % name)
537        return self.update(site, name, logger)
538
539    def update(self, site, name, logger):
540        """Install a reports container in `site`.
541
542        If one exists already, do nothing.
543        """
544        if site.get('reports', None) is not None:
545            return
546        site['reports'] = ReportsContainer()
547        logger.info('Added reports container for site "%s"' % name)
548        return
Note: See TracBrowser for help on using the repository browser.