Add feature for generating new report of arbitrary type and params
This commit is contained in:
		
							parent
							
								
									13bba63382
								
							
						
					
					
						commit
						a139d9c844
					
				
					 6 changed files with 327 additions and 12 deletions
				
			
		| 
						 | 
				
			
			@ -60,7 +60,11 @@
 | 
			
		|||
              % endif
 | 
			
		||||
          % elif index_title:
 | 
			
		||||
              <span class="global">»</span>
 | 
			
		||||
              <span class="global">${index_title}</span>
 | 
			
		||||
              % if index_url:
 | 
			
		||||
                  ${h.link_to(index_title, index_url, class_='global')}
 | 
			
		||||
              % else:
 | 
			
		||||
                  <span class="global">${index_title}</span>
 | 
			
		||||
              % endif
 | 
			
		||||
          % endif
 | 
			
		||||
 | 
			
		||||
          <div class="feedback">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										65
									
								
								tailbone/templates/reports/choose.mako
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								tailbone/templates/reports/choose.mako
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,65 @@
 | 
			
		|||
## -*- coding: utf-8; -*-
 | 
			
		||||
<%inherit file="/base.mako" />
 | 
			
		||||
 | 
			
		||||
<%def name="title()">${index_title}</%def>
 | 
			
		||||
 | 
			
		||||
<%def name="content_title()"></%def>
 | 
			
		||||
 | 
			
		||||
