Compare commits
	
		
			5 commits
		
	
	
		
			c1e6053aaf
			...
			1ec25636df
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1ec25636df | |||
| 65511a26b2 | |||
| ffd4ee929c | |||
| b972f1a132 | |||
| 956021dcbf | 
					 11 changed files with 660 additions and 17 deletions
				
			
		
							
								
								
									
										12
									
								
								CHANGELOG.md
									
										
									
									
									
								
							
							
						
						
									
										12
									
								
								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/)
 | 
					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).
 | 
					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)
 | 
					## v0.19.3 (2025-01-09)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Fix
 | 
					### Fix
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										6
									
								
								docs/api/wuttaweb.views.reports.rst
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								docs/api/wuttaweb.views.reports.rst
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,6 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					``wuttaweb.views.reports``
 | 
				
			||||||
 | 
					==========================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: wuttaweb.views.reports
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
| 
						 | 
					@ -60,6 +60,7 @@ the narrative docs are pretty scant.  That will eventually change.
 | 
				
			||||||
   api/wuttaweb.views.master
 | 
					   api/wuttaweb.views.master
 | 
				
			||||||
   api/wuttaweb.views.people
 | 
					   api/wuttaweb.views.people
 | 
				
			||||||
   api/wuttaweb.views.progress
 | 
					   api/wuttaweb.views.progress
 | 
				
			||||||
 | 
					   api/wuttaweb.views.reports
 | 
				
			||||||
   api/wuttaweb.views.roles
 | 
					   api/wuttaweb.views.roles
 | 
				
			||||||
   api/wuttaweb.views.settings
 | 
					   api/wuttaweb.views.settings
 | 
				
			||||||
   api/wuttaweb.views.upgrades
 | 
					   api/wuttaweb.views.upgrades
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[project]
 | 
					[project]
 | 
				
			||||||
name = "WuttaWeb"
 | 
					name = "WuttaWeb"
 | 
				
			||||||
version = "0.19.3"
 | 
					version = "0.20.0"
 | 
				
			||||||
description = "Web App for Wutta Framework"
 | 
					description = "Web App for Wutta Framework"
 | 
				
			||||||
readme = "README.md"
 | 
					readme = "README.md"
 | 
				
			||||||
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
 | 
					authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
 | 
				
			||||||
