diff --git a/tailbone/templates/batch/crud.mako b/tailbone/templates/batch/crud.mako new file mode 100644 index 00000000..b7ed9353 --- /dev/null +++ b/tailbone/templates/batch/crud.mako @@ -0,0 +1,78 @@ +## -*- coding: utf-8 -*- +<%inherit file="/crud.mako" /> + +<%def name="title()">${"View" if form.readonly else "Edit"} ${batch_display} + +<%def name="head_tags()"> + + + + +
+ + + + ${form.render(form_id='batch-form', buttons=capture(buttons))|n} + +
+ +<%def name="buttons()"> +
+ % if not form.readonly and batch.refreshable: + ${h.submit('save-refresh', "Save & Refresh Data")} + % endif + % if not batch.executed and request.has_perm('{0}.execute'.format(permission_prefix)): + ## ${h.link_to(execute_title, url('{0}.execute'.format(route_prefix), uuid=batch.uuid))} + + % endif +
+ + +
diff --git a/tailbone/templates/batch/edit.mako b/tailbone/templates/batch/edit.mako new file mode 100644 index 00000000..d6922b7c --- /dev/null +++ b/tailbone/templates/batch/edit.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/crud.mako" /> +${parent.body()} diff --git a/tailbone/templates/batch/rows.mako b/tailbone/templates/batch/rows.mako index c7d04e57..616fc694 100644 --- a/tailbone/templates/batch/rows.mako +++ b/tailbone/templates/batch/rows.mako @@ -9,7 +9,10 @@ -

${h.link_to("Delete all rows matching current search", url('{0}.rows.bulk_delete'.format(route_prefix), uuid=batch.uuid))}

+ ## TODO: Fix this check for edit mode. + % if not batch.executed and request.referrer.endswith('/edit'): +

${h.link_to("Delete all rows matching current search", url('{0}.rows.bulk_delete'.format(route_prefix), uuid=batch.uuid))}

+ % endif diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 63e5e16d..d6922b7c 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -1,65 +1,3 @@ ## -*- coding: utf-8 -*- -<%inherit file="/crud.mako" /> - -<%def name="title()">View ${batch_display} - -<%def name="head_tags()"> - - - - -
- - - - ${form.render()|n} - -
- -
+<%inherit file="/batch/crud.mako" /> +${parent.body()} diff --git a/tailbone/templates/forms/form.mako b/tailbone/templates/forms/form.mako index e1407fc3..60cb633a 100644 --- a/tailbone/templates/forms/form.mako +++ b/tailbone/templates/forms/form.mako @@ -1,16 +1,20 @@ ## -*- coding: utf-8 -*-
- ${h.form(form.action_url, enctype='multipart/form-data')} + ${h.form(form.action_url, id=form_id or None, method='post', enctype='multipart/form-data')} ${form.fieldset.render()|n} -
- ${h.submit('create', form.create_label if form.creating else form.update_label)} - % if form.creating and form.allow_successive_creates: - ${h.submit('create_and_continue', form.successive_create_label)} - % endif - Cancel -
+ % if buttons: + ${buttons|n} + % else: +
+ ${h.submit('create', form.create_label if form.creating else form.update_label)} + % if form.creating and form.allow_successive_creates: + ${h.submit('create_and_continue', form.successive_create_label)} + % endif + Cancel +
+ % endif ${h.end_form()}
diff --git a/tailbone/templates/forms/form_readonly.mako b/tailbone/templates/forms/form_readonly.mako index f8715630..0e4a73f8 100644 --- a/tailbone/templates/forms/form_readonly.mako +++ b/tailbone/templates/forms/form_readonly.mako @@ -1,4 +1,7 @@ ## -*- coding: utf-8 -*-
${form.fieldset.render()|n} + % if buttons: + ${buttons|n} + % endif
diff --git a/tailbone/templates/vendors/invoices/create.mako b/tailbone/templates/vendors/invoices/create.mako new file mode 100644 index 00000000..2e46901c --- /dev/null +++ b/tailbone/templates/vendors/invoices/create.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/create.mako" /> +${parent.body()} diff --git a/tailbone/templates/vendors/invoices/edit.mako b/tailbone/templates/vendors/invoices/edit.mako new file mode 100644 index 00000000..d0eea0a6 --- /dev/null +++ b/tailbone/templates/vendors/invoices/edit.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/edit.mako" /> +${parent.body()} diff --git a/tailbone/templates/vendors/invoices/index.mako b/tailbone/templates/vendors/invoices/index.mako new file mode 100644 index 00000000..acddd2fb --- /dev/null +++ b/tailbone/templates/vendors/invoices/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/vendors/invoices/view.mako b/tailbone/templates/vendors/invoices/view.mako new file mode 100644 index 00000000..9b89af91 --- /dev/null +++ b/tailbone/templates/vendors/invoices/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/batch/view.mako" /> +${parent.body()} diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 563540fc..c1bf97e7 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -37,7 +37,7 @@ import formalchemy from pyramid.renderers import render_to_response from pyramid.response import FileResponse from pyramid.httpexceptions import HTTPFound, HTTPNotFound -from webhelpers.html.tags import link_to +from webhelpers.html.tags import link_to, HTML from rattail.db import model from rattail.db import Session as RatSession @@ -234,9 +234,9 @@ class BatchGrid(BaseGrid): if self.request.has_perm('{0}.view'.format(self.permission_prefix)): g.viewable = True g.view_route_name = '{0}.view'.format(self.route_prefix) - # if self.request.has_perm('{0}.edit'.format(self.permission_prefix)): - # g.editable = True - # g.edit_route_name = '{0}.edit'.format(self.route_prefix) + if self.request.has_perm('{0}.edit'.format(self.permission_prefix)): + g.editable = True + g.edit_route_name = '{0}.edit'.format(self.route_prefix) if self.request.has_perm('{0}.delete'.format(self.permission_prefix)): g.deletable = True g.delete_route_name = '{0}.delete'.format(self.route_prefix) @@ -321,6 +321,12 @@ class BaseCrud(CrudView): else: super(BaseCrud, self).flash_create(model) + def flash_update(self, model): + if 'update' in self.flash: + self.request.session.flash(self.flash['update']) + else: + super(BaseCrud, self).flash_update(model) + def flash_delete(self, model): if 'delete' in self.flash: self.request.session.flash(self.flash['delete']) @@ -414,6 +420,33 @@ class BatchCrud(BaseCrud): fs.executed_by, ]) + def update(self): + """ + Don't allow editing a batch which has already been executed. + """ + batch = self.get_model_from_request() + if not batch: + return HTTPNotFound() + if batch.executed: + return HTTPFound(location=self.view_url(batch.uuid)) + return self.crud(batch) + + def post_create_url(self, form): + """ + Redirect to view batch after creating a batch. + """ + batch = form.fieldset.model + return self.view_url(batch.uuid) + + def post_update_url(self, form): + """ + Redirect back to edit batch page after editing a batch, unless the + refresh flag is set, in which case do that. + """ + if self.request.params.get('refresh') == 'true': + return self.refresh_url() + return self.request.current_route_url() + def template_kwargs(self, form): """ Add some things to the template context: current batch model, batch @@ -425,6 +458,7 @@ class BatchCrud(BaseCrud): 'batch': batch, 'batch_display': self.batch_display, 'batch_display_plural': self.batch_display_plural, + 'execute_title': self.handler.get_execute_title(batch), 'route_prefix': self.route_prefix, 'permission_prefix': self.permission_prefix, } @@ -511,6 +545,14 @@ class BatchCrud(BaseCrud): uuid = self.request.matchdict['uuid'] return self.request.route_url('{0}.view'.format(self.route_prefix), uuid=uuid) + def refresh_url(self, uuid=None): + """ + Returns the URL for refreshing a batch; defaults to current batch. + """ + if uuid is None: + uuid = self.request.matchdict['uuid'] + return self.request.route_url('{0}.refresh'.format(self.route_prefix), uuid=uuid) + def execute(self): batch = self.current_batch() if self.handler.execute(batch): @@ -561,15 +603,15 @@ class FileBatchCrud(BatchCrud): override this, but :meth:`configure_fieldset()` instead. """ fs = self.make_fieldset(model) - fs.created.set(label="Uploaded", renderer=DateTimeFieldRenderer(self.request.rattail_config)) - fs.created_by.set(label="Uploaded by", renderer=UserFieldRenderer) + fs.created.set(label="Uploaded", renderer=DateTimeFieldRenderer(self.request.rattail_config), readonly=True) + fs.created_by.set(label="Uploaded by", renderer=UserFieldRenderer, readonly=True) fs.cognized.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) fs.cognized_by.set(label="Cognized by", renderer=UserFieldRenderer) fs.executed.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) fs.executed_by.set(label="Executed by", renderer=UserFieldRenderer) fs.append(formalchemy.Field('data_file')) fs.data_file.set(renderer=formalchemy.fields.FileFieldRenderer) - fs.filename.set(renderer=DownloadLinkRenderer(self.route_prefix)) + fs.filename.set(renderer=DownloadLinkRenderer(self.route_prefix), readonly=True) self.configure_fieldset(fs) if self.creating: del fs.created @@ -660,13 +702,6 @@ class FileBatchCrud(BatchCrud): return HTTPFound(location=self.request.route_url( '{0}.create'.format(self.route_prefix))) - def post_save_url(self, form): - """ - Redirect to "view batch" after creating or updating a batch. - """ - batch = form.fieldset.model - return self.view_url(batch.uuid) - def pre_delete(self, batch): """ Delete all data (files etc.) for the batch. @@ -690,6 +725,23 @@ class FileBatchCrud(BatchCrud): return response +class StatusRenderer(EnumFieldRenderer): + """ + Custom renderer for ``status_code`` fields. Adds ``status_text`` value as + title attribute if it exists. + """ + + def render_readonly(self, **kwargs): + value = self.raw_value + if value is None: + return '' + status_code_text = self.enumeration.get(value, unicode(value)) + row = self.field.parent.model + if row.status_text: + return HTML.tag('span', title=row.status_text, c=status_code_text) + return status_code_text + + class BatchRowGrid(BaseGrid): """ Base grid view for batch rows, which can be filtered and sorted. Also it @@ -778,14 +830,16 @@ class BatchRowGrid(BaseGrid): g = self.make_grid() g.extra_row_class = self.tr_class g.sequence.set(label="Seq.") - g.status_code.set(label="Status", renderer=EnumFieldRenderer(self.row_class.STATUS)) + g.status_code.set(label="Status", renderer=StatusRenderer(self.row_class.STATUS)) self._configure_grid(g) self.configure_grid(g) batch = self.current_batch() # g.viewable = True # g.view_route_name = '{0}.rows.view'.format(self.route_prefix) - if not batch.executed and self.request.has_perm('{0}.edit'.format(self.permission_prefix)): + # TODO: Fix this check for edit mode. + edit_mode = self.request.referrer.endswith('/edit') + if edit_mode and not batch.executed and self.request.has_perm('{0}.edit'.format(self.permission_prefix)): # g.editable = True # g.edit_route_name = '{0}.rows.edit'.format(self.route_prefix) g.deletable = True diff --git a/tailbone/views/crud.py b/tailbone/views/crud.py index ce2f6203..31f03b7d 100644 --- a/tailbone/views/crud.py +++ b/tailbone/views/crud.py @@ -158,7 +158,11 @@ class CrudView(View): and self.request.params.get('create_and_continue')): return HTTPFound(location=self.request.current_route_url()) - return HTTPFound(location=self.post_save_url(form)) + if form.creating: + url = self.post_create_url(form) + else: + url = self.post_update_url(form) + return HTTPFound(location=url) self.validation_failed(form) @@ -199,6 +203,12 @@ class CrudView(View): def post_save_url(self, form): return self.home_url + def post_create_url(self, form): + return self.post_save_url(form) + + def post_update_url(self, form): + return self.post_save_url(form) + def validation_failed(self, form): pass diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py index f41ba844..79e9d0f3 100644 --- a/tailbone/views/vendors/__init__.py +++ b/tailbone/views/vendors/__init__.py @@ -33,3 +33,4 @@ from .core import (VendorsGrid, VendorCrud, VendorVersionView, def includeme(config): config.include('tailbone.views.vendors.core') config.include('tailbone.views.vendors.catalogs') + config.include('tailbone.views.vendors.invoices') diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index 8aabc98b..46439a01 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -26,8 +26,6 @@ Views for maintaining vendor catalogs from __future__ import unicode_literals -from pyramid.httpexceptions import HTTPFound - from rattail.db import model from rattail.db.api import get_setting, get_vendor from rattail.db.batch.vendorcatalog import VendorCatalog, VendorCatalogRow diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py new file mode 100644 index 00000000..b3df35a4 --- /dev/null +++ b/tailbone/views/vendors/invoices.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Rattail 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 Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ +""" +Views for maintaining vendor invoices +""" + +from __future__ import unicode_literals + +from rattail.db import model +from rattail.db.api import get_setting, get_vendor +from rattail.db.batch.vendorinvoice import VendorInvoice, VendorInvoiceRow +from rattail.db.batch.vendorinvoice.handler import VendorInvoiceHandler +from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser +from rattail.util import load_object + +import formalchemy + +from tailbone.db import Session +from tailbone.views.batch import FileBatchGrid, FileBatchCrud, BatchRowGrid, BatchRowCrud, defaults + + +class VendorInvoiceGrid(FileBatchGrid): + """ + Grid view for vendor invoices. + """ + batch_class = VendorInvoice + batch_display = "Vendor Invoice" + route_prefix = 'vendors.invoices' + + def join_map_extras(self): + return {'vendor': lambda q: q.join(model.Vendor)} + + def filter_map_extras(self): + return {'vendor': self.filter_ilike(model.Vendor.name)} + + def filter_config_extras(self): + return {'filter_type_vendor': 'lk', + 'include_filter_vendor': True} + + def sort_map_extras(self): + return {'vendor': self.sorter(model.Vendor.name)} + + def configure_grid(self, g): + g.configure( + include=[ + g.created, + g.created_by, + g.vendor, + g.filename, + g.executed, + ], + readonly=True) + + +class VendorInvoiceCrud(FileBatchCrud): + """ + CRUD view for vendor invoices. + """ + batch_class = VendorInvoice + batch_handler_class = VendorInvoiceHandler + route_prefix = 'vendors.invoices' + + batch_display = "Vendor Invoice" + flash = {'create': "New vendor invoice has been uploaded.", + 'update': "Vendor invoice has been updated.", + 'delete': "Vendor invoice has been deleted."} + + def get_handler(self): + """ + Returns a `BatchHandler` instance for the view. + + Derived classes may override this, but if you only need to replace the + handler (i.e. and not the view logic) then you can instead subclass + :class:`rattail.db.batch.vendorinvoice.handler.VendorInvoiceHandler` + and create a setting named "rattail.batch.vendorinvoice.handler" in the + database, the value of which should be a spec string pointed at your + custom handler. + """ + handler = get_setting(Session, 'rattail.batch.vendorinvoice.handler') + if not handler: + handler = self.request.rattail_config.get('rattail.batch', 'vendorinvoice.handler') + if handler: + handler = load_object(handler)(self.request.rattail_config) + if not handler: + handler = super(VendorInvoiceCrud, self).get_handler() + return handler + + def configure_fieldset(self, fs): + parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display) + parser_options = [(p.display, p.key) for p in parsers] + parser_options.insert(0, ("(please choose)", '')) + fs.parser_key.set(renderer=formalchemy.fields.SelectFieldRenderer, + options=parser_options) + fs.configure( + include=[ + fs.vendor.readonly(), + fs.data_file.label("Invoice File"), + fs.parser_key.label("File Type"), + fs.filename, + fs.purchase_order_number.label(self.handler.po_number_title), + fs.invoice_date.readonly(), + fs.created, + fs.created_by, + fs.executed, + fs.executed_by, + ]) + if self.creating: + del fs.vendor + del fs.invoice_date + else: + del fs.parser_key + + def init_batch(self, batch): + parser = require_invoice_parser(batch.parser_key) + vendor = get_vendor(Session, parser.vendor_key) + if not vendor: + self.request.session.flash("No vendor setting found in database for key: {0}".format(parser.vendor_key)) + return False + batch.vendor = vendor + return True + + +class VendorInvoiceRowGrid(BatchRowGrid): + """ + Grid view for vendor invoice rows. + """ + row_class = VendorInvoiceRow + route_prefix = 'vendors.invoices' + + def filter_map_extras(self): + return {'ilike': ['upc', 'brand_name', 'description', 'size', 'vendor_code']} + + def filter_config_extras(self): + return {'filter_label_upc': "UPC", + 'filter_label_brand_name': "Brand"} + + def configure_grid(self, g): + g.configure( + include=[ + g.sequence, + g.upc.label("UPC"), + g.brand_name.label("Brand"), + g.description, + g.size, + g.vendor_code, + g.shipped_cases.label("Cases"), + g.shipped_units.label("Units"), + g.unit_cost, + g.status_code, + ], + readonly=True) + + def tr_class(self, row, i): + if row.status_code in ((row.STATUS_NOT_IN_PURCHASE, + row.STATUS_NOT_IN_INVOICE, + row.STATUS_DIFFERS_FROM_PURCHASE)): + return 'notice' + if row.status_code == row.STATUS_NOT_IN_DB: + return 'warning' + + +class VendorInvoiceRowCrud(BatchRowCrud): + row_class = VendorInvoiceRow + route_prefix = 'vendors.invoices' + + +def includeme(config): + """ + Add configuration for the vendor invoice views. + """ + defaults(config, VendorInvoiceGrid, VendorInvoiceCrud, VendorInvoiceRowGrid, VendorInvoiceRowCrud, '/vendors/invoices/')