feat: add basic support for "reports" feature
not much here yet, but trying to keep it lean and unopinionated since implementations will probably vary a bit
This commit is contained in:
parent
1bfab90d35
commit
20d4d4d93f
6
docs/api/wuttjamaican.reports.rst
Normal file
6
docs/api/wuttjamaican.reports.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``wuttjamaican.reports``
|
||||||
|
========================
|
||||||
|
|
||||||
|
.. automodule:: wuttjamaican.reports
|
||||||
|
:members:
|
|
@ -247,6 +247,33 @@ Glossary
|
||||||
portion of the :term:`app`. Similar to a "plugin" concept; see
|
portion of the :term:`app`. Similar to a "plugin" concept; see
|
||||||
:doc:`narr/providers/index`.
|
: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
|
||||||
|
<report>`, 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
|
settings table
|
||||||
Table in the :term:`app database` which is used to store
|
Table in the :term:`app database` which is used to store
|
||||||
:term:`config settings<config setting>`. See also
|
:term:`config settings<config setting>`. See also
|
||||||
|
|
|
@ -87,5 +87,6 @@ Contents
|
||||||
api/wuttjamaican.install
|
api/wuttjamaican.install
|
||||||
api/wuttjamaican.people
|
api/wuttjamaican.people
|
||||||
api/wuttjamaican.progress
|
api/wuttjamaican.progress
|
||||||
|
api/wuttjamaican.reports
|
||||||
api/wuttjamaican.testing
|
api/wuttjamaican.testing
|
||||||
api/wuttjamaican.util
|
api/wuttjamaican.util
|
||||||
|
|
|
@ -88,6 +88,7 @@ class AppHandler:
|
||||||
default_email_handler_spec = 'wuttjamaican.email:EmailHandler'
|
default_email_handler_spec = 'wuttjamaican.email:EmailHandler'
|
||||||
default_install_handler_spec = 'wuttjamaican.install:InstallHandler'
|
default_install_handler_spec = 'wuttjamaican.install:InstallHandler'
|
||||||
default_people_handler_spec = 'wuttjamaican.people:PeopleHandler'
|
default_people_handler_spec = 'wuttjamaican.people:PeopleHandler'
|
||||||
|
default_report_handler_spec = 'wuttjamaican.reports:ReportHandler'
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self.config = config
|
self.config = config
|
||||||
|
@ -879,6 +880,19 @@ class AppHandler:
|
||||||
self.handlers['people'] = factory(self.config, **kwargs)
|
self.handlers['people'] = factory(self.config, **kwargs)
|
||||||
return self.handlers['people']
|
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
|
# convenience delegators
|
||||||
##############################
|
##############################
|
||||||
|
|
228
src/wuttjamaican/reports.py
Normal file
228
src/wuttjamaican/reports.py
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Report Utilities
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from wuttjamaican.app import GenericHandler
|
||||||
|
|
||||||
|
|
||||||
|
class Report:
|
||||||
|
"""
|
||||||
|
Base class for all :term:`reports <report>`.
|
||||||
|
|
||||||
|
.. 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 <report
|
||||||
|
module>`.
|
||||||
|
|
||||||
|
This will discover all report modules exposed by the
|
||||||
|
:term:`app`, and/or its :term:`providers <provider>`.
|
||||||
|
"""
|
||||||
|
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 <report>`, 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
|
|
@ -0,0 +1 @@
|
||||||
|
# nb. some tests require this to be a true package
|
|
@ -557,6 +557,12 @@ app_title = WuttaTest
|
||||||
people = self.app.get_people_handler()
|
people = self.app.get_people_handler()
|
||||||
self.assertIsInstance(people, PeopleHandler)
|
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):
|
def test_send_email(self):
|
||||||
from wuttjamaican.email import EmailHandler
|
from wuttjamaican.email import EmailHandler
|
||||||
|
|
||||||
|
|
|
@ -151,7 +151,7 @@ class TestEmailHandler(ConfigTestCase):
|
||||||
self.assertEqual(len(modules), 1)
|
self.assertEqual(len(modules), 1)
|
||||||
self.assertIs(modules[0], mod)
|
self.assertIs(modules[0], mod)
|
||||||
|
|
||||||
# provider may specify modules as list
|
# provider may specify modules as string
|
||||||
providers = {
|
providers = {
|
||||||
'wuttatest': MagicMock(email_modules='wuttjamaican.email'),
|
'wuttatest': MagicMock(email_modules='wuttjamaican.email'),
|
||||||
}
|
}
|
||||||
|
|
111
tests/test_reports.py
Normal file
111
tests/test_reports.py
Normal file
|
@ -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'}])
|
Loading…
Reference in a new issue