From b0e8f7d9851d6aa32df12e4749c7104bbfd7feab Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 22 May 2018 13:54:50 -0500 Subject: [PATCH] Various changes to support current receiving workflows i.e. for sake of truck dump, adding child from invoice etc. --- tailbone/forms/core.py | 7 ++ tailbone/templates/master/delete.mako | 2 +- tailbone/templates/master/edit.mako | 2 +- tailbone/templates/master/index.mako | 2 +- tailbone/templates/master/view.mako | 2 +- tailbone/views/master.py | 26 +++-- tailbone/views/purchasing/receiving.py | 156 ++++++++++++++++++++----- 7 files changed, 153 insertions(+), 44 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 4ffc73d3..8401729a 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -605,6 +605,9 @@ class Form(object): else: self.enums.pop(key, None) + def get_enum(self, key): + return self.enums.get(key) + def set_renderer(self, key, renderer): if renderer is None: if key in self.renderers: @@ -810,6 +813,10 @@ class Form(object): except TypeError: return getattr(record, field_name, None) + # TODO: is this always safe to do? + elif self.defaults and field_name in self.defaults: + return self.defaults[field_name] + def validate(self, *args, **kwargs): if kwargs.pop('newstyle', False): # yay, new behavior! diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index 85892e35..da247e5c 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -25,7 +25,7 @@ % if master.editable and request.has_perm('{}.edit'.format(permission_prefix)):
  • ${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}
  • % endif - % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)): + % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)):
  • ${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}
  • % endif diff --git a/tailbone/templates/master/edit.mako b/tailbone/templates/master/edit.mako index 6d4a9a60..4a93f8a7 100644 --- a/tailbone/templates/master/edit.mako +++ b/tailbone/templates/master/edit.mako @@ -27,7 +27,7 @@ % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)):
  • ${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}
  • % endif - % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)): + % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)):
  • ${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}
  • % endif diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 8a9a9d7a..56f8c4f6 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -67,7 +67,7 @@ % if master.results_downloadable_xlsx and request.has_perm('{}.results_xlsx'.format(permission_prefix)):
  • ${h.link_to("Download results as XLSX", url('{}.results_xlsx'.format(route_prefix)))}
  • % endif - % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)): + % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): % if master.creates_multiple:
  • ${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}
  • % else: diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 7456c9a3..a584bed5 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -54,7 +54,7 @@ % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)):
  • ${h.link_to("Delete this {}".format(model_title), action_url('delete', instance), class_='delete-instance')}
  • % endif - % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)): + % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): % if master.creates_multiple:
  • ${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}
  • % else: diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 8f08c4db..e1c93f39 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -72,6 +72,7 @@ class MasterView(View): results_downloadable_csv = False results_downloadable_xlsx = False creatable = True + show_create_link = True viewable = True editable = True deletable = True @@ -601,12 +602,13 @@ class MasterView(View): def render_mobile_row_listitem(self, obj, i): return obj - def create(self): + def create(self, form=None): """ View for creating a new model record. """ self.creating = True - form = self.make_form(self.get_model_class()) + if form is None: + form = self.make_form(self.get_model_class()) if self.request.method == 'POST': if self.validate_form(form): # let save_create_form() return alternate object if necessary @@ -2200,7 +2202,7 @@ class MasterView(View): except os.error: return 0 - def make_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): + def make_form(self, instance=None, factory=None, fields=None, schema=None, make_kwargs=None, configure=None, **kwargs): """ Creates a new form for the given model class/instance """ @@ -2210,15 +2212,19 @@ class MasterView(View): fields = self.get_form_fields() if schema is None: schema = self.make_form_schema() + if make_kwargs is None: + make_kwargs = self.make_form_kwargs + if configure is None: + configure = self.configure_form # TODO: SQLAlchemy class instance is assumed *unless* we get a dict # (seems like we should be smarter about this somehow) # if not self.creating and not isinstance(instance, dict): if not self.creating: kwargs['model_instance'] = instance - kwargs = self.make_form_kwargs(**kwargs) + kwargs = make_kwargs(**kwargs) form = factory(fields, schema, **kwargs) - self.configure_form(form) + configure(form) return form def get_form_fields(self): @@ -2271,12 +2277,10 @@ class MasterView(View): self.set_labels(form) def validate_form(self, form): - controls = self.request.POST.items() - try: - self.form_deserialized = form.validate(controls) - except deform.ValidationFailure: - return False - return True + if form.validate(newstyle=True): + self.form_deserialized = form.validated + return True + return False def objectify(self, form, data): obj = form.schema.objectify(data, context=form.model_instance) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 6c754df8..7350cf79 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -35,7 +35,7 @@ from rattail import pod from rattail.db import model, api from rattail.gpc import GPC from rattail.time import localtime -from rattail.util import pretty_quantity, prettify +from rattail.util import pretty_quantity, prettify, OrderedDict from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser import colander @@ -131,6 +131,7 @@ class ReceivingBatchView(PurchasingBatchView): form_fields = [ 'id', 'batch_type', + 'description', 'store', 'vendor', 'truck_dump', @@ -234,12 +235,9 @@ class ReceivingBatchView(PurchasingBatchView): # batch_type if self.creating: - batch_type_values = [ + f.set_enum('batch_type', OrderedDict([ ('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') @@ -285,6 +283,12 @@ class ReceivingBatchView(PurchasingBatchView): else: f.remove_field('truck_dump_batch') + # truck_dump_vendor + if self.creating: + f.set_label('truck_dump_vendor', "Vendor") + f.set_readonly('truck_dump_vendor') + f.set_renderer('truck_dump_vendor', self.render_truck_dump_vendor) + else: f.remove_fields('truck_dump', 'truck_dump_children', @@ -341,8 +345,11 @@ class ReceivingBatchView(PurchasingBatchView): if batch_type == 'from_scratch': kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch_uuid', None) - elif batch_type == 'truck_dump': - pass + elif batch_type.startswith('truck_dump_child'): + truck_dump = self.get_instance() + kwargs['store'] = truck_dump.store + kwargs['vendor'] = truck_dump.vendor + kwargs['truck_dump_batch'] = truck_dump else: raise NotImplementedError return kwargs @@ -367,16 +374,91 @@ class ReceivingBatchView(PurchasingBatchView): url = self.request.route_url('receiving.view', uuid=truck_dump.uuid) return tags.link_to(text, url) + def render_truck_dump_vendor(self, batch, field): + truck_dump = self.get_instance() + vendor = truck_dump.vendor + text = "({}) {}".format(vendor.id, vendor.name) + url = self.request.route_url('vendors.view', uuid=vendor.uuid) + return tags.link_to(text, url) + def render_truck_dump_children(self, batch, field): + contents = [] children = batch.truck_dump_children - if not children: + if children: + 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)])) + contents.append(HTML.tag('ul', c=items)) + if batch.complete and not batch.executed: + buttons = self.make_truck_dump_child_buttons(batch) + if buttons: + buttons = HTML.literal(' ').join(buttons) + contents.append(HTML.tag('div', class_='buttons', c=[buttons])) + if not contents: 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) + return HTML.tag('div', c=contents) + + def make_truck_dump_child_buttons(self, batch): + return [ + tags.link_to("Add from Invoice File", self.get_action_url('add_child_from_invoice', batch), class_='button autodisable'), + ] + + def add_child_from_invoice(self): + """ + View for adding a child batch to a truck dump, from invoice file. + """ + batch = self.get_instance() + if not batch.truck_dump: + self.request.session.flash("Batch is not a truck dump: {}".format(batch)) + return self.redirect(self.get_action_url('view', batch)) + if batch.executed: + self.request.session.flash("Batch has already been executed: {}".format(batch)) + return self.redirect(self.get_action_url('view', batch)) + if not batch.complete: + self.request.session.flash("Batch is not marked as complete: {}".format(batch)) + return self.redirect(self.get_action_url('view', batch)) + self.creating = True + form = self.make_child_from_invoice_form(self.get_model_class()) + return self.create(form=form) + + def make_child_from_invoice_form(self, instance, **kwargs): + """ + Creates a new form for the given model class/instance + """ + kwargs['configure'] = self.configure_child_from_invoice_form + return self.make_form(instance=instance, **kwargs) + + def configure_child_from_invoice_form(self, f): + assert self.creating + truck_dump = self.get_instance() + + self.configure_form(f) + + f.set_fields([ + 'batch_type', + 'truck_dump_parent', + 'truck_dump_vendor', + 'invoice_file', + 'invoice_parser_key', + 'description', + 'notes', + ]) + + # batch_type + f.set_widget('batch_type', forms.widgets.ReadonlyWidget()) + f.set_default('batch_type', 'truck_dump_child_from_invoice') + + # truck_dump_batch_uuid + f.set_readonly('truck_dump_parent') + f.set_renderer('truck_dump_parent', self.render_truck_dump_parent) + + def render_truck_dump_parent(self, batch, field): + truck_dump = self.get_instance() + 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_mobile_listitem(self, batch, i): title = "({}) {} for ${:0,.2f} - {}, {}".format( @@ -565,9 +647,10 @@ class ReceivingBatchView(PurchasingBatchView): } if self.request.has_perm('{}.create_row'.format(permission_prefix)): - update_form = forms.Form(schema=MobileReceivingForm(), request=self.request) + schema = MobileReceivingForm().bind(session=self.Session()) + update_form = forms.Form(schema=schema, request=self.request) if update_form.validate(newstyle=True): - row = self.Session.merge(update_form.validated['row']) + row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row']) # TODO: surely this (delete_row) should be split out to a separate view if update_form.validated['delete_row']: @@ -646,7 +729,7 @@ class ReceivingBatchView(PurchasingBatchView): return credit @classmethod - def defaults(cls, config): + def _receiving_defaults(cls, config): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() model_key = cls.get_model_key() @@ -657,21 +740,19 @@ class ReceivingBatchView(PurchasingBatchView): config.add_view(cls, attr='mobile_lookup', route_name='mobile.{}.lookup'.format(route_prefix), renderer='json', permission='{}.create_row'.format(permission_prefix)) + if cls.allow_truck_dump: + config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/{{{}}}/add-child-from-invoice'.format(url_prefix, model_key)) + config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix), + permission='{}.create'.format(permission_prefix)) + + @classmethod + def defaults(cls, config): + cls._receiving_defaults(config) cls._purchasing_defaults(config) cls._batch_defaults(config) cls._defaults(config) -class PurchaseBatchRowType(forms.types.ObjectType): - model_class = model.PurchaseBatchRow - - def deserialize(self, node, cstruct): - row = super(PurchaseBatchRowType, self).deserialize(node, cstruct) - if row and row.batch.executed: - raise colander.Invalid(node, "Batch has already been executed") - return row - - class MobileNewReceivingBatch(colander.MappingSchema): vendor = colander.SchemaNode(forms.types.VendorType()) @@ -684,9 +765,26 @@ class MobileNewReceivingBatch(colander.MappingSchema): ])) +# TODO: this is a stopgap measure to fix an obvious bug, which exists when the +# session is not provided by the view at runtime (i.e. when it was instead +# being provided by the type instance, which was created upon app startup). +@colander.deferred +def valid_purchase_batch_row(node, kw): + session = kw['session'] + def validate(node, value): + row = session.query(model.PurchaseBatchRow).get(value) + if not row: + raise colander.Invalid(node, "Batch row not found") + if row.batch.executed: + raise colander.Invalid(node, "Batch has already been executed") + return row.uuid + return validate + + class MobileReceivingForm(colander.MappingSchema): - row = colander.SchemaNode(PurchaseBatchRowType()) + row = colander.SchemaNode(colander.String(), + validator=valid_purchase_batch_row) mode = colander.SchemaNode(colander.String(), validator=colander.OneOf([