<%def name="extra_javascript()">
 | 
			
		||||
  <script type="text/javascript">
 | 
			
		||||
 | 
			
		||||
    var report_descriptions = ${json.dumps(report_descriptions)|n};
 | 
			
		||||
 | 
			
		||||
    function show_description(key) {
 | 
			
		||||
        var desc = report_descriptions[key];
 | 
			
		||||
        $('#report-description').text(desc);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    $(function() {
 | 
			
		||||
 | 
			
		||||
        var report_type = $('select[name="report_type"]');
 | 
			
		||||
 | 
			
		||||
        report_type.change(function(event) {
 | 
			
		||||
            show_description(report_type.val());
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
  </script>
 | 
			
		||||
</%def>
 | 
			
		||||
 | 
			
		||||
<%def name="extra_styles()">
 | 
			
		||||
  <style type="text/css">
 | 
			
		||||
 | 
			
		||||
    #report-description {
 | 
			
		||||
      margin-top: 2em;
 | 
			
		||||
      margin-left: 2em;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  </style>
 | 
			
		||||
</%def>
 | 
			
		||||
 | 
			
		||||
<%def name="context_menu_items()">
 | 
			
		||||
  % if request.has_perm('report_output.list'):
 | 
			
		||||
      ${h.link_to("View Generated Reports", url('report_output'))}
 | 
			
		||||
  % endif
 | 
			
		||||
</%def>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
<div style="display: flex; justify-content: space-between;">
 | 
			
		||||
 | 
			
		||||
  <div class="form-wrapper">
 | 
			
		||||
    <p>Please select the type of report you wish to generate.</p>
 | 
			
		||||
 | 
			
		||||
    <div style="display: flex;">
 | 
			
		||||
      ${form.render()|n}
 | 
			
		||||
      <div id="report-description"></div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
  </div><!-- form-wrapper -->
 | 
			
		||||
 | 
			
		||||
  <ul id="context-menu">
 | 
			
		||||
    ${self.context_menu_items()}
 | 
			
		||||
  </ul>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										26
									
								
								tailbone/templates/reports/generate.mako
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								tailbone/templates/reports/generate.mako
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,26 @@
 | 
			
		|||
## -*- coding: utf-8; -*-
 | 
			
		||||
<%inherit file="/base.mako" />
 | 
			
		||||
 | 
			
		||||
<%def name="title()">${index_title} » ${report.name}</%def>
 | 
			
		||||
 | 
			
		||||
<%def name="content_title()">${report.name}</%def>
 | 
			
		||||
 | 
			
		||||
<%def name="context_menu_items()">
 | 
			
		||||
  % if request.has_perm('report_output.list'):
 | 
			
		||||
      ${h.link_to("View Generated Reports", url('report_output'))}
 | 
			
		||||
  % endif
 | 
			
		||||
</%def>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
<div style="display: flex; justify-content: space-between;">
 | 
			
		||||
 | 
			
		||||
  <div class="form-wrapper">
 | 
			
		||||
    <p style="padding: 1em;">${report.__doc__}</p>
 | 
			
		||||
    ${form.render()|n}
 | 
			
		||||
  </div><!-- form-wrapper -->
 | 
			
		||||
 | 
			
		||||
  <ul id="context-menu">
 | 
			
		||||
    ${self.context_menu_items()}
 | 
			
		||||
  </ul>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										11
									
								
								tailbone/templates/reports/generated/index.mako
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								tailbone/templates/reports/generated/index.mako
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
## -*- coding: utf-8; -*-
 | 
			
		||||
<%inherit file="/master/index.mako" />
 | 
			
		||||
 | 
			
		||||
<%def name="context_menu_items()">
 | 
			
		||||
  ${parent.context_menu_items()}
 | 
			
		||||
  % if request.has_perm('{}.generate'.format(permission_prefix)):
 | 
			
		||||
      <li>${h.link_to("Generate new Report", url('generate_report'))}</li>
 | 
			
		||||
  % endif
 | 
			
		||||
</%def>
 | 
			
		||||
 | 
			
		||||
${parent.body()}
 | 
			
		||||
| 
						 | 
				
			
			@ -91,23 +91,39 @@ class ExportMasterView(MasterView):
 | 
			
		|||
 | 
			
		||||
    def configure_form(self, f):
 | 
			
		||||
        super(ExportMasterView, self).configure_form(f)
 | 
			
		||||
        export = f.model_instance
 | 
			
		||||
 | 
			
		||||
        # NOTE: we try to handle the 'creating' scenario even though this class
 | 
			
		||||
        # doesn't officially support that; just in case a subclass does want to
 | 
			
		||||
 | 
			
		||||
        # id
 | 
			
		||||
        f.set_readonly('id')
 | 
			
		||||
        f.set_renderer('id', self.render_id)
 | 
			
		||||
        f.set_label('id', "ID")
 | 
			
		||||
        if self.creating:
 | 
			
		||||
            f.remove_field('id')
 | 
			
		||||
        else:
 | 
			
		||||
            f.set_readonly('id')
 | 
			
		||||
            f.set_renderer('id', self.render_id)
 | 
			
		||||
            f.set_label('id', "ID")
 | 
			
		||||
 | 
			
		||||
        # created
 | 
			
		||||
        f.set_readonly('created')
 | 
			
		||||
        f.set_type('created', 'datetime')
 | 
			
		||||
        if self.creating:
 | 
			
		||||
            f.remove_field('created')
 | 
			
		||||
        else:
 | 
			
		||||
            f.set_readonly('created')
 | 
			
		||||
            f.set_type('created', 'datetime')
 | 
			
		||||
 | 
			
		||||
        # created_by
 | 
			
		||||
        f.set_readonly('created_by')
 | 
			
		||||
        f.set_renderer('created_by', self.render_created_by)
 | 
			
		||||
        f.set_label('created_by', "Created by")
 | 
			
		||||
        if self.creating:
 | 
			
		||||
            f.remove_field('created_by')
 | 
			
		||||
        else:
 | 
			
		||||
            f.set_readonly('created_by')
 | 
			
		||||
            f.set_renderer('created_by', self.render_created_by)
 | 
			
		||||
            f.set_label('created_by', "Created by")
 | 
			
		||||
 | 
			
		||||
        # record_count
 | 
			
		||||
        f.set_readonly('record_count')
 | 
			
		||||
        if self.creating:
 | 
			
		||||
            f.remove_field('record_count')
 | 
			
		||||
        else:
 | 
			
		||||
            f.set_readonly('record_count')
 | 
			
		||||
 | 
			
		||||
        # download
 | 
			
		||||
        if self.export_has_file and self.viewing:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@
 | 
			
		|||
################################################################################
 | 
			
		||||
#
 | 
			
		||||
#  Rattail -- Retail Software Framework
 | 
			
		||||
#  Copyright © 2010-2018 Lance Edgar
 | 
			
		||||
#  Copyright © 2010-2019 Lance Edgar
 | 
			
		||||
#
 | 
			
		||||
#  This file is part of Rattail.
 | 
			
		||||
#
 | 
			
		||||
| 
						 | 
				
			
			@ -27,25 +27,34 @@ Reporting views
 | 
			
		|||
from __future__ import unicode_literals, absolute_import
 | 
			
		||||
 | 
			
		||||
import re
 | 
			
		||||
import datetime
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
import six
 | 
			
		||||
 | 
			
		||||
import rattail
 | 
			
		||||
from rattail.db import model
 | 
			
		||||
from rattail.db import model, Session as RattailSession
 | 
			
		||||
from rattail.files import resource_path
 | 
			
		||||
from rattail.time import localtime
 | 
			
		||||
from rattail.reporting import get_report_handler
 | 
			
		||||
from rattail.threads import Thread
 | 
			
		||||
 | 
			
		||||
import colander
 | 
			
		||||
from mako.template import Template
 | 
			
		||||
from pyramid.response import Response
 | 
			
		||||
 | 
			
		||||
from tailbone import forms
 | 
			
		||||
from tailbone.db import Session
 | 
			
		||||
from tailbone.views import View
 | 
			
		||||
from tailbone.views.exports import ExportMasterView
 | 
			
		||||
from tailbone.progress import SessionProgress
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
plu_upc_pattern = re.compile(r'^000000000(\d{5})$')
 | 
			
		||||
weighted_upc_pattern = re.compile(r'^002(\d{5})00000\d$')
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_upc(product):
 | 
			
		||||
    """
 | 
			
		||||
| 
						 | 
				
			
			@ -240,6 +249,185 @@ class ReportOutputView(ExportMasterView):
 | 
			
		|||
        return self.file_response(path)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GenerateReport(View):
 | 
			
		||||
    """
 | 
			
		||||
    View for generating a new report.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, request):
 | 
			
		||||
        super(GenerateReport, self).__init__(request)
 | 
			
		||||
        self.handler = self.get_handler()
 | 
			
		||||
 | 
			
		||||
    def get_handler(self):
 | 
			
		||||
        return get_report_handler(self.rattail_config)
 | 
			
		||||
 | 
			
		||||
    def choose(self):
 | 
			
		||||
        """
 | 
			
		||||
        View which allows user to choose which type of report they wish to
 | 
			
		||||
        generate.
 | 
			
		||||
        """
 | 
			
		||||
        # handler is responsible for determining which report types are valid
 | 
			
		||||
        reports = self.handler.get_reports()
 | 
			
		||||
 | 
			
		||||
        # make form to accept user choice of report type
 | 
			
		||||
        schema = NewReport().bind(valid_report_types=list(reports))
 | 
			
		||||
        form = forms.Form(schema=schema, request=self.request)
 | 
			
		||||
        form.submit_label = "Continue"
 | 
			
		||||
        form.cancel_url = self.request.route_url('report_output')
 | 
			
		||||
 | 
			
		||||
        # TODO: should probably "group" certain reports together somehow?
 | 
			
		||||
        # e.g. some for customers/membership, others for product movement etc.
 | 
			
		||||
        values = [(r.type_key, r.name) for r in reports.values()]
 | 
			
		||||
        form.set_widget('report_type', forms.widgets.PlainSelectWidget(values=values,
 | 
			
		||||
                                                                       size=10))
 | 
			
		||||
 | 
			
		||||
        # if form validates, that means user has chosen a report type, so we
 | 
			
		||||
        # just redirect to the appropriate "new report" page
 | 
			
		||||
        if form.validate(newstyle=True):
 | 
			
		||||
            raise self.redirect(self.request.route_url('generate_specific_report',
 | 
			
		||||
                                                       type_key=form.validated['report_type']))
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            'index_title': "Generate Report",
 | 
			
		||||
            'form': form,
 | 
			
		||||
            'dform': form.make_deform_form(),
 | 
			
		||||
            'reports': reports,
 | 
			
		||||
            'report_descriptions': dict([(r.type_key, r.__doc__)
 | 
			
		||||
                                         for r in reports.values()]),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def generate(self):
 | 
			
		||||
        """
 | 
			
		||||
        View for actually generating a new report.  Allows user to provide
 | 
			
		||||
        input parameters specific to the report type, then creates a new report
 | 
			
		||||
        and redirects user to view the output.
 | 
			
		||||
        """
 | 
			
		||||
        type_key = self.request.matchdict['type_key']
 | 
			
		||||
        report = self.handler.get_report(type_key)
 | 
			
		||||
        report_params = report.make_params(Session())
 | 
			
		||||
 | 
			
		||||
        NODE_TYPES = {
 | 
			
		||||
            datetime.date: colander.Date,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        schema = colander.Schema()
 | 
			
		||||
        for param in report_params:
 | 
			
		||||
 | 
			
		||||
            # make a new node of appropriate schema type
 | 
			
		||||
            node_type = NODE_TYPES.get(param.type, colander.String)
 | 
			
		||||
            node = colander.SchemaNode(typ=node_type(), name=param.name)
 | 
			
		||||
 | 
			
		||||
            # allow empty value if param is optional
 | 
			
		||||
            if not param.required:
 | 
			
		||||
                node.missing = colander.null
 | 
			
		||||
 | 
			
		||||
            schema.add(node)
 | 
			
		||||
 | 
			
		||||
        form = forms.Form(schema=schema, request=self.request)
 | 
			
		||||
        form.submit_label = "Generate this Report"
 | 
			
		||||
        form.cancel_url = self.request.route_url('generate_report')
 | 
			
		||||
 | 
			
		||||
        # must declare jquery support for date fields, ugh
 | 
			
		||||
        # TODO: obviously would be nice for this to be automatic?
 | 
			
		||||
        for param in report_params:
 | 
			
		||||
            if param.type is datetime.date:
 | 
			
		||||
                form.set_type(param.name, 'date_jquery')
 | 
			
		||||
 | 
			
		||||
        # if form validates, start generating new report output; show progress page
 | 
			
		||||
        if form.validate(newstyle=True):
 | 
			
		||||
            key = 'report_output.generate'
 | 
			
		||||
            progress = SessionProgress(self.request, key)
 | 
			
		||||
            kwargs = {'progress': progress}
 | 
			
		||||
            thread = Thread(target=self.generate_thread,
 | 
			
		||||
                            args=(report, form.validated, self.request.user.uuid),
 | 
			
		||||
                            kwargs=kwargs)
 | 
			
		||||
            thread.start()
 | 
			
		||||
            return self.render_progress(progress, {
 | 
			
		||||
                'cancel_url': self.request.route_url('report_output'),
 | 
			
		||||
                'cancel_msg': "Report generation was canceled",
 | 
			
		||||
            })
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            'index_title': "Generate Report",
 | 
			
		||||
            'index_url': self.request.route_url('generate_report'),
 | 
			
		||||
            'report': report,
 | 
			
		||||
            'form': form,
 | 
			
		||||
            'dform': form.make_deform_form(),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def generate_thread(self, report, params, user_uuid, progress=None):
 | 
			
		||||
        """
 | 
			
		||||
        Generate output for the given report and params, and return the
 | 
			
		||||
        resulting :class:`rattail:~rattail.db.model.reports.ReportOutput`
 | 
			
		||||
        object.
 | 
			
		||||
        """
 | 
			
		||||
        session = RattailSession()
 | 
			
		||||
        user = session.query(model.User).get(user_uuid)
 | 
			
		||||
        try:
 | 
			
		||||
            output = self.handler.generate_output(session, report, params, user, progress=progress)
 | 
			
		||||
 | 
			
		||||
        # if anything goes wrong, rollback and log the error etc.
 | 
			
		||||
        except Exception as error:
 | 
			
		||||
            session.rollback()
 | 
			
		||||
            log.exception("Failed to generate '%s' report: %s", report.type_key, report)
 | 
			
		||||
            session.close()
 | 
			
		||||
            if progress:
 | 
			
		||||
                progress.session.load()
 | 
			
		||||
                progress.session['error'] = True
 | 
			
		||||
                progress.session['error_msg'] = "Failed to generate report: {}: {}".format(
 | 
			
		||||
                    type(error).__name__, error)
 | 
			
		||||
                progress.session.save()
 | 
			
		||||
 | 
			
		||||
        # if no error, check result flag (false means user canceled)
 | 
			
		||||
        else:
 | 
			
		||||
            session.commit()
 | 
			
		||||
            success_url = self.request.route_url('report_output.view', uuid=output.uuid)
 | 
			
		||||
            session.close()
 | 
			
		||||
            if progress:
 | 
			
		||||
                progress.session.load()
 | 
			
		||||
                progress.session['complete'] = True
 | 
			
		||||
                progress.session['success_url'] = success_url
 | 
			
		||||
                progress.session.save()
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def defaults(cls, config):
 | 
			
		||||
 | 
			
		||||
        # note that we include this in the "Generated Reports" permissions group
 | 
			
		||||
        config.add_tailbone_permission('report_output', 'report_output.generate',
 | 
			
		||||
                                       "Generate new report (of any type)")
 | 
			
		||||
 | 
			
		||||
        # "generate report" (which is really "choose")
 | 
			
		||||
        config.add_route('generate_report', '/reports/generate')
 | 
			
		||||
        config.add_view(cls, attr='choose', route_name='generate_report',
 | 
			
		||||
                        permission='report_output.generate',
 | 
			
		||||
                        renderer='/reports/choose.mako')
 | 
			
		||||
 | 
			
		||||
        # "generate specific report" (accept custom params, truly generate)
 | 
			
		||||
        config.add_route('generate_specific_report', '/reports/generate/{type_key}')
 | 
			
		||||
        config.add_view(cls, attr='generate', route_name='generate_specific_report',
 | 
			
		||||
                        permission='report_output.generate',
 | 
			
		||||
                        renderer='/reports/generate.mako')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@colander.deferred
 | 
			
		||||
def valid_report_type(node, kw):
 | 
			
		||||
    valid_report_types = kw['valid_report_types']
 | 
			
		||||
 | 
			
		||||
    def validate(node, value):
 | 
			
		||||
        # we just need to provide possible values, and let core validator
 | 
			
		||||
        # handle the rest
 | 
			
		||||
        oneof = colander.OneOf(valid_report_types)
 | 
			
		||||
        return oneof(node, value)
 | 
			
		||||
 | 
			
		||||
    return validate
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewReport(colander.Schema):
 | 
			
		||||
 | 
			
		||||
    report_type = colander.SchemaNode(colander.String(),
 | 
			
		||||
                                      validator=valid_report_type)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def add_routes(config):
 | 
			
		||||
    config.add_route('reports.ordering',        '/reports/ordering')
 | 
			
		||||
    config.add_route('reports.inventory',       '/reports/inventory')
 | 
			
		||||
| 
						 | 
				
			
			@ -254,4 +442,9 @@ def includeme(config):
 | 
			
		|||
    config.add_view(InventoryWorksheet, route_name='reports.inventory',
 | 
			
		||||
                    renderer='/reports/inventory.mako')
 | 
			
		||||
 | 
			
		||||
    # fix permission group
 | 
			
		||||
    config.add_tailbone_permission_group('report_output', "Generated Reports")
 | 
			
		||||
 | 
			
		||||
    # note that GenerateReport must come first, per route matching
 | 
			
		||||
    GenerateReport.defaults(config)
 | 
			
		||||
    ReportOutputView.defaults(config)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue