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
%def>
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>
+
+<%def name="func_show_batch_type()">
+
+%def>
+
+${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(