diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e4ab3..9d5b53b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,18 +5,6 @@ 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/docs/api/wuttaweb.views.reports.rst b/docs/api/wuttaweb.views.reports.rst deleted file mode 100644 index 52f6cb0..0000000 --- a/docs/api/wuttaweb.views.reports.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttaweb.views.reports`` -========================== - -.. automodule:: wuttaweb.views.reports - :members: diff --git a/docs/index.rst b/docs/index.rst index c535410..cd1d227 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -60,7 +60,6 @@ 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/pyproject.toml b/pyproject.toml index 5a11bc1..1393d58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.20.0" +version = "0.19.3" 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.20.0", + "WuttJamaican[db]>=0.19.3", "zope.sqlalchemy>=1.5", ] diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index c9567bc..f2b9e2c 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -161,23 +161,10 @@ 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, @@ -240,9 +227,6 @@ 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. @@ -282,9 +266,7 @@ 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', @@ -308,11 +290,9 @@ 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 @@ -960,15 +940,10 @@ 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 d039b76..d913054 100644 --- a/src/wuttaweb/templates/forms/vue_template.mako +++ b/src/wuttaweb/templates/forms/vue_template.mako @@ -1,10 +1,8 @@ ## -*- coding: utf-8; -*- - % endif - diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 6876603..ef9473d 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 - page_templates = self.get_page_templates(template) - mako_path = page_templates[0] + template_prefix = self.get_template_prefix() + mako_path = f'{template_prefix}/{template}.mako' try: return render_to_response(mako_path, context, request=self.request) except IOError: # failing that, try one or more fallback templates - for fallback in page_templates[1:]: + for fallback in self.get_fallback_templates(template): try: return render_to_response(fallback, context, request=self.request) except IOError: @@ -1815,51 +1815,21 @@ 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 the current page. See also - :meth:`get_page_templates()`. + attempted for rendering a view. This is used within + :meth:`render_to_response()` if the "first guess" template + file was not found. :param template: Base name for a template (without prefix), e.g. - ``'view'``. + ``'custom'``. - :returns: List of template paths to be tried, based on the - specified template. For instance if ``template`` is - ``'view'`` this will (by default) return:: + :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:: - ['/master/view.mako'] + ['/master/custom.mako'] """ return [f'/master/{template}.mako'] diff --git a/src/wuttaweb/views/reports.py b/src/wuttaweb/views/reports.py deleted file mode 100644 index 357da41..0000000 --- a/src/wuttaweb/views/reports.py +++ /dev/null @@ -1,266 +0,0 @@ -# -*- 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 deleted file mode 100644 index 8f0de1b..0000000 --- a/tests/views/test_reports.py +++ /dev/null @@ -1,231 +0,0 @@ -# -*- 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'))