diff --git a/docs/api/wuttjamaican.reports.rst b/docs/api/wuttjamaican.reports.rst new file mode 100644 index 0000000..0d79149 --- /dev/null +++ b/docs/api/wuttjamaican.reports.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.reports`` +======================== + +.. automodule:: wuttjamaican.reports + :members: diff --git a/docs/glossary.rst b/docs/glossary.rst index 3a16ea2..22f5ad5 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -247,6 +247,33 @@ Glossary portion of the :term:`app`. Similar to a "plugin" concept; see :doc:`narr/providers/index`. + report + The concept of a report is intentionally vague, in the context of + WuttJamaican. Basically it is something which can be "ran" + (usually with :term:`report params`) to generate a data set. The + output can be viewed in the app UI, or it can be saved to file. + + The base class is :class:`~wuttjamaican.reports.Report`. See + also :term:`report handler`. + + report handler + The :term:`handler` responsible for running :term:`reports + `, for display in app UI or saved to file etc. + + Base class is :class:`~wuttjamaican.reports.ReportHandler`. + + report key + Unique key which identifies a particular :term:`report`. + + report module + This refers to a Python module which contains :term:`report` + definitions. + + report params + This refers to the input parameters used when running a + :term:`report`. It is usually a simple mapping of key/value + pairs. + settings table Table in the :term:`app database` which is used to store :term:`config settings`. See also diff --git a/docs/index.rst b/docs/index.rst index ef8acce..baa26ef 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -87,5 +87,6 @@ Contents api/wuttjamaican.install api/wuttjamaican.people api/wuttjamaican.progress + api/wuttjamaican.reports api/wuttjamaican.testing api/wuttjamaican.util diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index c7e0b37..ca9d56a 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -88,6 +88,7 @@ class AppHandler: default_email_handler_spec = 'wuttjamaican.email:EmailHandler' default_install_handler_spec = 'wuttjamaican.install:InstallHandler' default_people_handler_spec = 'wuttjamaican.people:PeopleHandler' + default_report_handler_spec = 'wuttjamaican.reports:ReportHandler' def __init__(self, config): self.config = config @@ -879,6 +880,19 @@ class AppHandler: self.handlers['people'] = factory(self.config, **kwargs) return self.handlers['people'] + def get_report_handler(self, **kwargs): + """ + Get the configured :term:`report handler`. + + :rtype: :class:`~wuttjamaican.reports.ReportHandler` + """ + if 'reports' not in self.handlers: + spec = self.config.get(f'{self.appname}.reports.handler_spec', + default=self.default_report_handler_spec) + factory = self.load_object(spec) + self.handlers['reports'] = factory(self.config, **kwargs) + return self.handlers['reports'] + ############################## # convenience delegators ############################## diff --git a/src/wuttjamaican/reports.py b/src/wuttjamaican/reports.py new file mode 100644 index 0000000..6d5cb88 --- /dev/null +++ b/src/wuttjamaican/reports.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttJamaican -- Base package for Wutta Framework +# Copyright © 2023-2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework 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 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework 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 +# Wutta Framework. If not, see . +# +################################################################################ +""" +Report Utilities +""" + +import importlib + +from wuttjamaican.app import GenericHandler + + +class Report: + """ + Base class for all :term:`reports `. + + .. attribute:: report_key + + Each report must define a unique key, to identify it. + + .. attribute:: report_title + + This is the common display title for the report. + """ + report_title = "Untitled Report" + + def __init__(self, config): + self.config = config + self.app = config.get_app() + + def add_params(self, schema): + """ + Add field nodes to the given schema, defining all + :term:`report params`. + + :param schema: :class:`~colander:colander.Schema` instance. + + The schema is from Colander so nodes must be compatible with + that; for instance:: + + import colander + + def add_params(self, schema): + + schema.add(colander.SchemaNode( + colander.Date(), + name='start_date')) + + schema.add(colander.SchemaNode( + colander.Date(), + name='end_date')) + """ + + def get_output_columns(self): + """ + This should return a list of column definitions to be used + when displaying or persisting the data output. + + Each entry can be a simple column name, or else a dict with + other options, e.g.:: + + def get_output_columns(self): + return [ + 'foo', + {'name': 'bar', + 'label': "BAR"}, + {'name': 'sales', + 'label': "Total Sales", + 'numeric': True, + 'formatter': self.app.render_currency}, + ] + + :returns: List of column definitions as described above. + + The last entry shown above has all options currently + supported; here we explain those: + + * ``name`` - True name for the column. + + * ``label`` - Display label for the column. If not specified, + one is derived from the ``name``. + + * ``numeric`` - Boolean indicating the column data is numeric, + so should be right-aligned. + + * ``formatter`` - Custom formatter / value rendering callable + for the column. If set, this will be called with just one + arg (the value) for each data row. + """ + raise NotImplementedError + + def make_data(self, params, progress=None): + """ + This must "run" the report and return the final data. + + Note that this should *not* (usually) write the data to file, + its purpose is just to obtain the data. + + The return value should usually be a dict, with no particular + structure required beyond that. However it also can be a list + of data rows. + + There is no default logic here; subclass must define. + + :param params: Dict of :term:`report params`. + + :param progress: Optional progress indicator factory. + + :returns: Data dict, or list of rows. + """ + raise NotImplementedError + + +class ReportHandler(GenericHandler): + """ + Base class and default implementation for the :term:`report + handler`. + """ + + def get_report_modules(self): + """ + Returns a list of all known :term:`report modules `. + + This will discover all report modules exposed by the + :term:`app`, and/or its :term:`providers `. + """ + if not hasattr(self, '_report_modules'): + self._report_modules = [] + for provider in self.app.providers.values(): + if hasattr(provider, 'report_modules'): + modules = provider.report_modules + if modules: + if isinstance(modules, str): + modules = [modules] + for module in modules: + module = importlib.import_module(module) + self._report_modules.append(module) + + return self._report_modules + + def get_reports(self): + """ + Returns a dict of all known :term:`reports `, keyed by + :term:`report key`. + + This calls :meth:`get_report_modules()` and for each module, + it discovers all the reports it contains. + """ + if not hasattr(self, '_reports'): + self._reports = {} + for module in self.get_report_modules(): + for name in dir(module): + obj = getattr(module, name) + if (isinstance(obj, type) + and obj is not Report + and issubclass(obj, Report)): + self._reports[obj.report_key] = obj + + return self._reports + + def get_report(self, key, instance=True): + """ + Fetch the :term:`report` class or instance for given key. + + :param key: Identifying :term:`report key`. + + :param instance: Whether to return the class, or an instance. + Default is ``True`` which means return the instance. + + :returns: :class:`Report` class or instance, or ``None`` if + the report could not be found. + """ + reports = self.get_reports() + if key in reports: + report = reports[key] + if instance: + report = report(self.config) + return report + + def make_report_data(self, report, params=None, progress=None, **kwargs): + """ + Run the given report and return the output data. + + This calls :meth:`Report.make_data()` on the report, and + tweaks the output as needed for consistency. The return value + should resemble this structure:: + + { + 'output_title': "My Report", + 'data': ..., + } + + However that is the *minimum*; the dict may have other keys as + well. + + :param report: :class:`Report` instance to run. + + :param params: Dict of :term:`report params`. + + :param progress: Optional progress indicator factory. + + :returns: Data dict with structure shown above. + """ + data = report.make_data(params or {}, progress=progress, **kwargs) + if not isinstance(data, dict): + data = {'data': data} + data.setdefault('output_title', report.report_title) + return data diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..1bbf4c1 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +# nb. some tests require this to be a true package diff --git a/tests/test_app.py b/tests/test_app.py index 7c0f963..5230134 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -557,6 +557,12 @@ app_title = WuttaTest people = self.app.get_people_handler() self.assertIsInstance(people, PeopleHandler) + def test_get_report_handler(self): + from wuttjamaican.reports import ReportHandler + + handler = self.app.get_report_handler() + self.assertIsInstance(handler, ReportHandler) + def test_send_email(self): from wuttjamaican.email import EmailHandler diff --git a/tests/test_email.py b/tests/test_email.py index d21fa32..1776723 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -151,7 +151,7 @@ class TestEmailHandler(ConfigTestCase): self.assertEqual(len(modules), 1) self.assertIs(modules[0], mod) - # provider may specify modules as list + # provider may specify modules as string providers = { 'wuttatest': MagicMock(email_modules='wuttjamaican.email'), } diff --git a/tests/test_reports.py b/tests/test_reports.py new file mode 100644 index 0000000..7b19385 --- /dev/null +++ b/tests/test_reports.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch, MagicMock + +from wuttjamaican import reports as mod +from wuttjamaican.testing import ConfigTestCase + + +class MockFooReport(mod.Report): + report_key = 'mock_foo' + report_title = "MOCK Report" + + def make_data(self, params, **kwargs): + return [ + {'foo': 'bar'}, + ] + + +class TestReport(ConfigTestCase): + + def test_get_output_columns(self): + report = mod.Report(self.config) + self.assertRaises(NotImplementedError, report.get_output_columns) + + def test_make_data(self): + report = mod.Report(self.config) + self.assertRaises(NotImplementedError, report.make_data, {}) + + +class TestReportHandler(ConfigTestCase): + + def make_handler(self): + return mod.ReportHandler(self.config) + + def test_get_report_modules(self): + + # no providers, no report modules + with patch.object(self.app, 'providers', new={}): + handler = self.make_handler() + self.assertEqual(handler.get_report_modules(), []) + + # provider may specify modules as list + providers = { + 'wuttatest': MagicMock(report_modules=['wuttjamaican.reports']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + modules = handler.get_report_modules() + self.assertEqual(len(modules), 1) + self.assertIs(modules[0], mod) + + # provider may specify modules as string + providers = { + 'wuttatest': MagicMock(report_modules='wuttjamaican.reports'), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + modules = handler.get_report_modules() + self.assertEqual(len(modules), 1) + self.assertIs(modules[0], mod) + + def test_get_reports(self): + + # no providers, no reports + with patch.object(self.app, 'providers', new={}): + handler = self.make_handler() + self.assertEqual(handler.get_reports(), {}) + + # provider may define reports (via modules) + providers = { + 'wuttatest': MagicMock(report_modules=['tests.test_reports']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + reports = handler.get_reports() + self.assertEqual(len(reports), 1) + self.assertIn('mock_foo', reports) + + def test_get_report(self): + providers = { + 'wuttatest': MagicMock(report_modules=['tests.test_reports']), + } + + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + + # as instance + report = handler.get_report('mock_foo') + self.assertIsInstance(report, mod.Report) + self.assertIsInstance(report, MockFooReport) + + # as class + report = handler.get_report('mock_foo', instance=False) + self.assertTrue(issubclass(report, mod.Report)) + self.assertIs(report, MockFooReport) + + def test_make_report_data(self): + providers = { + 'wuttatest': MagicMock(report_modules=['tests.test_reports']), + } + + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + report = handler.get_report('mock_foo') + + data = handler.make_report_data(report) + self.assertEqual(len(data), 2) + self.assertIn('output_title', data) + self.assertEqual(data['output_title'], "MOCK Report") + self.assertIn('data', data) + self.assertEqual(data['data'], [{'foo': 'bar'}])