From a139d9c844e0f3e9412e571556c1373e023277ad Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Mar 2019 18:38:33 -0500 Subject: [PATCH] Add feature for generating new report of arbitrary type and params --- tailbone/templates/base.mako | 6 +- tailbone/templates/reports/choose.mako | 65 ++++++ tailbone/templates/reports/generate.mako | 26 +++ .../templates/reports/generated/index.mako | 11 + tailbone/views/exports.py | 34 ++- tailbone/views/reports.py | 197 +++++++++++++++++- 6 files changed, 327 insertions(+), 12 deletions(-) create mode 100644 tailbone/templates/reports/choose.mako create mode 100644 tailbone/templates/reports/generate.mako create mode 100644 tailbone/templates/reports/generated/index.mako diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 9bc9dfa4..00e27a60 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -60,7 +60,11 @@ % endif % elif index_title: » - ${index_title} + % if index_url: + ${h.link_to(index_title, index_url, class_='global')} + % else: + ${index_title} + % endif % endif
diff --git a/tailbone/templates/reports/choose.mako b/tailbone/templates/reports/choose.mako new file mode 100644 index 00000000..79cb7baa --- /dev/null +++ b/tailbone/templates/reports/choose.mako @@ -0,0 +1,65 @@ +## -*- coding: utf-8; -*- +<%inherit file="/base.mako" /> + +<%def name="title()">${index_title} + +<%def name="content_title()"> + +<%def name="extra_javascript()"> + + + +<%def name="extra_styles()"> + + + +<%def name="context_menu_items()"> + % if request.has_perm('report_output.list'): + ${h.link_to("View Generated Reports", url('report_output'))} + % endif + + + +
+ +
+

Please select the type of report you wish to generate.

+ +
+ ${form.render()|n} +
+
+ +
+ +
    + ${self.context_menu_items()} +
+ +
diff --git a/tailbone/templates/reports/generate.mako b/tailbone/templates/reports/generate.mako new file mode 100644 index 00000000..7ec901e8 --- /dev/null +++ b/tailbone/templates/reports/generate.mako @@ -0,0 +1,26 @@ +## -*- coding: utf-8; -*- +<%inherit file="/base.mako" /> + +<%def name="title()">${index_title} » ${report.name} + +<%def name="content_title()">${report.name} + +<%def name="context_menu_items()"> + % if request.has_perm('report_output.list'): + ${h.link_to("View Generated Reports", url('report_output'))} + % endif + + + +
+ +
+

${report.__doc__}

+ ${form.render()|n} +
+ +
    + ${self.context_menu_items()} +
+ +
diff --git a/tailbone/templates/reports/generated/index.mako b/tailbone/templates/reports/generated/index.mako new file mode 100644 index 00000000..63a5b9b5 --- /dev/null +++ b/tailbone/templates/reports/generated/index.mako @@ -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)): +
  • ${h.link_to("Generate new Report", url('generate_report'))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index 542199cc..1125cde7 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.py @@ -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: diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index d68f4f9c..0ae407d7 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -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)