From cd7922f204b32484f8c050d4cb3d71ecaf407162 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 18 May 2018 15:51:47 -0500 Subject: [PATCH] Add "most of" support for truck dump receiving still not complete, but conceptually it sort of is... --- tailbone/forms/core.py | 6 + tailbone/templates/batch/view.mako | 8 +- tailbone/templates/receiving/create.mako | 67 +++++++++ tailbone/views/batch/core.py | 126 ++++++++-------- tailbone/views/master.py | 36 +++++ tailbone/views/purchases/credits.py | 7 +- tailbone/views/purchasing/batch.py | 16 +- tailbone/views/purchasing/receiving.py | 178 ++++++++++++++++++++++- 8 files changed, 368 insertions(+), 76 deletions(-) create mode 100644 tailbone/templates/receiving/create.mako diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 68140b26..4ffc73d3 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -42,6 +42,7 @@ import deform from colanderalchemy import SQLAlchemySchemaNode from colanderalchemy.schema import _creation_order from deform import widget as dfwidget +from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML @@ -585,6 +586,11 @@ class Form(object): self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) elif type_ == 'text': self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) + elif type_ == 'file': + tmpstore = SessionFileUploadTempStore(self.request) + self.set_node(key, colander.SchemaNode(deform.FileData(), + widget=dfwidget.FileUploadWidget(tmpstore), + title=self.get_label(key))) else: raise ValueError("unknown type for '{}' field: {}".format(key, type_)) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 31f4c88e..bdc63e6a 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -61,7 +61,13 @@ <%def name="execute_button()"> % if not batch.executed and request.has_perm('{}.execute'.format(permission_prefix)): - + % if execute_enabled: + + % elif why_not_execute: + + % else: + + % endif % endif diff --git a/tailbone/templates/receiving/create.mako b/tailbone/templates/receiving/create.mako new file mode 100644 index 00000000..d05634b9 --- /dev/null +++ b/tailbone/templates/receiving/create.mako @@ -0,0 +1,67 @@ +## -*- coding: utf-8; -*- +<%inherit file="/batch/create.mako" /> + +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + ${self.func_show_batch_type()} + + + +<%def name="func_show_batch_type()"> + + + +${parent.body()} diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 15a9c496..cf236c36 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -42,11 +42,9 @@ from rattail.util import load_object, prettify import colander import deform -from deform import widget as dfwidget from pyramid import httpexceptions from pyramid.renderers import render_to_response from pyramid.response import FileResponse -from pyramid_deform import SessionFileUploadTempStore from webhelpers2.html import HTML, tags from tailbone import forms, grids @@ -131,6 +129,9 @@ class BatchMasterView(MasterView): return load_object(spec)(self.rattail_config) return self.batch_handler_class(self.rattail_config) + def download_path(self, batch, filename): + return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename) + def template_kwargs_view(self, **kwargs): batch = kwargs['instance'] kwargs['batch'] = batch @@ -140,6 +141,8 @@ class BatchMasterView(MasterView): if kwargs['execute_enabled']: url = self.get_action_url('execute', batch) kwargs['execute_form'] = self.make_execute_form(batch, action_url=url) + else: + kwargs['why_not_execute'] = self.handler.why_not_execute(batch) return kwargs def allow_worksheet(self, batch): @@ -278,9 +281,6 @@ class BatchMasterView(MasterView): return status_code_text return render_status - def download_path(self, batch, filename): - return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename) - def render_user(self, batch, field): user = getattr(batch, field) if not user: @@ -312,6 +312,7 @@ class BatchMasterView(MasterView): f.remove_field('complete') def save_create_form(self, form): + uploads = self.normalize_uploads(form, skip=['filename']) self.before_create(form) session = self.Session() @@ -346,17 +347,15 @@ class BatchMasterView(MasterView): batch = self.handler.make_batch(session, **kwargs) self.Session.flush() - - # TODO: this needs work yet surely... - # if batch has input data file, let handler properly establish that - if 'filename' in form.schema: - if filedict: - self.handler.set_input_file(batch, filepath) - os.remove(filepath) - os.rmdir(tempdir) - + self.process_uploads(batch, form, uploads) return batch + def process_uploads(self, batch, form, uploads): + for key, upload in six.iteritems(uploads): + self.handler.set_input_file(batch, upload['temp_path'], attr=key) + os.remove(upload['temp_path']) + os.rmdir(upload['tempdir']) + def save_mobile_create_form(self, form): self.before_create(form) session = self.Session() @@ -536,6 +535,39 @@ class BatchMasterView(MasterView): 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_kwargs(self, **kwargs): + """ + Whether or not rows may be edited or deleted will depend partially on + whether the parent batch has been executed. + """ + batch = self.get_instance() + + # TODO: most of this logic is copied from MasterView, should refactor/merge somehow... + if 'main_actions' not in kwargs: + actions = [] + + # view action + if self.rows_viewable: + view = lambda r, i: self.get_row_action_url('view', r) + actions.append(grids.GridAction('view', icon='zoomin', url=view)) + + # edit and delete are NOT allowed after execution, or if batch is "complete" + if not batch.executed and not batch.complete: + + # edit action + if self.rows_editable: + actions.append(grids.GridAction('edit', icon='pencil', url=self.row_edit_action_url)) + + # delete action + permission_prefix = self.get_permission_prefix() + if self.rows_deletable and self.request.has_perm('{}.delete_row'.format(permission_prefix)): + actions.append(grids.GridAction('delete', icon='trash', url=self.row_delete_action_url)) + kwargs.setdefault('delete_speedbump', self.rows_deletable_speedbump) + + kwargs['main_actions'] = actions + + return super(BatchMasterView, self).make_row_grid_kwargs(**kwargs) + def make_row_grid_tools(self, batch): return (self.make_default_row_grid_tools(batch) or '') + (self.make_batch_row_grid_tools(batch) or '') @@ -555,10 +587,7 @@ class BatchMasterView(MasterView): """ Delete all data (files etc.) for the batch. """ - if hasattr(batch, 'delete_data'): - batch.delete_data(self.rattail_config) - if hasattr(batch, 'data_rows'): - del batch.data_rows[:] + self.handler.delete(batch) super(BatchMasterView, self).delete_instance(batch) def get_fallback_templates(self, template, mobile=False): @@ -1153,6 +1182,7 @@ class FileBatchMasterView(BatchMasterView): """ Base class for all file-based "batch master" views. """ + downloadable = True @property def upload_dir(self): @@ -1171,62 +1201,26 @@ class FileBatchMasterView(BatchMasterView): def configure_form(self, f): super(FileBatchMasterView, self).configure_form(f) + batch = f.model_instance # filename - f.set_renderer('filename', self.render_filename) - f.set_label('filename', "Data File") - if self.editing: - f.set_readonly('filename') - if self.creating: - if 'filename' not in f.fields: - f.fields.insert(0, 'filename') - tmpstore = SessionFileUploadTempStore(self.request) - f.set_node('filename', colander.SchemaNode(deform.FileData(), widget=dfwidget.FileUploadWidget(tmpstore))) + # TODO: what's up with this re-insertion again..? + # if 'filename' not in f.fields: + # f.fields.insert(0, 'filename') + f.set_type('filename', 'file') + else: + f.set_readonly('filename') + f.set_renderer('filename', self.render_filename) def render_filename(self, batch, field): - path = batch.filepath(self.rattail_config, filename=batch.filename) + filename = getattr(batch, field) + if not filename: + return "" + path = batch.filepath(self.rattail_config, filename=filename) url = self.get_action_url('download', batch) return self.render_file_field(path, url) - def download(self): - """ - View for downloading the data file associated with a batch. - """ - batch = self.get_instance() - if not batch: - raise httpexceptions.HTTPNotFound() - path = batch.filepath(self.rattail_config) - response = FileResponse(path, request=self.request) - response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path)) - filename = os.path.basename(batch.filename).encode('ascii', 'replace') - response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(filename) - return response - - @classmethod - def defaults(cls, config): - cls._filebatch_defaults(config) - cls._batch_defaults(config) - cls._defaults(config) - - @classmethod - def _filebatch_defaults(cls, config): - route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() - permission_prefix = cls.get_permission_prefix() - model_title = cls.get_model_title() - model_title_plural = cls.get_model_title_plural() - - # fix permission group title - config.add_tailbone_permission_group(permission_prefix, model_title_plural) - - # download batch data file - config.add_route('{}.download'.format(route_prefix), '{}/{{uuid}}/download'.format(url_prefix)) - config.add_view(cls, attr='download', route_name='{}.download'.format(route_prefix), - permission='{}.download'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.download'.format(permission_prefix), - "Download existing {} data file".format(model_title)) - class MobileBatchStatusFilter(grids.filters.MobileFilter): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c6e8db19..fbd160ee 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -27,6 +27,7 @@ Model Master View from __future__ import unicode_literals, absolute_import import os +import tempfile import logging import six @@ -633,14 +634,39 @@ class MasterView(View): return self.render_to_response('create', {'form': form}, mobile=True) def save_create_form(self, form): + uploads = self.normalize_uploads(form) self.before_create(form) with self.Session().no_autoflush: obj = self.objectify(form, self.form_deserialized) self.before_create_flush(obj, form) self.Session.add(obj) self.Session.flush() + self.process_uploads(obj, form, uploads) return obj + def normalize_uploads(self, form, skip=None): + uploads = {} + for node in form.schema: + if isinstance(node.typ, deform.FileData): + if skip and node.name in skip: + continue + filedict = self.form_deserialized.get(node.name) + if filedict: + tempdir = tempfile.mkdtemp() + filepath = os.path.join(tempdir, filedict['filename']) + tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid']) + tmpdata = tmpinfo['fp'].read() + with open(filepath, 'wb') as f: + f.write(tmpdata) + uploads[node.name] = { + 'tempdir': tempdir, + 'temp_path': filepath, + } + return uploads + + def process_uploads(self, obj, form, uploads): + pass + def before_create_flush(self, obj, form): pass @@ -1230,6 +1256,8 @@ class MasterView(View): """ obj = self.get_instance() filename = self.request.GET.get('filename', None) + if not filename: + raise self.notfound() path = self.download_path(obj, filename) response = FileResponse(path, request=self.request) response.content_length = os.path.getsize(path) @@ -2124,6 +2152,14 @@ class MasterView(View): """ return getattr(cls, 'mobile_row_form_factory', forms.Form) + def render_downloadable_file(self, obj, field): + filename = getattr(obj, field) + if not filename: + return "" + path = self.download_path(obj, filename) + url = self.get_action_url('download', obj, _query={'filename': filename}) + return self.render_file_field(path, url) + def render_file_field(self, path, url=None, filename=None): """ Convenience for rendering a file with optional download link diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index ef6cd497..c8b6f684 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.py @@ -26,6 +26,8 @@ Views for "true" purchase credits from __future__ import unicode_literals, absolute_import +import six + from rattail.db import model from webhelpers2.html import tags @@ -70,12 +72,13 @@ class PurchaseCreditView(MasterView): g.set_sort_defaults('date_received', 'desc') + g.set_enum('status', self.enum.PURCHASE_CREDIT_STATUS) g.filters['status'].set_value_renderer(grids.filters.EnumValueRenderer(self.enum.PURCHASE_CREDIT_STATUS)) g.filters['status'].default_active = True g.filters['status'].default_verb = 'not_equal' - g.filters['status'].default_value = self.enum.PURCHASE_CREDIT_STATUS_SATISFIED + # TODO: should not have to convert value to string! + g.filters['status'].default_value = six.text_type(self.enum.PURCHASE_CREDIT_STATUS_SATISFIED) - g.set_enum('status', self.enum.PURCHASE_CREDIT_STATUS) # g.set_type('upc', 'gpc') g.set_type('cases_shorted', 'quantity') g.set_type('units_shorted', 'quantity') diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index fc5d1e37..b4156edf 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -48,6 +48,7 @@ class PurchasingBatchView(BatchMasterView): model_row_class = model.PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' supports_new_product = False + cloneable = True grid_columns = [ 'id', @@ -513,22 +514,33 @@ class PurchasingBatchView(BatchMasterView): kwargs = super(PurchasingBatchView, self).get_batch_kwargs(batch, mobile=mobile) kwargs['mode'] = self.batch_mode kwargs['truck_dump'] = batch.truck_dump + kwargs['invoice_parser_key'] = batch.invoice_parser_key + if batch.store: kwargs['store'] = batch.store elif batch.store_uuid: kwargs['store_uuid'] = batch.store_uuid + + if batch.truck_dump_batch: + kwargs['truck_dump_batch'] = batch.truck_dump_batch + elif batch.truck_dump_batch_uuid: + kwargs['truck_dump_batch_uuid'] = batch.truck_dump_batch_uuid + if batch.vendor: kwargs['vendor'] = batch.vendor elif batch.vendor_uuid: kwargs['vendor_uuid'] = batch.vendor_uuid + if batch.department: kwargs['department'] = batch.department elif batch.department_uuid: kwargs['department_uuid'] = batch.department_uuid + if batch.buyer: kwargs['buyer'] = batch.buyer elif batch.buyer_uuid: kwargs['buyer_uuid'] = batch.buyer_uuid + kwargs['po_number'] = batch.po_number kwargs['po_total'] = batch.po_total @@ -600,7 +612,9 @@ class PurchasingBatchView(BatchMasterView): def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: return 'warning' - if row.status_code in (row.STATUS_INCOMPLETE, row.STATUS_ORDERED_RECEIVED_DIFFER): + if row.status_code in (row.STATUS_INCOMPLETE, + row.STATUS_ORDERED_RECEIVED_DIFFER, + row.STATUS_TRUCKDUMP_UNCLAIMED): return 'notice' def configure_row_form(self, f): diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 039b32ed..55e7d60c 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -28,17 +28,19 @@ from __future__ import unicode_literals, absolute_import import re +import six import sqlalchemy as sa from rattail import pod from rattail.db import model, api from rattail.gpc import GPC from rattail.util import pretty_quantity, prettify +from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser import colander from deform import widget as dfwidget from pyramid import httpexceptions -from webhelpers2.html import tags +from webhelpers2.html import tags, HTML from tailbone import forms, grids from tailbone.views.purchasing import PurchasingBatchView @@ -96,9 +98,8 @@ class ReceivingBatchView(PurchasingBatchView): model_title = "Receiving Batch" model_title_plural = "Receiving Batches" index_title = "Receiving" - creatable = False + downloadable = True rows_editable = True - rows_deletable = False mobile_creatable = True mobile_rows_filterable = True mobile_rows_creatable = True @@ -107,6 +108,11 @@ class ReceivingBatchView(PurchasingBatchView): allow_from_scratch = True allow_truck_dump = False + labels = { + 'truck_dump_batch': "Truck Dump Parent", + 'invoice_parser_key': "Invoice Parser", + } + grid_columns = [ 'id', 'vendor', @@ -123,9 +129,14 @@ class ReceivingBatchView(PurchasingBatchView): form_fields = [ 'id', + 'batch_type', 'store', 'vendor', 'truck_dump', + 'truck_dump_children', + 'truck_dump_batch', + 'invoice_file', + 'invoice_parser_key', 'department', 'purchase', 'vendor_email', @@ -143,6 +154,7 @@ class ReceivingBatchView(PurchasingBatchView): 'created', 'created_by', 'status_code', + 'rowcount', 'complete', 'executed', 'executed_by', @@ -203,12 +215,166 @@ class ReceivingBatchView(PurchasingBatchView): def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_RECEIVING + def row_editable(self, row): + batch = row.batch + if batch.truck_dump_batch: + return False + return True + + def row_deletable(self, row): + batch = row.batch + if batch.truck_dump: + return True + return False + def configure_form(self, f): super(ReceivingBatchView, self).configure_form(f) + batch = f.model_instance - # truck_dump - if self.editing: - f.set_readonly('truck_dump') + # batch_type + if self.creating: + batch_type_values = [ + ('from_scratch', "New from Scratch"), + ] + if self.allow_truck_dump: + batch_type_values.append(('truck_dump', "Invoice for Truck Dump")) + f.set_widget('batch_type', forms.widgets.JQuerySelectWidget(values=batch_type_values)) + else: + f.remove_field('batch_type') + + # truck_dump* + if self.allow_truck_dump: + + # truck_dump + if self.creating: + f.remove_field('truck_dump') + elif batch.truck_dump_batch: + f.remove_field('truck_dump') + else: + f.set_readonly('truck_dump') + + # truck_dump_children + if self.viewing: + if batch.truck_dump: + f.set_renderer('truck_dump_children', self.render_truck_dump_children) + else: + f.remove_field('truck_dump_children') + else: + f.remove_field('truck_dump_children') + + # truck_dump_batch + if self.creating: + f.replace('truck_dump_batch', 'truck_dump_batch_uuid') + batches = self.Session.query(model.PurchaseBatch)\ + .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\ + .filter(model.PurchaseBatch.truck_dump == True)\ + .filter(model.PurchaseBatch.complete == True)\ + .filter(model.PurchaseBatch.executed == None)\ + .order_by(model.PurchaseBatch.id) + batch_values = [(b.uuid, six.text_type(b)) for b in batches] + batch_values.insert(0, ('', "(please choose)")) + f.set_widget('truck_dump_batch_uuid', forms.widgets.JQuerySelectWidget(values=batch_values)) + f.set_label('truck_dump_batch_uuid', "Truck Dump Parent") + elif batch.truck_dump: + f.remove_field('truck_dump_batch') + elif batch.truck_dump_batch: + f.set_readonly('truck_dump_batch') + f.set_renderer('truck_dump_batch', self.render_truck_dump_batch) + else: + f.remove_field('truck_dump_batch') + + else: + f.remove_fields('truck_dump', + 'truck_dump_children', + 'truck_dump_batch') + + # invoice_file + if self.creating: + f.set_type('invoice_file', 'file') + else: + f.set_readonly('invoice_file') + f.set_renderer('invoice_file', self.render_downloadable_file) + + # invoice_parser_key + if self.creating: + parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display) + parser_values = [(p.key, p.display) for p in parsers] + parser_values.insert(0, ('', "(please choose)")) + f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values)) + else: + f.remove_field('invoice_parser_key') + + # store + if self.creating: + store = self.rattail_config.get_store(self.Session()) + f.set_widget('store_uuid', forms.widgets.ReadonlyWidget()) + f.set_default('store_uuid', store.uuid) + f.set_hidden('store_uuid') + + # purchase + if self.creating: + f.remove_field('purchase') + + # department + if self.creating: + f.remove_field('department_uuid') + + def template_kwargs_create(self, **kwargs): + kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs) + if self.allow_truck_dump: + vmap = {} + batches = self.Session.query(model.PurchaseBatch)\ + .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\ + .filter(model.PurchaseBatch.truck_dump == True)\ + .filter(model.PurchaseBatch.complete == True) + for batch in batches: + vmap[batch.uuid] = batch.vendor_uuid + kwargs['batch_vendor_map'] = vmap + return kwargs + + def get_batch_kwargs(self, batch, mobile=False): + kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile) + if not mobile: + batch_type = self.request.POST['batch_type'] + if batch_type == 'from_scratch': + kwargs.pop('truck_dump_batch', None) + kwargs.pop('truck_dump_batch_uuid', None) + elif batch_type == 'truck_dump': + pass + else: + raise NotImplementedError + return kwargs + + def delete_instance(self, batch): + """ + Delete all data (files etc.) for the batch. + """ + truck_dump = batch.truck_dump_batch + if batch.truck_dump: + for child in batch.truck_dump_children: + self.delete_instance(child) + super(ReceivingBatchView, self).delete_instance(batch) + if truck_dump: + self.handler.refresh(truck_dump) + + def render_truck_dump_batch(self, batch, field): + truck_dump = batch.truck_dump_batch + if not truck_dump: + return "" + text = six.text_type(truck_dump) + url = self.request.route_url('receiving.view', uuid=truck_dump.uuid) + return tags.link_to(text, url) + + def render_truck_dump_children(self, batch, field): + children = batch.truck_dump_children + if not children: + return "" + items = [] + for child in children: + text = six.text_type(child) + url = self.request.route_url('receiving.view', uuid=child.uuid) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + return HTML.tag('ul', c=items) def render_mobile_listitem(self, batch, i): title = "({}) {} for ${:0,.2f} - {}, {}".format(