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/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/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",
 ]
 
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; -*-
 
 <script type="text/x-template" id="${form.vue_tagname}-template">
-  ${h.form(form.action_url, method='post', enctype='multipart/form-data', **form_attrs)}
-    ${h.csrf_token(request)}
+  ${h.form(form.action_url, **form_attrs)}
+    % if form.action_method == 'post':
+        ${h.csrf_token(request)}
+    % endif
 
     % if form.has_global_errors():
         % for msg in form.get_global_errors():
@@ -33,7 +35,13 @@
           % endif
 
           % if form.show_button_reset:
-              <b-button native-type="reset">
+              <b-button
+                % if form.reset_url:
+                    tag="a" href="${form.reset_url}"
+                % else:
+                    native-type="reset"
+                % endif
+                >
                 Reset
               </b-button>
           % endif
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 @@
 
             </div>
           </form>
+
+      % else:
+          <div></div>
       % endif
 
       <div style="display: flex; flex-direction: column; justify-content: space-between;">
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:
+      <br />
+      <a name="report-output"></a>
+      <div style="display: flex; justify-content: space-between;">
+        <div class="report-header">
+          ${self.report_output_header()}
+        </div>
+        <div class="report-tools">
+          ${self.report_tools()}
+        </div>
+      </div>
+      ${self.report_output_body()}
+  % endif
+</%def>
+
+<%def name="report_output_header()">
+  <h4 class="is-size-4"><a href="#report-output">{{ reportData.output_title }}</a></h4>
+</%def>
+
+<%def name="report_tools()"></%def>
+
+<%def name="report_output_body()">
+  ${self.report_output_table()}
+</%def>
+
+<%def name="report_output_table()">
+  <b-table :data="reportData.data"
+           narrowed
+           hoverable>
+    % for column in report_columns:
+        <b-table-column field="${column['name']}"
+                        label="${column['label']}"
+                        % if column.get('numeric'):
+                            numeric
+                        % endif
+                        v-slot="props">
+          <span v-html="props.row.${column['name']}"></span>
+        </b-table-column>
+    % endfor
+  </b-table>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  % if report_data is not Undefined:
+      <script>
+
+        ThisPageData.reportData = ${json.dumps(report_data)|n}
+
+        WholePageData.mountedHooks.push(function() {
+            location.href = '#report-output'
+        })
+
+      </script>
+  % endif
+</%def>
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']
 
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 <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+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 <report>`; 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'))