Add feature for generating new report of arbitrary type and params

This commit is contained in:
Lance Edgar 2019-03-27 18:38:33 -05:00
parent 13bba63382
commit a139d9c844
6 changed files with 327 additions and 12 deletions

View file

@ -60,8 +60,12 @@
% endif % endif
% elif index_title: % elif index_title:
<span class="global">&raquo;</span> <span class="global">&raquo;</span>
% if index_url:
${h.link_to(index_title, index_url, class_='global')}
% else:
<span class="global">${index_title}</span> <span class="global">${index_title}</span>
% endif % endif
% endif
<div class="feedback"> <div class="feedback">
% if help_url is not Undefined and help_url: % if help_url is not Undefined and help_url:

View 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>

View file

@ -0,0 +1,26 @@
## -*- coding: utf-8; -*-
<%inherit file="/base.mako" />
<%def name="title()">${index_title} &raquo; ${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>

View 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()}

View file

@ -91,22 +91,38 @@ class ExportMasterView(MasterView):
def configure_form(self, f): def configure_form(self, f):
super(ExportMasterView, self).configure_form(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 # id
if self.creating:
f.remove_field('id')
else:
f.set_readonly('id') f.set_readonly('id')
f.set_renderer('id', self.render_id) f.set_renderer('id', self.render_id)
f.set_label('id', "ID") f.set_label('id', "ID")
# created # created
if self.creating:
f.remove_field('created')
else:
f.set_readonly('created') f.set_readonly('created')
f.set_type('created', 'datetime') f.set_type('created', 'datetime')
# created_by # created_by
if self.creating:
f.remove_field('created_by')
else:
f.set_readonly('created_by') f.set_readonly('created_by')
f.set_renderer('created_by', self.render_created_by) f.set_renderer('created_by', self.render_created_by)
f.set_label('created_by', "Created by") f.set_label('created_by', "Created by")
# record_count # record_count
if self.creating:
f.remove_field('record_count')
else:
f.set_readonly('record_count') f.set_readonly('record_count')
# download # download

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2018 Lance Edgar # Copyright © 2010-2019 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -27,25 +27,34 @@ Reporting views
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
import re import re
import datetime
import logging
import six import six
import rattail import rattail
from rattail.db import model from rattail.db import model, Session as RattailSession
from rattail.files import resource_path from rattail.files import resource_path
from rattail.time import localtime 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 mako.template import Template
from pyramid.response import Response from pyramid.response import Response
from tailbone import forms
from tailbone.db import Session from tailbone.db import Session
from tailbone.views import View from tailbone.views import View
from tailbone.views.exports import ExportMasterView from tailbone.views.exports import ExportMasterView
from tailbone.progress import SessionProgress
plu_upc_pattern = re.compile(r'^000000000(\d{5})$') plu_upc_pattern = re.compile(r'^000000000(\d{5})$')
weighted_upc_pattern = re.compile(r'^002(\d{5})00000\d$') weighted_upc_pattern = re.compile(r'^002(\d{5})00000\d$')
log = logging.getLogger(__name__)
def get_upc(product): def get_upc(product):
""" """
@ -240,6 +249,185 @@ class ReportOutputView(ExportMasterView):
return self.file_response(path) 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): def add_routes(config):
config.add_route('reports.ordering', '/reports/ordering') config.add_route('reports.ordering', '/reports/ordering')
config.add_route('reports.inventory', '/reports/inventory') config.add_route('reports.inventory', '/reports/inventory')
@ -254,4 +442,9 @@ def includeme(config):
config.add_view(InventoryWorksheet, route_name='reports.inventory', config.add_view(InventoryWorksheet, route_name='reports.inventory',
renderer='/reports/inventory.mako') 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) ReportOutputView.defaults(config)