From 956021dcbf7d6feb12645bc03a406df59cb77816 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 11 Jan 2025 19:41:11 -0600 Subject: [PATCH 1/5] fix: add `get_page_templates()` method for master view i thought i needed it to do something clever for report views, but wound up not needing it.. however this seems like a reasonable abstraction which may come in handy later --- src/wuttaweb/views/master.py | 52 ++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index ef9473d..6876603 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -1777,14 +1777,14 @@ class MasterView(View): context = self.get_template_context(context) # first try the template path most specific to this view - template_prefix = self.get_template_prefix() - mako_path = f'{template_prefix}/{template}.mako' + page_templates = self.get_page_templates(template) + mako_path = page_templates[0] try: return render_to_response(mako_path, context, request=self.request) except IOError: # failing that, try one or more fallback templates - for fallback in self.get_fallback_templates(template): + for fallback in page_templates[1:]: try: return render_to_response(fallback, context, request=self.request) except IOError: @@ -1815,21 +1815,51 @@ class MasterView(View): """ return context + def get_page_templates(self, template): + """ + Returns a list of all templates which can be attempted, to + render the current page. This is called by + :meth:`render_to_response()`. + + The list should be in order of preference, e.g. the first + entry will be the most "specific" template, with subsequent + entries becoming more generic. + + In practice this method defines the first entry but calls + :meth:`get_fallback_templates()` for the rest. + + :param template: Base name for a template (without prefix), e.g. + ``'view'``. + + :returns: List of template paths to be tried, based on the + specified template. For instance if ``template`` is + ``'view'`` this will (by default) return:: + + [ + '/widgets/view.mako', + '/master/view.mako', + ] + + """ + template_prefix = self.get_template_prefix() + page_templates = [f'{template_prefix}/{template}.mako'] + page_templates.extend(self.get_fallback_templates(template)) + return page_templates + def get_fallback_templates(self, template): """ Returns a list of "fallback" template paths which may be - attempted for rendering a view. This is used within - :meth:`render_to_response()` if the "first guess" template - file was not found. + attempted for rendering the current page. See also + :meth:`get_page_templates()`. :param template: Base name for a template (without prefix), e.g. - ``'custom'``. + ``'view'``. - :returns: List of full template paths to be tried, based on - the specified template. For instance if ``template`` is - ``'custom'`` this will (by default) return:: + :returns: List of template paths to be tried, based on the + specified template. For instance if ``template`` is + ``'view'`` this will (by default) return:: - ['/master/custom.mako'] + ['/master/view.mako'] """ return [f'/master/{template}.mako'] From b972f1a132631bb7da644bcc231caf782ea4da8a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 11 Jan 2025 19:42:25 -0600 Subject: [PATCH 2/5] fix: add placeholder when grid has no filters otherwise tools section doesn't get pushed to the right --- src/wuttaweb/templates/grids/vue_template.mako | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index e876eca..746a939 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -89,6 +89,9 @@ + + % else: +
% endif
From ffd4ee929c2446ba133cd673f6482c197a30a418 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 11 Jan 2025 19:49:56 -0600 Subject: [PATCH 3/5] fix: add `action_method` and `reset_url` params for Form class so a form can use GET instead of POST, and reset button can be a link. these are needed for new report views --- src/wuttaweb/forms/base.py | 27 ++++++++++++++++++- .../templates/forms/vue_template.mako | 14 +++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index f2b9e2c..c9567bc 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -161,10 +161,23 @@ class Form: See also :meth:`set_required()` and :meth:`is_required()`. + .. attribute:: action_method + + HTTP method to use when submitting form; ``'post'`` is default. + .. attribute:: action_url String URL to which the form should be submitted, if applicable. + .. attribute:: reset_url + + String URL to which the reset button should "always" redirect, + if applicable. + + This is null by default, in which case it will use standard + browser behavior for the form reset button (if shown). See + also :attr:`show_button_reset`. + .. attribute:: cancel_url String URL to which the Cancel button should "always" redirect, @@ -227,6 +240,9 @@ class Form: Flag indicating whether a Reset button should be shown. Default is ``False``. + Unless there is a :attr:`reset_url`, the reset button will use + standard behavior per the browser. + .. attribute:: show_button_cancel Flag indicating whether a Cancel button should be shown. @@ -266,7 +282,9 @@ class Form: readonly_fields=[], required_fields={}, labels={}, + action_method='post', action_url=None, + reset_url=None, cancel_url=None, cancel_url_fallback=None, vue_tagname='wutta-form', @@ -290,9 +308,11 @@ class Form: self.readonly_fields = set(readonly_fields or []) self.required_fields = required_fields or {} self.labels = labels or {} + self.action_method = action_method self.action_url = action_url self.cancel_url = cancel_url self.cancel_url_fallback = cancel_url_fallback + self.reset_url = reset_url self.vue_tagname = vue_tagname self.align_buttons_right = align_buttons_right self.auto_disable_submit = auto_disable_submit @@ -940,10 +960,15 @@ class Form: """ context['form'] = self context['dform'] = self.get_deform() - context.setdefault('form_attrs', {}) context.setdefault('request', self.request) context['model_data'] = self.get_vue_model_data() + # set form method, enctype + context.setdefault('form_attrs', {}) + context['form_attrs'].setdefault('method', self.action_method) + if self.action_method == 'post': + context['form_attrs'].setdefault('enctype', 'multipart/form-data') + # auto disable button on submit if self.auto_disable_submit: context['form_attrs']['@submit'] = 'formSubmitting = true' diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako index d913054..d039b76 100644 --- a/src/wuttaweb/templates/forms/vue_template.mako +++ b/src/wuttaweb/templates/forms/vue_template.mako @@ -1,8 +1,10 @@ ## -*- coding: utf-8; -*- + % 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')) From 1ec25636df0bec5f31330b3f4009fa4d27717658 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 11 Jan 2025 22:00:48 -0600 Subject: [PATCH 5/5] =?UTF-8?q?bump:=20version=200.19.3=20=E2=86=92=200.20?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ pyproject.toml | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d5b53b..b1e4ab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to wuttaweb 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 views for Reports + +### Fix + +- add `action_method` and `reset_url` params for Form class +- add placeholder when grid has no filters +- add `get_page_templates()` method for master view + ## v0.19.3 (2025-01-09) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 1393d58..5a11bc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.19.3" +version = "0.20.0" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] @@ -44,7 +44,7 @@ dependencies = [ "pyramid_tm", "waitress", "WebHelpers2", - "WuttJamaican[db]>=0.19.3", + "WuttJamaican[db]>=0.20.0", "zope.sqlalchemy>=1.5", ]