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
|
||||
: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
|
||||
Table in the :term:`app database` which is used to store
|
||||
:term:`config settings<config setting>`. See also
|
||||
|
|
|
@ -87,5 +87,6 @@ Contents
|
|||
api/wuttjamaican.install
|
||||
api/wuttjamaican.people
|
||||
api/wuttjamaican.progress
|
||||
api/wuttjamaican.reports
|
||||
api/wuttjamaican.testing
|
||||
api/wuttjamaican.util
|
||||
|
|
|
@ -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
|
||||
##############################
|
||||
|
|
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()
|
||||
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
|
||||
|
||||
|
|
|
@ -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'),
|
||||
}
|
||||
|
|
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