From a6e43d16584be0654184c3ca2804ba9058ab7f82 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 6 Nov 2016 12:58:29 -0600 Subject: [PATCH] Add support for new Purchase/Batch views, 'create row' master pattern More refactoring here but hopefully not that important.. --- tailbone/static/css/newgrids.css | 6 + tailbone/templates/master/create_row.mako | 10 + tailbone/templates/master/edit_row.mako | 2 +- .../templates/purchases/batches/index.mako | 11 + tailbone/templates/purchases/index.mako | 11 + tailbone/views/__init__.py | 1 + tailbone/views/batch.py | 48 ++-- tailbone/views/master.py | 65 ++++- tailbone/views/purchases/__init__.py | 32 +++ tailbone/views/purchases/batch.py | 232 ++++++++++++++++++ tailbone/views/purchases/core.py | 163 ++++++++++++ 11 files changed, 555 insertions(+), 26 deletions(-) create mode 100644 tailbone/templates/master/create_row.mako create mode 100644 tailbone/templates/purchases/batches/index.mako create mode 100644 tailbone/templates/purchases/index.mako create mode 100644 tailbone/views/purchases/__init__.py create mode 100644 tailbone/views/purchases/batch.py create mode 100644 tailbone/views/purchases/core.py diff --git a/tailbone/static/css/newgrids.css b/tailbone/static/css/newgrids.css index 66351494..4b19509a 100644 --- a/tailbone/static/css/newgrids.css +++ b/tailbone/static/css/newgrids.css @@ -28,6 +28,12 @@ white-space: nowrap; } +.newgrid-wrapper .grid-header td.tools p { + line-height: 2em; + margin: 0; + padding: 0 0.5em 0 0; +} + /****************************** * filters ******************************/ diff --git a/tailbone/templates/master/create_row.mako b/tailbone/templates/master/create_row.mako new file mode 100644 index 00000000..1ad0ab1f --- /dev/null +++ b/tailbone/templates/master/create_row.mako @@ -0,0 +1,10 @@ +## -*- coding: utf-8 -*- +<%inherit file="/master/create.mako" /> + +<%def name="title()">New ${row_model_title} + +<%def name="context_menu_items()"> +
  • ${h.link_to("Back to {}".format(model_title), index_url)}
  • + + +${parent.body()} diff --git a/tailbone/templates/master/edit_row.mako b/tailbone/templates/master/edit_row.mako index c3cfc0af..dab77592 100644 --- a/tailbone/templates/master/edit_row.mako +++ b/tailbone/templates/master/edit_row.mako @@ -10,7 +10,7 @@
  • ${h.link_to("Delete this {}".format(row_model_title), row_action_url('delete', instance))}
  • % endif % if master.rows_creatable and request.has_perm('{}.create'.format(row_permission_prefix)): -
  • ${h.link_to("Create a new {}".format(row_model_title), url('{}.create'.format(row_route_prefix)))}
  • +
  • ${h.link_to("Create a new {}".format(row_model_title), url('{}.create_row'.format(route_prefix), uuid=row_parent.uuid))}
  • % endif diff --git a/tailbone/templates/purchases/batches/index.mako b/tailbone/templates/purchases/batches/index.mako new file mode 100644 index 00000000..346a409c --- /dev/null +++ b/tailbone/templates/purchases/batches/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('purchases.batch'): +
  • ${h.link_to("Go to Purchases", url('purchases'))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/templates/purchases/index.mako b/tailbone/templates/purchases/index.mako new file mode 100644 index 00000000..d9d44c2b --- /dev/null +++ b/tailbone/templates/purchases/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('purchases.batch.list'): +
  • ${h.link_to("Go to Purchase Batches", url('purchases.batch'))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index a1f6502f..a716abca 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -76,6 +76,7 @@ def includeme(config): config.include('tailbone.views.people') config.include('tailbone.views.products') config.include('tailbone.views.progress') + config.include('tailbone.views.purchases') config.include('tailbone.views.reportcodes') config.include('tailbone.views.reports') config.include('tailbone.views.roles') diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 4e726a00..05f66955 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -182,10 +182,7 @@ class BatchMasterView(MasterView): views are encouraged to override this method. """ if self.creating: - fs.configure( - include=[ - fs.created_by.hidden(), - ]) + fs.configure() else: batch = fs.model @@ -208,21 +205,31 @@ class BatchMasterView(MasterView): def _postconfigure_fieldset(self, fs): if self.creating: - if 'created' in fs.render_fields: - del fs.created - if 'created_by' in fs.render_fields: - del fs.created_by - if 'executed' in fs.render_fields: - del fs.executed - if 'executed_by' in fs.render_fields: - del fs.executed_by + unwanted = [ + 'id', + 'rowcount', + 'created', + 'created_by', + 'cognized', + 'cognized_by', + 'executed', + 'executed_by', + 'purge', + 'data_rows', + ] + for field in unwanted: + if field in fs.render_fields: + delattr(fs, field) else: batch = fs.model if not batch.executed: - if 'executed' in fs.render_fields: - del fs.executed - if 'executed_by' in fs.render_fields: - del fs.executed_by + unwanted = [ + 'executed', + 'executed_by', + ] + for field in unwanted: + if field in fs.render_fields: + delattr(fs, field) def save_create_form(self, form): """ @@ -325,11 +332,14 @@ class BatchMasterView(MasterView): return self.render_to_response('edit', context) - def make_row_grid_tools(self, batch): + def make_batch_row_grid_tools(self, batch): if not batch.executed: url = self.request.route_url('{}.delete_rows'.format(self.get_route_prefix()), uuid=batch.uuid) return HTML.tag('p', c=tags.link_to("Delete all rows matching current search", url)) + def make_row_grid_tools(self, batch): + return self.make_batch_row_grid_tools(batch) + def redirect_after_edit(self, batch): """ Redirect back to edit batch page after editing a batch, unless the @@ -464,7 +474,7 @@ class BatchMasterView(MasterView): if progress: progress.session.load() progress.session['error'] = True - progress.session['error_msg'] = "Data refresh failed: {}".format(error) + progress.session['error_msg'] = "Data refresh failed: {} {}".format(error.__class__.__name__, error) progress.session.save() return @@ -619,7 +629,7 @@ class BatchMasterView(MasterView): if progress: progress.session.load() progress.session['error'] = True - progress.session['error_msg'] = "Batch execution failed: {}".format(error) + progress.session['error_msg'] = "Batch execution failed: {}: {}".format(type(error).__name__, error) progress.session.save() # If no error, check result flag (false means user canceled). diff --git a/tailbone/views/master.py b/tailbone/views/master.py index e9be3536..ecbc357e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -36,6 +36,7 @@ from rattail.util import prettify import formalchemy from pyramid import httpexceptions from pyramid.renderers import get_renderer, render_to_response, render +from webhelpers.html import HTML, tags from tailbone import forms, newgrids as grids from tailbone.views import View @@ -179,8 +180,14 @@ class MasterView(View): tools=self.make_row_grid_tools(instance)) return self.render_to_response('view', context) - def make_row_grid_tools(self, instance): - pass + def make_default_row_grid_tools(self, obj): + if self.rows_creatable: + link = tags.link_to("Create a new {}".format(self.get_row_model_title()), + self.get_action_url('create_row', obj)) + return HTML.tag('p', c=link) + + def make_row_grid_tools(self, obj): + return self.make_default_row_grid_tools(obj) def make_row_grid(self, **kwargs): """ @@ -296,6 +303,8 @@ class MasterView(View): @classmethod def get_row_model_title(cls): + if hasattr(cls, 'row_model_title'): + return cls.row_model_title return "{} Row".format(cls.get_model_title()) @classmethod @@ -1022,6 +1031,40 @@ class MasterView(View): # Associated Rows Stuff ############################## + def create_row(self): + """ + View for creating a new row record. + """ + self.creating = True + parent = self.get_instance() + index_url = self.get_action_url('view', parent) + form = self.make_row_form(self.model_row_class, cancel_url=index_url) + if self.request.method == 'POST': + if form.validate(): + self.before_create_row(form) + self.save_create_row_form(form) + obj = form.fieldset.model + self.after_create_row(obj) + return self.redirect_after_create_row(obj) + return self.render_to_response('create_row', { + 'index_url': index_url, + 'index_title': '{} {}'.format( + self.get_model_title(), + self.get_instance_title(parent)), + 'form': form}) + + def save_create_row_form(self, form): + self.save_row_form(form) + + def before_create_row(self, form): + pass + + def after_create_row(self, row_object): + pass + + def redirect_after_create_row(self, row): + return self.redirect(self.get_action_url('view', self.get_parent(row))) + def view_row(self): """ View for viewing details of a single data row. @@ -1061,6 +1104,7 @@ class MasterView(View): parent = self.get_parent(row) return self.render_to_response('edit_row', { 'instance': row, + 'row_parent': parent, 'instance_title': self.get_row_instance_title(row), 'instance_deletable': self.row_deletable(row), 'index_url': self.get_action_url('view', parent), @@ -1138,10 +1182,11 @@ class MasterView(View): self.configure_row_fieldset(fieldset) kwargs.setdefault('action_url', self.request.current_route_url(_query=None)) - if self.creating: - kwargs.setdefault('cancel_url', self.get_action_url('view', self.get_parent(instance))) - else: - kwargs.setdefault('cancel_url', self.get_row_action_url('view', instance)) + if 'cancel_url' not in kwargs: + if self.creating: + kwargs['cancel_url'] = self.get_action_url('view', self.get_parent(instance)) + else: + kwargs['cancel_url'] = self.get_row_action_url('view', instance) form = forms.AlchemyForm(self.request, fieldset, **kwargs) form.readonly = self.viewing @@ -1253,6 +1298,14 @@ class MasterView(View): ### sub-rows stuff follows + # create row + if cls.has_rows and cls.rows_creatable: + config.add_route('{}.create_row'.format(route_prefix), '{}/{{{}}}/new-row'.format(url_prefix, model_key)) + config.add_view(cls, attr='create_row', route_name='{}.create_row'.format(route_prefix), + permission='{}.create_row'.format(permission_prefix)) + config.add_tailbone_permission(permission_prefix, '{}.create_row'.format(permission_prefix), + "Create new {} rows".format(model_title)) + # view row if cls.has_rows and cls.rows_viewable: config.add_route('{}.view'.format(row_route_prefix), '{}/{{uuid}}'.format(row_url_prefix)) diff --git a/tailbone/views/purchases/__init__.py b/tailbone/views/purchases/__init__.py new file mode 100644 index 00000000..f7c8bd1a --- /dev/null +++ b/tailbone/views/purchases/__init__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2016 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 purchase orders +""" + +from __future__ import unicode_literals, absolute_import + + +def includeme(config): + config.include('tailbone.views.purchases.core') + config.include('tailbone.views.purchases.batch') diff --git a/tailbone/views/purchases/batch.py b/tailbone/views/purchases/batch.py new file mode 100644 index 00000000..19572ca3 --- /dev/null +++ b/tailbone/views/purchases/batch.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2016 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 purchase order batches +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db import model, api +from rattail.gpc import GPC +from rattail.db.batch.purchase.handler import PurchaseBatchHandler +from rattail.time import localtime + +import formalchemy as fa + +from tailbone import forms +from tailbone.db import Session +from tailbone.views.batch import BatchMasterView + + +class PurchaseBatchView(BatchMasterView): + """ + Master view for purchase order batches. + """ + model_class = model.PurchaseBatch + model_title_plural = "Purchase Batches" + model_row_class = model.PurchaseBatchRow + batch_handler_class = PurchaseBatchHandler + route_prefix = 'purchases.batch' + url_prefix = '/purchases/batches' + rows_creatable = True + rows_editable = True + + def _preconfigure_grid(self, g): + super(PurchaseBatchView, self)._preconfigure_grid(g) + + g.joiners['vendor'] = lambda q: q.join(model.Vendor) + g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name, + default_active=True, default_verb='contains') + g.sorters['vendor'] = g.make_sorter(model.Vendor.name) + + g.joiners['buyer'] = lambda q: q.join(model.Employee).join(model.Person) + g.filters['buyer'] = g.make_filter('buyer', model.Person.display_name, + default_active=True, default_verb='contains') + g.sorters['buyer'] = g.make_sorter(model.Person.display_name) + + g.date_ordered.set(label="Ordered") + + def configure_grid(self, g): + g.configure( + include=[ + g.id, + g.vendor, + g.buyer, + g.date_ordered, + g.created, + g.created_by, + g.executed, + ], + readonly=True) + + def _preconfigure_fieldset(self, fs): + super(PurchaseBatchView, self)._preconfigure_fieldset(fs) + fs.po_number.set(label="PO Number") + fs.po_total.set(label="PO Total") + + def configure_fieldset(self, fs): + fs.configure( + include=[ + fs.store, + fs.vendor.with_renderer(forms.renderers.VendorFieldRenderer), + fs.buyer.with_renderer(forms.renderers.EmployeeFieldRenderer), + fs.date_ordered, + fs.po_number, + fs.po_total, + fs.created, + fs.created_by, + fs.executed, + fs.executed_by, + ]) + + if self.creating: + del fs.po_total + + # default store may be configured + store = self.rattail_config.get('rattail', 'store') + if store: + store = api.get_store(Session(), store) + if store: + fs.model.store = store + + # default buyer is current user + if self.request.method != 'POST': + buyer = self.request.user.employee + if buyer: + fs.model.buyer = buyer + + # default order date is today + fs.model.date_ordered = localtime(self.rattail_config).date() + + def _preconfigure_row_grid(self, g): + super(PurchaseBatchView, self)._preconfigure_row_grid(g) + + g.filters['upc'].label = "UPC" + g.filters['brand_name'].label = "Brand" + + g.upc.set(label="UPC") + g.brand_name.set(label="Brand") + g.cases_ordered.set(label="Cases") + g.units_ordered.set(label="Units") + g.po_total.set(label="Total") + + def configure_row_grid(self, g): + g.configure( + include=[ + g.sequence, + g.upc, + g.brand_name, + g.description, + g.size, + g.cases_ordered, + g.units_ordered, + g.po_total, + g.status_code, + ], + readonly=True) + + def make_row_grid_tools(self, batch): + return self.make_default_row_grid_tools(batch) + +# def row_grid_row_attrs(self, row, i): +# attrs = {} +# if row.status_code in (row.STATUS_NOT_IN_PURCHASE, +# row.STATUS_NOT_IN_INVOICE, +# row.STATUS_DIFFERS_FROM_PURCHASE): +# attrs['class_'] = 'notice' +# if row.status_code in (row.STATUS_NOT_IN_DB, +# row.STATUS_COST_NOT_IN_DB, +# row.STATUS_NO_CASE_QUANTITY): +# attrs['class_'] = 'warning' +# return attrs + + def _preconfigure_row_fieldset(self, fs): + super(PurchaseBatchView, self)._preconfigure_row_fieldset(fs) + fs.upc.set(label="UPC") + fs.brand_name.set(label="Brand") + fs.po_unit_cost.set(label="PO Unit Cost") + fs.po_total.set(label="PO Total") + fs.append(fa.Field('item_lookup', label="Item Lookup Code", required=True, + validate=self.item_lookup)) + + def item_lookup(self, value, field=None): + """ + Try to locate a single product using ``value`` as a lookup code. + """ + batch = self.get_instance() + product = api.get_product_by_vendor_code(Session(), value, vendor=batch.vendor) + if product: + return product.uuid + if value.isdigit(): + product = api.get_product_by_upc(Session(), GPC(value)) + if not product: + product = api.get_product_by_upc(Session(), GPC(value, calc_check_digit='upc')) + if product: + if not product.cost_for_vendor(batch.vendor): + raise fa.ValidationError("Product {} exists but has no cost for vendor {}".format( + product.upc.pretty(), batch.vendor)) + return product.uuid + raise fa.ValidationError("Product not found") + + def configure_row_fieldset(self, fs): + + if self.creating: + fs.configure( + include=[ + fs.item_lookup, + fs.cases_ordered, + fs.units_ordered, + ]) + + elif self.editing: + fs.configure( + include=[ + fs.upc.readonly(), + fs.product.readonly(), + fs.cases_ordered, + fs.units_ordered, + ]) + + def before_create_row(self, form): + row = form.fieldset.model + batch = self.get_instance() + row.sequence = max([0] + [r.sequence for r in batch.data_rows]) + 1 + row.batch = batch + # TODO: this seems heavy-handed but works.. + row.product_uuid = self.item_lookup(form.fieldset.item_lookup.value) + + def after_create_row(self, row): + self.handler.refresh_row(row) + + def redirect_after_create_row(self, row): + self.request.session.flash("Added item: {} {}".format(row.upc.pretty(), row.product)) + return self.redirect(self.request.current_route_url()) + + # TODO: redirect to new purchase... + # def get_execute_success_url(self, batch, result, **kwargs): + # # return self.get_action_url('view', batch) + # return + + +def includeme(config): + PurchaseBatchView.defaults(config) diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py new file mode 100644 index 00000000..6c568a43 --- /dev/null +++ b/tailbone/views/purchases/core.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2016 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 "true" purchase orders +""" + +from __future__ import unicode_literals, absolute_import + +from rattail import enum +from rattail.db import model + +import formalchemy as fa + +from tailbone import forms +from tailbone.db import Session +from tailbone.views import MasterView + + +class PurchaseView(MasterView): + """ + Master view for purchase orders. + """ + model_class = model.Purchase + creatable = False + editable = False + + has_rows = True + model_row_class = model.PurchaseItem + row_model_title = 'Purchase Item' + + def _preconfigure_grid(self, g): + g.joiners['store'] = lambda q: q.join(model.Store) + g.filters['store'] = g.make_filter('store', model.Store.name) + g.sorters['store'] = g.make_sorter(model.Store.name) + + g.joiners['vendor'] = lambda q: q.join(model.Vendor) + g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name, + default_active=True, default_verb='contains') + g.sorters['vendor'] = g.make_sorter(model.Vendor.name) + + g.joiners['buyer'] = lambda q: q.join(model.Employee).join(model.Person) + g.filters['buyer'] = g.make_filter('buyer', model.Person.display_name, + default_active=True, default_verb='contains') + g.sorters['buyer'] = g.make_sorter(model.Person.display_name) + + g.filters['date_ordered'].label = "Ordered" + g.filters['date_ordered'].default_active = True + g.filters['date_ordered'].default_verb = 'equal' + + g.default_sortkey = 'date_ordered' + g.default_sortdir = 'desc' + + g.date_ordered.set(label="Ordered") + g.status.set(renderer=forms.renderers.EnumFieldRenderer(enum.PURCHASE_STATUS)) + + def configure_grid(self, g): + g.configure( + include=[ + g.store, + g.vendor, + g.buyer, + g.date_ordered, + g.status, + ], + readonly=True) + + def _preconfigure_fieldset(self, fs): + fs.vendor.set(renderer=forms.renderers.VendorFieldRenderer) + fs.status.set(renderer=forms.renderers.EnumFieldRenderer(enum.PURCHASE_STATUS), + readonly=True) + fs.po_number.set(label="PO Number") + fs.po_total.set(label="PO Total") + + def configure_fieldset(self, fs): + fs.configure( + include=[ + fs.store, + fs.vendor, + fs.buyer, + fs.date_ordered, + fs.po_number, + fs.po_total, + fs.status, + fs.created, + fs.created_by, + ]) + + def get_parent(self, item): + return item.purchase + + def get_row_data(self, purchase): + return Session.query(model.PurchaseItem)\ + .filter(model.PurchaseItem.purchase == purchase) + + def _preconfigure_row_grid(self, g): + g.default_sortkey = 'sequence' + g.sequence.set(label="Seq") + g.upc.set(label="UPC") + g.brand_name.set(label="Brand") + g.cases_ordered.set(label="Cases") + g.units_ordered.set(label="Units") + g.po_total.set(label="PO Total") + + def configure_row_grid(self, g): + g.configure( + include=[ + g.sequence, + g.upc, + g.brand_name, + g.description, + g.size, + g.cases_ordered, + g.units_ordered, + g.po_total, + ], + readonly=True) + + def _preconfigure_row_fieldset(self, fs): + fs.vendor_code.set(label="Vendor Item Code") + fs.upc.set(label="UPC") + fs.po_unit_cost.set(label="PO Unit Cost") + fs.po_total.set(label="PO Total") + fs.append(fa.Field('department', value=lambda i: '{} {}'.format(i.department_number, i.department_name))) + + def configure_row_fieldset(self, fs): + + fs.configure( + include=[ + fs.sequence, + fs.vendor_code, + fs.upc, + fs.product, + fs.department, + fs.case_quantity, + fs.cases_ordered, + fs.units_ordered, + fs.po_unit_cost, + fs.po_total, + ]) + + +def includeme(config): + PurchaseView.defaults(config)