3
0
Fork 0

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:
Lance Edgar 2025-01-11 19:04:30 -06:00
parent 1bfab90d35
commit 20d4d4d93f
9 changed files with 395 additions and 1 deletions

View file

@ -0,0 +1,6 @@
``wuttjamaican.reports``
========================
.. automodule:: wuttjamaican.reports
:members:

View file

@ -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

View file

@ -87,5 +87,6 @@ Contents
api/wuttjamaican.install
api/wuttjamaican.people
api/wuttjamaican.progress
api/wuttjamaican.reports
api/wuttjamaican.testing
api/wuttjamaican.util

View file

@ -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
View 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

View file

@ -0,0 +1 @@
# nb. some tests require this to be a true package

View file

@ -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

View file

@ -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
View 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'}])