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_output_header()} +
+
+ ${self.report_tools()} +
+
+ ${self.report_output_body()} + % endif + + +<%def name="report_output_header()"> +

{{ reportData.output_title }}

+ + +<%def name="report_tools()"> + +<%def name="report_output_body()"> + ${self.report_output_table()} + + +<%def name="report_output_table()"> + + % for column in report_columns: + + + + % endfor + + + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if report_data is not Undefined: + + % endif + 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'))