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>
+
+<%def name="content_title()">%def>
+
+<%def name="extra_javascript()">
+
+%def>
+
+<%def name="extra_styles()">
+
+%def>
+
+<%def name="context_menu_items()">
+ % if request.has_perm('report_output.list'):
+ ${h.link_to("View Generated Reports", url('report_output'))}
+ % endif
+%def>
+
+
+
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>
+
+<%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>
+
+
+
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
+%def>
+
+${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)