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
-%def>
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'))