Compare commits
3 commits
1bfab90d35
...
ce79346f76
Author | SHA1 | Date | |
---|---|---|---|
|
ce79346f76 | ||
|
a9d2f32e40 | ||
|
20d4d4d93f |
11
CHANGELOG.md
11
CHANGELOG.md
|
@ -5,6 +5,17 @@ All notable changes to WuttJamaican will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## v0.20.0 (2025-01-11)
|
||||
|
||||
### Feat
|
||||
|
||||
- add basic support for "reports" feature
|
||||
|
||||
### Fix
|
||||
|
||||
- add `render_percent()` method for app handler
|
||||
- set global default sender to root@localhost
|
||||
|
||||
## v0.19.3 (2025-01-09)
|
||||
|
||||
### Fix
|
||||
|
|
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
|
||||
|
|
|
@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "WuttJamaican"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
description = "Base package for Wutta Framework"
|
||||
readme = "README.md"
|
||||
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
|
||||
|
|
|
@ -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
|
||||
|
@ -771,6 +772,22 @@ class AppHandler:
|
|||
"""
|
||||
return simple_error(error)
|
||||
|
||||
def render_percent(self, value, decimals=2):
|
||||
"""
|
||||
Return a human-friendly display string for the given
|
||||
percentage value, e.g. ``23.45139`` becomes ``"23.45 %"``.
|
||||
|
||||
:param value: The value to be rendered.
|
||||
|
||||
:returns: Display string for the percentage value.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
fmt = f'{{:0.{decimals}f}} %'
|
||||
if value < 0:
|
||||
return f'({fmt.format(-value)})'
|
||||
return fmt.format(value)
|
||||
|
||||
def render_quantity(self, value, empty_zero=False):
|
||||
"""
|
||||
Return a human-friendly display string for the given quantity
|
||||
|
@ -879,6 +896,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
|
|
@ -491,6 +491,22 @@ app_title = WuttaTest
|
|||
result = self.app.render_error(error)
|
||||
self.assertEqual(result, "RuntimeError")
|
||||
|
||||
def test_render_percent(self):
|
||||
|
||||
# null
|
||||
self.assertEqual(self.app.render_percent(None), "")
|
||||
|
||||
# typical
|
||||
self.assertEqual(self.app.render_percent(12.3419), '12.34 %')
|
||||
|
||||
# more decimal places
|
||||
self.assertEqual(self.app.render_percent(12.3419, decimals=3), '12.342 %')
|
||||
self.assertEqual(self.app.render_percent(12.3419, decimals=4), '12.3419 %')
|
||||
|
||||
# negative
|
||||
self.assertEqual(self.app.render_percent(-12.3419), '(12.34 %)')
|
||||
self.assertEqual(self.app.render_percent(-12.3419, decimals=3), '(12.342 %)')
|
||||
|
||||
def test_render_quantity(self):
|
||||
|
||||
# null
|
||||
|
@ -557,6 +573,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