diff --git a/docs/api/wuttaweb.views.reports.rst b/docs/api/wuttaweb.views.reports.rst
new file mode 100644
index 0000000..52f6cb0
--- /dev/null
+++ b/docs/api/wuttaweb.views.reports.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.views.reports``
+==========================
+
+.. automodule:: wuttaweb.views.reports
+ :members:
diff --git a/docs/index.rst b/docs/index.rst
index cd1d227..c535410 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -60,6 +60,7 @@ the narrative docs are pretty scant. That will eventually change.
api/wuttaweb.views.master
api/wuttaweb.views.people
api/wuttaweb.views.progress
+ api/wuttaweb.views.reports
api/wuttaweb.views.roles
api/wuttaweb.views.settings
api/wuttaweb.views.upgrades
diff --git a/src/wuttaweb/templates/reports/view.mako b/src/wuttaweb/templates/reports/view.mako
new file mode 100644
index 0000000..44d6a52
--- /dev/null
+++ b/src/wuttaweb/templates/reports/view.mako
@@ -0,0 +1,61 @@
+## -*- coding: utf-8; mode: html; -*-
+<%inherit file="/master/view.mako" />
+
+<%def name="page_layout()">
+ ${parent.page_layout()}
+ % if report_data is not Undefined:
+
+
+
+
+
+ ${self.report_tools()}
+
+
+ ${self.report_output_body()}
+ % endif
+%def>
+
+<%def name="report_output_header()">
+
+%def>
+
+<%def name="report_tools()">%def>
+
+<%def name="report_output_body()">
+ ${self.report_output_table()}
+%def>
+
+<%def name="report_output_table()">
+
+ % for column in report_columns:
+
+
+
+ % endfor
+
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+ % if report_data is not Undefined:
+
+ % endif
+%def>
diff --git a/src/wuttaweb/views/reports.py b/src/wuttaweb/views/reports.py
new file mode 100644
index 0000000..357da41
--- /dev/null
+++ b/src/wuttaweb/views/reports.py
@@ -0,0 +1,266 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# wuttaweb -- Web App for Wutta Framework
+# Copyright © 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 Views
+"""
+
+import datetime
+import logging
+import os
+import tempfile
+
+import deform
+
+from wuttaweb.views import MasterView
+
+
+log = logging.getLogger(__name__)
+
+
+class ReportView(MasterView):
+ """
+ Master view for :term:`reports `; route prefix is
+ ``reports``.
+
+ Notable URLs provided by this class:
+
+ * ``/reports/``
+ * ``/reports/XXX``
+ """
+ model_title = "Report"
+ model_key = 'report_key'
+ filterable = False
+ sort_on_backend = False
+ creatable = False
+ editable = False
+ deletable = False
+ route_prefix = 'reports'
+ template_prefix = '/reports'
+
+ grid_columns = [
+ 'report_title',
+ 'help_text',
+ 'report_key',
+ ]
+
+ form_fields = [
+ 'help_text',
+ ]
+
+ def __init__(self, request, context=None):
+ super().__init__(request, context=context)
+ self.report_handler = self.app.get_report_handler()
+
+ def get_grid_data(self, columns=None, session=None):
+ """ """
+ data = []
+ for report in self.report_handler.get_reports().values():
+ data.append(self.normalize_report(report))
+ return data
+
+ def normalize_report(self, report):
+ """ """
+ return {
+ 'report_key': report.report_key,
+ 'report_title': report.report_title,
+ 'help_text': report.__doc__,
+ }
+
+ def configure_grid(self, g):
+ """ """
+ super().configure_grid(g)
+
+ # report_key
+ g.set_link('report_key')
+
+ # report_title
+ g.set_link('report_title')
+ g.set_searchable('report_title')
+
+ # help_text
+ g.set_searchable('help_text')
+
+ def get_instance(self):
+ """ """
+ key = self.request.matchdict['report_key']
+ report = self.report_handler.get_report(key)
+ if report:
+ return self.normalize_report(report)
+
+ raise self.notfound()
+
+ def get_instance_title(self, report):
+ """ """
+ return report['report_title']
+
+ def view(self):
+ """
+ This lets user "view" the report but in this context that
+ means showing them a form with report params, so they can run
+ it.
+ """
+ key = self.request.matchdict['report_key']
+ report = self.report_handler.get_report(key)
+ normal = self.normalize_report(report)
+
+ report_url = self.get_action_url('view', normal)
+ form = self.make_model_form(normal,
+ action_method='get',
+ action_url=report_url,
+ cancel_url=self.get_index_url(),
+ show_button_reset=True,
+ reset_url=report_url,
+ button_label_submit="Run Report",
+ button_icon_submit='arrow-circle-right')
+
+ context = {
+ 'instance': normal,
+ 'report': report,
+ 'form': form,
+ 'xref_buttons': self.get_xref_buttons(report),
+ }
+
+ if self.request.GET:
+ form.show_button_cancel = False
+ context = self.run_report(report, context)
+
+ return self.render_to_response('view', context)
+
+ def configure_form(self, f):
+ """ """
+ super().configure_form(f)
+ key = self.request.matchdict['report_key']
+ report = self.report_handler.get_report(key)
+
+ # help_text
+ f.set_readonly('help_text')
+
+ # add widget fields for all report params
+ schema = f.get_schema()
+ report.add_params(schema)
+ f.set_fields([node.name for node in schema.children])
+
+ def run_report(self, report, context):
+ """
+ Run the given report and update view template context.
+
+ This is called automatically from :meth:`view()`.
+
+ :param report:
+ :class:`~wuttjamaican:wuttjamaican.reports.Report` instance
+ to run.
+
+ :param context: Current view template context.
+
+ :returns: Final view template context.
+ """
+ form = context['form']
+ controls = list(self.request.GET.items())
+
+ # TODO: must re-inject help_text value for some reason,
+ # otherwise its absence screws things up. why?
+ controls.append(('help_text', report.__doc__))
+
+ dform = form.get_deform()
+ try:
+ params = dform.validate(controls)
+ except deform.ValidationFailure:
+ log.debug("form not valid: %s", dform.error)
+ return context
+
+ data = self.report_handler.make_report_data(report, params)
+
+ columns = self.normalize_columns(report.get_output_columns())
+ context['report_columns'] = columns
+
+ format_cols = [col for col in columns if col.get('formatter')]
+ if format_cols:
+ for record in data['data']:
+ for column in format_cols:
+ if column['name'] in record:
+ value = record[column['name']]
+ record[column['name']] = column['formatter'](value)
+
+ params.pop('help_text')
+ context['report_params'] = params
+ context['report_data'] = data
+ context['report_generated'] = datetime.datetime.now()
+ return context
+
+ def normalize_columns(self, columns):
+ """ """
+ normal = []
+ for column in columns:
+ if isinstance(column, str):
+ column = {'name': column}
+ column.setdefault('label', column['name'])
+ normal.append(column)
+ return normal
+
+ def get_download_data(self):
+ """ """
+ key = self.request.matchdict['report_key']
+ report = self.report_handler.get_report(key)
+ params = dict(self.request.GET)
+ columns = self.normalize_columns(report.get_output_columns())
+ data = self.report_handler.make_report_data(report, params)
+ return params, columns, data
+
+ def get_download_path(self, data, ext):
+ """ """
+ tempdir = tempfile.mkdtemp()
+ filename = f"{data['output_title']}.{ext}"
+ return os.path.join(tempdir, filename)
+
+ @classmethod
+ def defaults(cls, config):
+ """ """
+ cls._defaults(config)
+ cls._report_defaults(config)
+
+ @classmethod
+ def _report_defaults(cls, config):
+ permission_prefix = cls.get_permission_prefix()
+ model_title = cls.get_model_title()
+
+ # overwrite title for "view" perm since it also implies "run"
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.view',
+ f"View / run {model_title}")
+
+
+ # separate permission to download report files
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.download',
+ f"Download {model_title}")
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ ReportView = kwargs.get('ReportView', base['ReportView'])
+ ReportView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/tests/views/test_reports.py b/tests/views/test_reports.py
new file mode 100644
index 0000000..8f0de1b
--- /dev/null
+++ b/tests/views/test_reports.py
@@ -0,0 +1,231 @@
+# -*- coding: utf-8; -*-
+
+import datetime
+from unittest.mock import patch, MagicMock
+
+from wuttjamaican.reports import Report
+
+import colander
+from pyramid.httpexceptions import HTTPNotFound
+
+from wuttaweb.views import reports as mod
+from wuttaweb.testing import WebTestCase
+
+
+class SomeRandomReport(Report):
+ """
+ This report shows something random.
+ """
+ report_key = 'testing_some_random'
+ report_title = "Random Test Report"
+
+ def add_params(self, schema):
+
+ schema.add(colander.SchemaNode(
+ colander.String(),
+ name='foo',
+ missing=colander.null))
+
+ schema.add(colander.SchemaNode(
+ colander.Date(),
+ name='start_date',
+ missing=colander.null))
+
+ def get_output_columns(self):
+ return ['foo']
+
+ def make_data(self, params, **kwargs):
+ return {
+ 'output_title': "Testing Output",
+ 'data': [{'foo': 'bar'}],
+ }
+
+
+class TestReportViews(WebTestCase):
+
+ def make_view(self):
+ return mod.ReportView(self.request)
+
+ def test_includeme(self):
+ self.pyramid_config.include('wuttaweb.views.reports')
+
+ def test_get_grid_data(self):
+ view = self.make_view()
+ data = view.get_grid_data()
+ self.assertIsInstance(data, list)
+
+ def test_normalize_report(self):
+ view = self.make_view()
+ report = SomeRandomReport(self.config)
+ normal = view.normalize_report(report)
+ help_text = normal.pop('help_text').strip()
+ self.assertEqual(help_text, "This report shows something random.")
+ self.assertEqual(normal, {
+ 'report_key': 'testing_some_random',
+ 'report_title': "Random Test Report",
+ })
+
+ def test_configure_grid(self):
+ view = self.make_view()
+ grid = view.make_model_grid()
+ self.assertIn('report_title', grid.searchable_columns)
+ self.assertIn('help_text', grid.searchable_columns)
+
+ def test_get_instance(self):
+ view = self.make_view()
+ providers = {
+ 'wuttatest': MagicMock(report_modules=['tests.views.test_reports']),
+ }
+ with patch.object(self.app, 'providers', new=providers):
+
+ # normal
+ with patch.object(self.request, 'matchdict', new={'report_key': 'testing_some_random'}):
+ report = view.get_instance()
+ self.assertIsInstance(report, dict)
+ self.assertEqual(report['report_key'], 'testing_some_random')
+ self.assertEqual(report['report_title'], "Random Test Report")
+
+ # not found
+ with patch.object(self.request, 'matchdict', new={'report_key': 'this-should_notEXIST'}):
+ self.assertRaises(HTTPNotFound, view.get_instance)
+
+ def test_get_instance_title(self):
+ view = self.make_view()
+ result = view.get_instance_title({'report_title': 'whatever'})
+ self.assertEqual(result, 'whatever')
+
+ def test_view(self):
+ self.pyramid_config.add_route('home', '/')
+ self.pyramid_config.add_route('login', '/auth/login')
+ self.pyramid_config.add_route('reports', '/reports/')
+ self.pyramid_config.add_route('reports.view', '/reports/{report_key}')
+ view = self.make_view()
+ providers = dict(self.app.providers)
+ providers['wuttatest'] = MagicMock(report_modules=['tests.views.test_reports'])
+ with patch.object(self.app, 'providers', new=providers):
+ with patch.object(self.request, 'matchdict', new={'report_key': 'testing_some_random'}):
+
+ # initial view
+ response = view.view()
+ self.assertEqual(response.status_code, 200)
+ # nb. there's a button in there somewhere, but no output title
+ self.assertIn("Run Report", response.text)
+ self.assertNotIn("Testing Output", response.text)
+
+ # run the report
+ with patch.object(self.request, 'GET', new={
+ '__start__': 'start_date:mapping',
+ 'date': '2025-01-11',
+ '__end__': 'start_date',
+ }):
+ response = view.view()
+ self.assertEqual(response.status_code, 200)
+ # nb. there's a button in there somewhere, *and* an output title
+ self.assertIn("Run Report", response.text)
+ self.assertIn("Testing Output", response.text)
+
+ def test_configure_form(self):
+ view = self.make_view()
+ providers = dict(self.app.providers)
+ providers['wuttatest'] = MagicMock(report_modules=['tests.views.test_reports'])
+ with patch.object(self.app, 'providers', new=providers):
+
+ with patch.object(self.request, 'matchdict', new={'report_key': 'testing_some_random'}):
+ report = view.get_instance()
+ form = view.make_model_form(report)
+ self.assertIn('help_text', form.readonly_fields)
+ self.assertIn('foo', form)
+
+ def test_normalize_columns(self):
+ view = self.make_view()
+
+ columns = view.normalize_columns(['foo'])
+ self.assertEqual(columns, [
+ {'name': 'foo', 'label': 'foo'},
+ ])
+
+ columns = view.normalize_columns([{'name': 'foo'}])
+ self.assertEqual(columns, [
+ {'name': 'foo', 'label': 'foo'},
+ ])
+
+ columns = view.normalize_columns([{'name': 'foo', 'label': "FOO"}])
+ self.assertEqual(columns, [
+ {'name': 'foo', 'label': 'FOO'},
+ ])
+
+ columns = view.normalize_columns([{'name': 'foo', 'label': "FOO", 'numeric': True}])
+ self.assertEqual(columns, [
+ {'name': 'foo', 'label': 'FOO', 'numeric': True},
+ ])
+
+ def test_run_report(self):
+ view = self.make_view()
+ providers = dict(self.app.providers)
+ providers['wuttatest'] = MagicMock(report_modules=['tests.views.test_reports'])
+ with patch.object(self.app, 'providers', new=providers):
+
+ with patch.object(self.request, 'matchdict', new={'report_key': 'testing_some_random'}):
+ report = view.report_handler.get_report('testing_some_random')
+ normal = view.normalize_report(report)
+ form = view.make_model_form(normal)
+
+ # typical
+ context = view.run_report(report, {'form': form})
+ self.assertEqual(sorted(context['report_params']), ['foo', 'start_date'])
+ self.assertEqual(context['report_data'], {
+ 'output_title': "Testing Output",
+ 'data': [{'foo': 'bar'}],
+ })
+ self.assertIn('report_generated', context)
+
+ # invalid params
+ with patch.object(self.request, 'GET', new={'start_date': 'NOT_GOOD'}):
+ context = view.run_report(report, {'form': form})
+ self.assertNotIn('report_params', context)
+ self.assertNotIn('report_data', context)
+ self.assertNotIn('report_generated', context)
+
+ # custom formatter
+ with patch.object(report, 'get_output_columns') as get_output_columns:
+ get_output_columns.return_value = [
+ 'foo',
+ {'name': 'start_date',
+ 'formatter': lambda val: "FORMATTED VALUE"},
+ ]
+
+ with patch.object(report, 'make_data') as make_data:
+ make_data.return_value = [
+ {'foo': 'bar', 'start_date': datetime.date(2025, 1, 11)},
+ ]
+
+ context = view.run_report(report, {'form': form})
+ get_output_columns.assert_called_once_with()
+ self.assertEqual(len(context['report_columns']), 2)
+ self.assertEqual(context['report_columns'][0]['name'], 'foo')
+ self.assertEqual(context['report_columns'][1]['name'], 'start_date')
+ self.assertEqual(context['report_data'], {
+ 'output_title': "Random Test Report",
+ 'data': [{'foo': 'bar', 'start_date': 'FORMATTED VALUE'}],
+ })
+
+ def test_download_data(self):
+ view = self.make_view()
+ providers = dict(self.app.providers)
+ providers['wuttatest'] = MagicMock(report_modules=['tests.views.test_reports'])
+ with patch.object(self.app, 'providers', new=providers):
+ with patch.object(self.request, 'matchdict', new={'report_key': 'testing_some_random'}):
+
+ params, columns, data = view.get_download_data()
+ self.assertEqual(params, {})
+ self.assertEqual(columns, [{'name': 'foo', 'label': 'foo'}])
+ self.assertEqual(data, {
+ 'output_title': "Testing Output",
+ 'data': [{'foo': 'bar'}],
+ })
+
+ def test_download_path(self):
+ view = self.make_view()
+ data = {'output_title': "My Report"}
+ path = view.get_download_path(data, 'csv')
+ self.assertTrue(path.endswith('My Report.csv'))