| 
						 | 
					@ -44,7 +44,7 @@ dependencies = [
 | 
				
			||||||
        "pyramid_tm",
 | 
					        "pyramid_tm",
 | 
				
			||||||
        "waitress",
 | 
					        "waitress",
 | 
				
			||||||
        "WebHelpers2",
 | 
					        "WebHelpers2",
 | 
				
			||||||
        "WuttJamaican[db]>=0.19.3",
 | 
					        "WuttJamaican[db]>=0.20.0",
 | 
				
			||||||
        "zope.sqlalchemy>=1.5",
 | 
					        "zope.sqlalchemy>=1.5",
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -161,10 +161,23 @@ class Form:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
       See also :meth:`set_required()` and :meth:`is_required()`.
 | 
					       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
 | 
					    .. attribute:: action_url
 | 
				
			||||||
 | 
					
 | 
				
			||||||
       String URL to which the form should be submitted, if applicable.
 | 
					       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
 | 
					    .. attribute:: cancel_url
 | 
				
			||||||
 | 
					
 | 
				
			||||||
       String URL to which the Cancel button should "always" redirect,
 | 
					       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.
 | 
					       Flag indicating whether a Reset button should be shown.
 | 
				
			||||||
       Default is ``False``.
 | 
					       Default is ``False``.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					       Unless there is a :attr:`reset_url`, the reset button will use
 | 
				
			||||||
 | 
					       standard behavior per the browser.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    .. attribute:: show_button_cancel
 | 
					    .. attribute:: show_button_cancel
 | 
				
			||||||
 | 
					
 | 
				
			||||||
       Flag indicating whether a Cancel button should be shown.
 | 
					       Flag indicating whether a Cancel button should be shown.
 | 
				
			||||||
| 
						 | 
					@ -266,7 +282,9 @@ class Form:
 | 
				
			||||||
            readonly_fields=[],
 | 
					            readonly_fields=[],
 | 
				
			||||||
            required_fields={},
 | 
					            required_fields={},
 | 
				
			||||||
            labels={},
 | 
					            labels={},
 | 
				
			||||||
 | 
					            action_method='post',
 | 
				
			||||||
            action_url=None,
 | 
					            action_url=None,
 | 
				
			||||||
 | 
					            reset_url=None,
 | 
				
			||||||
            cancel_url=None,
 | 
					            cancel_url=None,
 | 
				
			||||||
            cancel_url_fallback=None,
 | 
					            cancel_url_fallback=None,
 | 
				
			||||||
            vue_tagname='wutta-form',
 | 
					            vue_tagname='wutta-form',
 | 
				
			||||||
| 
						 | 
					@ -290,9 +308,11 @@ class Form:
 | 
				
			||||||
        self.readonly_fields = set(readonly_fields or [])
 | 
					        self.readonly_fields = set(readonly_fields or [])
 | 
				
			||||||
        self.required_fields = required_fields or {}
 | 
					        self.required_fields = required_fields or {}
 | 
				
			||||||
        self.labels = labels or {}
 | 
					        self.labels = labels or {}
 | 
				
			||||||
 | 
					        self.action_method = action_method
 | 
				
			||||||
        self.action_url = action_url
 | 
					        self.action_url = action_url
 | 
				
			||||||
        self.cancel_url = cancel_url
 | 
					        self.cancel_url = cancel_url
 | 
				
			||||||
        self.cancel_url_fallback = cancel_url_fallback
 | 
					        self.cancel_url_fallback = cancel_url_fallback
 | 
				
			||||||
 | 
					        self.reset_url = reset_url
 | 
				
			||||||
        self.vue_tagname = vue_tagname
 | 
					        self.vue_tagname = vue_tagname
 | 
				
			||||||
        self.align_buttons_right = align_buttons_right
 | 
					        self.align_buttons_right = align_buttons_right
 | 
				
			||||||
        self.auto_disable_submit = auto_disable_submit
 | 
					        self.auto_disable_submit = auto_disable_submit
 | 
				
			||||||
| 
						 | 
					@ -940,10 +960,15 @@ class Form:
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        context['form'] = self
 | 
					        context['form'] = self
 | 
				
			||||||
        context['dform'] = self.get_deform()
 | 
					        context['dform'] = self.get_deform()
 | 
				
			||||||
        context.setdefault('form_attrs', {})
 | 
					 | 
				
			||||||
        context.setdefault('request', self.request)
 | 
					        context.setdefault('request', self.request)
 | 
				
			||||||
        context['model_data'] = self.get_vue_model_data()
 | 
					        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
 | 
					        # auto disable button on submit
 | 
				
			||||||
        if self.auto_disable_submit:
 | 
					        if self.auto_disable_submit:
 | 
				
			||||||
            context['form_attrs']['@submit'] = 'formSubmitting = true'
 | 
					            context['form_attrs']['@submit'] = 'formSubmitting = true'
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,8 +1,10 @@
 | 
				
			||||||
## -*- coding: utf-8; -*-
 | 
					## -*- coding: utf-8; -*-
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script type="text/x-template" id="${form.vue_tagname}-template">
 | 
					<script type="text/x-template" id="${form.vue_tagname}-template">
 | 
				
			||||||
  ${h.form(form.action_url, method='post', enctype='multipart/form-data', **form_attrs)}
 | 
					  ${h.form(form.action_url, **form_attrs)}
 | 
				
			||||||
    ${h.csrf_token(request)}
 | 
					    % if form.action_method == 'post':
 | 
				
			||||||
 | 
					        ${h.csrf_token(request)}
 | 
				
			||||||
 | 
					    % endif
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    % if form.has_global_errors():
 | 
					    % if form.has_global_errors():
 | 
				
			||||||
        % for msg in form.get_global_errors():
 | 
					        % for msg in form.get_global_errors():
 | 
				
			||||||
| 
						 | 
					@ -33,7 +35,13 @@
 | 
				
			||||||
          % endif
 | 
					          % endif
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          % if form.show_button_reset:
 | 
					          % 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
 | 
					                Reset
 | 
				
			||||||
              </b-button>
 | 
					              </b-button>
 | 
				
			||||||
          % endif
 | 
					          % endif
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -89,6 +89,9 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
          </form>
 | 
					          </form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      % else:
 | 
				
			||||||
 | 
					          <div></div>
 | 
				
			||||||
      % endif
 | 
					      % endif
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div style="display: flex; flex-direction: column; justify-content: space-between;">
 | 
					      <div style="display: flex; flex-direction: column; justify-content: space-between;">
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										61
									
								
								src/wuttaweb/templates/reports/view.mako
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								src/wuttaweb/templates/reports/view.mako
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -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>
 | 
				
			||||||
| 
						 | 
					@ -1777,14 +1777,14 @@ class MasterView(View):
 | 
				
			||||||
        context = self.get_template_context(context)
 | 
					        context = self.get_template_context(context)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # first try the template path most specific to this view
 | 
					        # first try the template path most specific to this view
 | 
				
			||||||
        template_prefix = self.get_template_prefix()
 | 
					        page_templates = self.get_page_templates(template)
 | 
				
			||||||
        mako_path = f'{template_prefix}/{template}.mako'
 | 
					        mako_path = page_templates[0]
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            return render_to_response(mako_path, context, request=self.request)
 | 
					            return render_to_response(mako_path, context, request=self.request)
 | 
				
			||||||
        except IOError:
 | 
					        except IOError:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # failing that, try one or more fallback templates
 | 
					            # failing that, try one or more fallback templates
 | 
				
			||||||
            for fallback in self.get_fallback_templates(template):
 | 
					            for fallback in page_templates[1:]:
 | 
				
			||||||
                try:
 | 
					                try:
 | 
				
			||||||
                    return render_to_response(fallback, context, request=self.request)
 | 
					                    return render_to_response(fallback, context, request=self.request)
 | 
				
			||||||
                except IOError:
 | 
					                except IOError:
 | 
				
			||||||
| 
						 | 
					@ -1815,21 +1815,51 @@ class MasterView(View):
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return context
 | 
					        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):
 | 
					    def get_fallback_templates(self, template):
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        Returns a list of "fallback" template paths which may be
 | 
					        Returns a list of "fallback" template paths which may be
 | 
				
			||||||
        attempted for rendering a view.  This is used within
 | 
					        attempted for rendering the current page.  See also
 | 
				
			||||||
        :meth:`render_to_response()` if the "first guess" template
 | 
					        :meth:`get_page_templates()`.
 | 
				
			||||||
        file was not found.
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param template: Base name for a template (without prefix), e.g.
 | 
					        :param template: Base name for a template (without prefix), e.g.
 | 
				
			||||||
           ``'custom'``.
 | 
					           ``'view'``.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :returns: List of full template paths to be tried, based on
 | 
					        :returns: List of template paths to be tried, based on the
 | 
				
			||||||
           the specified template.  For instance if ``template`` is
 | 
					           specified template.  For instance if ``template`` is
 | 
				
			||||||
           ``'custom'`` this will (by default) return::
 | 
					           ``'view'`` this will (by default) return::
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              ['/master/custom.mako']
 | 
					              ['/master/view.mako']
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return [f'/master/{template}.mako']
 | 
					        return [f'/master/{template}.mako']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										266
									
								
								src/wuttaweb/views/reports.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										266
									
								
								src/wuttaweb/views/reports.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -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)
 | 
				
			||||||
							
								
								
									
										231
									
								
								tests/views/test_reports.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										231
									
								
								tests/views/test_reports.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -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'))
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue