Compare commits

..

3 commits

Author SHA1 Message Date
Lance Edgar 28f90ad6b5 bump: version 0.21.11 → 0.22.0 2024-10-22 17:09:29 -05:00
Lance Edgar 535317e4f7 fix: avoid deprecated method to suggest username 2024-10-22 15:04:40 -05:00
Lance Edgar 072db39233 feat: add support for new ordering batch from parsed file 2024-10-22 14:26:10 -05:00
10 changed files with 511 additions and 232 deletions

View file

@ -5,6 +5,16 @@ All notable changes to Tailbone will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.22.0 (2024-10-22)
### Feat
- add support for new ordering batch from parsed file
### Fix
- avoid deprecated method to suggest username
## v0.21.11 (2024-10-03) ## v0.21.11 (2024-10-03)
### Fix ### Fix

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "Tailbone" name = "Tailbone"
version = "0.21.11" version = "0.22.0"
description = "Backoffice Web Application for Rattail" description = "Backoffice Web Application for Rattail"
readme = "README.md" readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

View file

@ -29,8 +29,7 @@ import logging
import humanize import humanize
import sqlalchemy as sa import sqlalchemy as sa
from rattail.db import model from rattail.db.model import PurchaseBatch, PurchaseBatchRow
from rattail.util import pretty_quantity
from cornice import Service from cornice import Service
from deform import widget as dfwidget from deform import widget as dfwidget
@ -45,7 +44,7 @@ log = logging.getLogger(__name__)
class ReceivingBatchViews(APIBatchView): class ReceivingBatchViews(APIBatchView):
model_class = model.PurchaseBatch model_class = PurchaseBatch
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'receivingbatchviews' route_prefix = 'receivingbatchviews'
permission_prefix = 'receiving' permission_prefix = 'receiving'
@ -55,7 +54,8 @@ class ReceivingBatchViews(APIBatchView):
supports_execute = True supports_execute = True
def base_query(self): def base_query(self):
query = super(ReceivingBatchViews, self).base_query() model = self.app.model
query = super().base_query()
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING) query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)
return query return query
@ -85,7 +85,7 @@ class ReceivingBatchViews(APIBatchView):
# assume "receive from PO" if given a PO key # assume "receive from PO" if given a PO key
if data.get('purchase_key'): if data.get('purchase_key'):
data['receiving_workflow'] = 'from_po' data['workflow'] = 'from_po'
return super().create_object(data) return super().create_object(data)
@ -120,6 +120,7 @@ class ReceivingBatchViews(APIBatchView):
return self._get(obj=batch) return self._get(obj=batch)
def eligible_purchases(self): def eligible_purchases(self):
model = self.app.model
uuid = self.request.params.get('vendor_uuid') uuid = self.request.params.get('vendor_uuid')
vendor = self.Session.get(model.Vendor, uuid) if uuid else None vendor = self.Session.get(model.Vendor, uuid) if uuid else None
if not vendor: if not vendor:
@ -176,7 +177,7 @@ class ReceivingBatchViews(APIBatchView):
class ReceivingBatchRowViews(APIBatchRowView): class ReceivingBatchRowViews(APIBatchRowView):
model_class = model.PurchaseBatchRow model_class = PurchaseBatchRow
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'receiving.rows' route_prefix = 'receiving.rows'
permission_prefix = 'receiving' permission_prefix = 'receiving'
@ -185,7 +186,8 @@ class ReceivingBatchRowViews(APIBatchRowView):
supports_quick_entry = True supports_quick_entry = True
def make_filter_spec(self): def make_filter_spec(self):
filters = super(ReceivingBatchRowViews, self).make_filter_spec() model = self.app.model
filters = super().make_filter_spec()
if filters: if filters:
# must translate certain convenience filters # must translate certain convenience filters
@ -296,11 +298,11 @@ class ReceivingBatchRowViews(APIBatchRowView):
return filters return filters
def normalize(self, row): def normalize(self, row):
data = super(ReceivingBatchRowViews, self).normalize(row) data = super().normalize(row)
model = self.app.model
batch = row.batch batch = row.batch
app = self.get_rattail_app() prodder = self.app.get_products_handler()
prodder = app.get_products_handler()
data['product_uuid'] = row.product_uuid data['product_uuid'] = row.product_uuid
data['item_id'] = row.item_id data['item_id'] = row.item_id
@ -375,7 +377,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
if accounted_for: if accounted_for:
# some product accounted for; button should receive "remainder" only # some product accounted for; button should receive "remainder" only
if remainder: if remainder:
remainder = pretty_quantity(remainder) remainder = self.app.render_quantity(remainder)
data['quick_receive_quantity'] = remainder data['quick_receive_quantity'] = remainder
data['quick_receive_text'] = "Receive Remainder ({} {})".format( data['quick_receive_text'] = "Receive Remainder ({} {})".format(
remainder, data['unit_uom']) remainder, data['unit_uom'])
@ -386,7 +388,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
else: # nothing yet accounted for, button should receive "all" else: # nothing yet accounted for, button should receive "all"
if not remainder: if not remainder:
log.warning("quick receive remainder is empty for row %s", row.uuid) log.warning("quick receive remainder is empty for row %s", row.uuid)
remainder = pretty_quantity(remainder) remainder = self.app.render_quantity(remainder)
data['quick_receive_quantity'] = remainder data['quick_receive_quantity'] = remainder
data['quick_receive_text'] = "Receive ALL ({} {})".format( data['quick_receive_text'] = "Receive ALL ({} {})".format(
remainder, data['unit_uom']) remainder, data['unit_uom'])
@ -414,7 +416,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
data['received_alert'] = None data['received_alert'] = None
if self.batch_handler.get_units_confirmed(row): if self.batch_handler.get_units_confirmed(row):
msg = "You have already received some of this product; last update was {}.".format( msg = "You have already received some of this product; last update was {}.".format(
humanize.naturaltime(app.make_utc() - row.modified)) humanize.naturaltime(self.app.make_utc() - row.modified))
data['received_alert'] = msg data['received_alert'] = msg
return data return data
@ -423,6 +425,8 @@ class ReceivingBatchRowViews(APIBatchRowView):
""" """
View which handles "receiving" against a particular batch row. View which handles "receiving" against a particular batch row.
""" """
model = self.app.model
# first do basic input validation # first do basic input validation
schema = ReceiveRow().bind(session=self.Session()) schema = ReceiveRow().bind(session=self.Session())
form = forms.Form(schema=schema, request=self.request) form = forms.Form(schema=schema, request=self.request)

View file

@ -0,0 +1,74 @@
## -*- coding: utf-8; -*-
<%inherit file="/configure.mako" />
<%def name="form_content()">
<h3 class="block is-size-3">Workflows</h3>
<div class="block" style="padding-left: 2rem;">
<p class="block">
Users can only choose from the workflows enabled below.
</p>
<b-field>
<b-checkbox name="rattail.batch.purchase.allow_ordering_from_scratch"
v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_scratch']"
native-value="true"
@input="settingsNeedSaved = true">
From Scratch
</b-checkbox>
</b-field>
<b-field>
<b-checkbox name="rattail.batch.purchase.allow_ordering_from_file"
v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_file']"
native-value="true"
@input="settingsNeedSaved = true">
From Order File
</b-checkbox>
</b-field>
</div>
<h3 class="block is-size-3">Vendors</h3>
<div class="block" style="padding-left: 2rem;">
<b-field message="If not set, user must choose a &quot;supported&quot; vendor.">
<b-checkbox name="rattail.batch.purchase.allow_ordering_any_vendor"
v-model="simpleSettings['rattail.batch.purchase.allow_ordering_any_vendor']"
native-value="true"
@input="settingsNeedSaved = true">
Allow ordering for <span class="has-text-weight-bold">any</span> vendor
</b-checkbox>
</b-field>
</div>
<h3 class="block is-size-3">Order Parsers</h3>
<div class="block" style="padding-left: 2rem;">
<p class="block">
Only the selected file parsers will be exposed to users.
</p>
% for Parser in order_parsers:
<b-field message="${Parser.key}">
<b-checkbox name="order_parser_${Parser.key}"
v-model="orderParsers['${Parser.key}']"
native-value="true"
@input="settingsNeedSaved = true">
${Parser.title}
</b-checkbox>
</b-field>
% endfor
</div>
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
ThisPageData.orderParsers = ${json.dumps(order_parsers_data)|n}
</script>
</%def>

View file

@ -69,12 +69,12 @@
<h3 class="block is-size-3">Vendors</h3> <h3 class="block is-size-3">Vendors</h3>
<div class="block" style="padding-left: 2rem;"> <div class="block" style="padding-left: 2rem;">
<b-field message="If set, user must choose a &quot;supported&quot; vendor; otherwise they may choose &quot;any&quot; vendor."> <b-field message="If not set, user must choose a &quot;supported&quot; vendor.">
<b-checkbox name="rattail.batch.purchase.supported_vendors_only" <b-checkbox name="rattail.batch.purchase.allow_receiving_any_vendor"
v-model="simpleSettings['rattail.batch.purchase.supported_vendors_only']" v-model="simpleSettings['rattail.batch.purchase.allow_receiving_any_vendor']"
native-value="true" native-value="true"
@input="settingsNeedSaved = true"> @input="settingsNeedSaved = true">
Only allow batch for "supported" vendors Allow receiving for <span class="has-text-weight-bold">any</span> vendor
</b-checkbox> </b-checkbox>
</b-field> </b-field>

View file

@ -46,10 +46,11 @@ import colander
from deform import widget as dfwidget from deform import widget as dfwidget
from webhelpers2.html import HTML, tags from webhelpers2.html import HTML, tags
from wuttaweb.util import render_csrf_token
from tailbone import forms, grids from tailbone import forms, grids
from tailbone.db import Session from tailbone.db import Session
from tailbone.views import MasterView from tailbone.views import MasterView
from tailbone.util import csrf_token
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -441,7 +442,7 @@ class BatchMasterView(MasterView):
form = [ form = [
begin_form, begin_form,
csrf_token(self.request), render_csrf_token(self.request),
tags.hidden('complete', value=value), tags.hidden('complete', value=value),
submit, submit,
tags.end_form(), tags.end_form(),

View file

@ -1382,7 +1382,7 @@ class PersonView(MasterView):
} }
if not context['users']: if not context['users']:
context['suggested_username'] = auth.generate_unique_username(self.Session(), context['suggested_username'] = auth.make_unique_username(self.Session(),
person=person) person=person)
return context return context

View file

@ -24,6 +24,8 @@
Base class for purchasing batch views Base class for purchasing batch views
""" """
import warnings
from rattail.db.model import PurchaseBatch, PurchaseBatchRow from rattail.db.model import PurchaseBatch, PurchaseBatchRow
import colander import colander
@ -67,6 +69,8 @@ class PurchasingBatchView(BatchMasterView):
'store', 'store',
'buyer', 'buyer',
'vendor', 'vendor',
'description',
'workflow',
'department', 'department',
'purchase', 'purchase',
'vendor_email', 'vendor_email',
@ -158,6 +162,174 @@ class PurchasingBatchView(BatchMasterView):
def batch_mode(self): def batch_mode(self):
raise NotImplementedError("Please define `batch_mode` for your purchasing batch view") raise NotImplementedError("Please define `batch_mode` for your purchasing batch view")
def get_supported_workflows(self):
"""
Return the supported "create batch" workflows.
"""
enum = self.app.enum
if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
return self.batch_handler.supported_ordering_workflows()
elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
return self.batch_handler.supported_receiving_workflows()
elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING:
return self.batch_handler.supported_costing_workflows()
raise ValueError("unknown batch mode")
def allow_any_vendor(self):
"""
Return boolean indicating whether creating a batch for "any"
vendor is allowed, vs. only supported vendors.
"""
enum = self.app.enum
if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
return self.batch_handler.allow_ordering_any_vendor()
elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
value = self.config.get_bool('rattail.batch.purchase.allow_receiving_any_vendor')
if value is not None:
return value
value = self.config.get_bool('rattail.batch.purchase.supported_vendors_only')
if value is not None:
warnings.warn("setting rattail.batch.purchase.supported_vendors_only is deprecated; "
"please use rattail.batch.purchase.allow_receiving_any_vendor instead",
DeprecationWarning)
# nb. must negate this setting
return not value
return False
raise ValueError("unknown batch mode")
def get_supported_vendors(self):
"""
Return the supported vendors for creating a batch.
"""
return []
def create(self, form=None, **kwargs):
"""
Custom view for creating a new batch. We split the process
into two steps, 1) choose workflow and 2) create batch. This
is because the specific form details for creating a batch will
depend on which "type" of batch creation is to be done, and
it's much easier to keep conditional logic for that in the
server instead of client-side etc.
"""
model = self.app.model
enum = self.app.enum
route_prefix = self.get_route_prefix()
workflows = self.get_supported_workflows()
valid_workflows = [workflow['workflow_key']
for workflow in workflows]
# if user has already identified their desired workflow, then
# we can just farm out to the default logic. we will of
# course configure our form differently, based on workflow,
# but this create() method at least will not need
# customization for that.
if self.request.matched_route.name.endswith('create_workflow'):
redirect = self.redirect(self.request.route_url(f'{route_prefix}.create'))
# however we do have one more thing to check - the workflow
# requested must of course be valid!
workflow_key = self.request.matchdict['workflow_key']
if workflow_key not in valid_workflows:
self.request.session.flash(f"Not a supported workflow: {workflow_key}", 'error')
raise redirect
# also, we require vendor to be correctly identified. if
# someone e.g. navigates to a URL by accident etc. we want
# to gracefully handle and redirect
uuid = self.request.matchdict['vendor_uuid']
vendor = self.Session.get(model.Vendor, uuid)
if not vendor:
self.request.session.flash("Invalid vendor selection. "
"Please choose an existing vendor.",
'warning')
raise redirect
# okay now do the normal thing, per workflow
return super().create(**kwargs)
# on the other hand, if caller provided a form, that means we are in
# the middle of some other custom workflow, e.g. "add child to truck
# dump parent" or some such. in which case we also defer to the normal
# logic, so as to not interfere with that.
if form:
return super().create(form=form, **kwargs)
# okay, at this point we need the user to select a vendor and workflow
self.creating = True
context = {}
# form to accept user choice of vendor/workflow
schema = colander.Schema()
schema.add(colander.SchemaNode(colander.String(), name='vendor'))
schema.add(colander.SchemaNode(colander.String(), name='workflow',
validator=colander.OneOf(valid_workflows)))
factory = self.get_form_factory()
form = factory(schema=schema, request=self.request)
# configure vendor field
vendor_handler = self.app.get_vendor_handler()
if self.allow_any_vendor():
# user may choose *any* available vendor
use_dropdown = vendor_handler.choice_uses_dropdown()
if use_dropdown:
vendors = self.Session.query(model.Vendor)\
.order_by(model.Vendor.id)\
.all()
vendor_values = [(vendor.uuid, f"({vendor.id}) {vendor.name}")
for vendor in vendors]
form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
if len(vendors) == 1:
form.set_default('vendor', vendors[0].uuid)
else:
vendor_display = ""
if self.request.method == 'POST':
if self.request.POST.get('vendor'):
vendor = self.Session.get(model.Vendor, self.request.POST['vendor'])
if vendor:
vendor_display = str(vendor)
vendors_url = self.request.route_url('vendors.autocomplete')
form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget(
field_display=vendor_display, service_url=vendors_url))
else: # only "supported" vendors allowed
vendors = self.get_supported_vendors()
vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor))
for vendor in vendors]
form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
form.set_validator('vendor', self.valid_vendor_uuid)
# configure workflow field
values = [(workflow['workflow_key'], workflow['display'])
for workflow in workflows]
form.set_widget('workflow',
dfwidget.SelectWidget(values=values))
if len(workflows) == 1:
form.set_default('workflow', workflows[0]['workflow_key'])
form.submit_label = "Continue"
form.cancel_url = self.get_index_url()
# if form validates, that means user has chosen a creation
# type, so we just redirect to the appropriate "new batch of
# type X" page
if form.validate():
workflow_key = form.validated['workflow']
vendor_uuid = form.validated['vendor']
url = self.request.route_url(f'{route_prefix}.create_workflow',
workflow_key=workflow_key,
vendor_uuid=vendor_uuid)
raise self.redirect(url)
context['form'] = form
if hasattr(form, 'make_deform_form'):
context['dform'] = form.make_deform_form()
return self.render_to_response('create', context)
def query(self, session): def query(self, session):
model = self.model model = self.model
return session.query(model.PurchaseBatch)\ return session.query(model.PurchaseBatch)\
@ -226,20 +398,40 @@ class PurchasingBatchView(BatchMasterView):
def configure_form(self, f): def configure_form(self, f):
super().configure_form(f) super().configure_form(f)
model = self.model model = self.app.model
enum = self.app.enum
route_prefix = self.get_route_prefix()
today = self.app.today()
batch = f.model_instance batch = f.model_instance
app = self.get_rattail_app() workflow = self.request.matchdict.get('workflow_key')
today = app.localtime().date() vendor_handler = self.app.get_vendor_handler()
# mode # mode
f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE) f.set_enum('mode', enum.PURCHASE_BATCH_MODE)
# workflow
if self.creating:
if workflow:
f.set_widget('workflow', dfwidget.HiddenWidget())
f.set_default('workflow', workflow)
f.set_hidden('workflow')
# nb. show readonly '_workflow'
f.insert_after('workflow', '_workflow')
f.set_readonly('_workflow')
f.set_renderer('_workflow', self.render_workflow)
else:
f.set_readonly('workflow')
f.set_renderer('workflow', self.render_workflow)
else:
f.remove('workflow')
# store # store
single_store = self.rattail_config.single_store() single_store = self.config.single_store()
if self.creating: if self.creating:
f.replace('store', 'store_uuid') f.replace('store', 'store_uuid')
if single_store: if single_store:
store = self.rattail_config.get_store(self.Session()) store = self.config.get_store(self.Session())
f.set_widget('store_uuid', dfwidget.HiddenWidget()) f.set_widget('store_uuid', dfwidget.HiddenWidget())
f.set_default('store_uuid', store.uuid) f.set_default('store_uuid', store.uuid)
f.set_hidden('store_uuid') f.set_hidden('store_uuid')
@ -263,7 +455,6 @@ class PurchasingBatchView(BatchMasterView):
if self.creating: if self.creating:
f.replace('vendor', 'vendor_uuid') f.replace('vendor', 'vendor_uuid')
f.set_label('vendor_uuid', "Vendor") f.set_label('vendor_uuid', "Vendor")
vendor_handler = app.get_vendor_handler()
use_dropdown = vendor_handler.choice_uses_dropdown() use_dropdown = vendor_handler.choice_uses_dropdown()
if use_dropdown: if use_dropdown:
vendors = self.Session.query(model.Vendor)\ vendors = self.Session.query(model.Vendor)\
@ -313,7 +504,7 @@ class PurchasingBatchView(BatchMasterView):
if buyer: if buyer:
buyer_display = str(buyer) buyer_display = str(buyer)
elif self.creating: elif self.creating:
buyer = app.get_employee(self.request.user) buyer = self.app.get_employee(self.request.user)
if buyer: if buyer:
buyer_display = str(buyer) buyer_display = str(buyer)
f.set_default('buyer_uuid', buyer.uuid) f.set_default('buyer_uuid', buyer.uuid)
@ -324,6 +515,30 @@ class PurchasingBatchView(BatchMasterView):
field_display=buyer_display, service_url=buyers_url)) field_display=buyer_display, service_url=buyers_url))
f.set_label('buyer_uuid', "Buyer") f.set_label('buyer_uuid', "Buyer")
# order_file
if self.creating:
f.set_type('order_file', 'file', required=False)
else:
f.set_readonly('order_file')
f.set_renderer('order_file', self.render_downloadable_file)
# order_parser_key
if self.creating:
kwargs = {}
if 'vendor_uuid' in self.request.matchdict:
vendor = self.Session.get(model.Vendor,
self.request.matchdict['vendor_uuid'])
if vendor:
kwargs['vendor'] = vendor
parsers = vendor_handler.get_supported_order_parsers(**kwargs)
parser_values = [(p.key, p.title) for p in parsers]
if len(parsers) == 1:
f.set_default('order_parser_key', parsers[0].key)
f.set_widget('order_parser_key', dfwidget.SelectWidget(values=parser_values))
f.set_label('order_parser_key', "Order Parser")
else:
f.remove_field('order_parser_key')
# invoice_file # invoice_file
if self.creating: if self.creating:
f.set_type('invoice_file', 'file', required=False) f.set_type('invoice_file', 'file', required=False)
@ -341,7 +556,7 @@ class PurchasingBatchView(BatchMasterView):
if vendor: if vendor:
kwargs['vendor'] = vendor kwargs['vendor'] = vendor
parsers = self.handler.get_supported_invoice_parsers(**kwargs) parsers = self.batch_handler.get_supported_invoice_parsers(**kwargs)
parser_values = [(p.key, p.display) for p in parsers] parser_values = [(p.key, p.display) for p in parsers]
if len(parsers) == 1: if len(parsers) == 1:
f.set_default('invoice_parser_key', parsers[0].key) f.set_default('invoice_parser_key', parsers[0].key)
@ -400,6 +615,35 @@ class PurchasingBatchView(BatchMasterView):
'vendor_contact', 'vendor_contact',
'status_code') 'status_code')
# tweak some things if we are in "step 2" of creating new batch
if self.creating and workflow:
# display vendor but do not allow changing
vendor = self.Session.get(model.Vendor, self.request.matchdict['vendor_uuid'])
if not vendor:
raise ValueError(f"vendor not found: {self.request.matchdict['vendor_uuid']}")
f.set_readonly('vendor_uuid')
f.set_default('vendor_uuid', str(vendor))
# cancel should take us back to choosing a workflow
f.cancel_url = self.request.route_url(f'{route_prefix}.create')
def render_workflow(self, batch, field):
key = self.request.matchdict['workflow_key']
info = self.get_workflow_info(key)
if info:
return info['display']
def get_workflow_info(self, key):
enum = self.app.enum
if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
return self.batch_handler.ordering_workflow_info(key)
elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
return self.batch_handler.receiving_workflow_info(key)
elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING:
return self.batch_handler.costing_workflow_info(key)
raise ValueError("unknown batch mode")
def render_store(self, batch, field): def render_store(self, batch, field):
store = batch.store store = batch.store
if not store: if not store:
@ -515,10 +759,12 @@ class PurchasingBatchView(BatchMasterView):
def get_batch_kwargs(self, batch, **kwargs): def get_batch_kwargs(self, batch, **kwargs):
kwargs = super().get_batch_kwargs(batch, **kwargs) kwargs = super().get_batch_kwargs(batch, **kwargs)
model = self.model model = self.app.model
kwargs['mode'] = self.batch_mode kwargs['mode'] = self.batch_mode
kwargs['workflow'] = self.request.POST['workflow']
kwargs['truck_dump'] = batch.truck_dump kwargs['truck_dump'] = batch.truck_dump
kwargs['order_parser_key'] = batch.order_parser_key
kwargs['invoice_parser_key'] = batch.invoice_parser_key kwargs['invoice_parser_key'] = batch.invoice_parser_key
if batch.store: if batch.store:
@ -536,6 +782,11 @@ class PurchasingBatchView(BatchMasterView):
elif batch.vendor_uuid: elif batch.vendor_uuid:
kwargs['vendor_uuid'] = batch.vendor_uuid kwargs['vendor_uuid'] = batch.vendor_uuid
# must pull vendor from URL if it was not in form data
if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs:
if 'vendor_uuid' in self.request.matchdict:
kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid']
if batch.department: if batch.department:
kwargs['department'] = batch.department kwargs['department'] = batch.department
elif batch.department_uuid: elif batch.department_uuid:
@ -919,6 +1170,25 @@ class PurchasingBatchView(BatchMasterView):
# # otherwise just view batch again # # otherwise just view batch again
# return self.get_action_url('view', batch) # return self.get_action_url('view', batch)
@classmethod
def defaults(cls, config):
cls._purchase_batch_defaults(config)
cls._batch_defaults(config)
cls._defaults(config)
@classmethod
def _purchase_batch_defaults(cls, config):
route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix()
permission_prefix = cls.get_permission_prefix()
# new batch using workflow X
config.add_route(f'{route_prefix}.create_workflow',
f'{url_prefix}/new/{{workflow_key}}/{{vendor_uuid}}')
config.add_view(cls, attr='create',
route_name=f'{route_prefix}.create_workflow',
permission=f'{permission_prefix}.create')
class NewProduct(colander.Schema): class NewProduct(colander.Schema):

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar # Copyright © 2010-2024 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -28,14 +28,10 @@ import os
import json import json
import openpyxl import openpyxl
from sqlalchemy import orm
from rattail.db import model, api
from rattail.core import Object from rattail.core import Object
from rattail.time import localtime
from webhelpers2.html import tags
from tailbone.db import Session
from tailbone.views.purchasing import PurchasingBatchView from tailbone.views.purchasing import PurchasingBatchView
@ -51,6 +47,8 @@ class OrderingBatchView(PurchasingBatchView):
rows_editable = True rows_editable = True
has_worksheet = True has_worksheet = True
default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html' default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html'
downloadable = True
configurable = True
labels = { labels = {
'po_total_calculated': "PO Total", 'po_total_calculated': "PO Total",
@ -59,9 +57,14 @@ class OrderingBatchView(PurchasingBatchView):
form_fields = [ form_fields = [
'id', 'id',
'store', 'store',
'buyer',
'vendor', 'vendor',
'description',
'workflow',
'order_file',
'order_parser_key',
'buyer',
'department', 'department',
'params',
'purchase', 'purchase',
'vendor_email', 'vendor_email',
'vendor_fax', 'vendor_fax',
@ -132,15 +135,26 @@ class OrderingBatchView(PurchasingBatchView):
return self.enum.PURCHASE_BATCH_MODE_ORDERING return self.enum.PURCHASE_BATCH_MODE_ORDERING
def configure_form(self, f): def configure_form(self, f):
super(OrderingBatchView, self).configure_form(f) super().configure_form(f)
batch = f.model_instance batch = f.model_instance
workflow = self.request.matchdict.get('workflow_key')
# purchase # purchase
if self.creating or not batch.executed or not batch.purchase: if self.creating or not batch.executed or not batch.purchase:
f.remove_field('purchase') f.remove_field('purchase')
# now that all fields are setup, some final tweaks based on workflow
if self.creating and workflow:
if workflow == 'from_scratch':
f.remove('order_file',
'order_parser_key')
elif workflow == 'from_file':
f.set_required('order_file')
def get_batch_kwargs(self, batch, **kwargs): def get_batch_kwargs(self, batch, **kwargs):
kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, **kwargs) kwargs = super().get_batch_kwargs(batch, **kwargs)
kwargs['ship_method'] = batch.ship_method kwargs['ship_method'] = batch.ship_method
kwargs['notes_to_vendor'] = batch.notes_to_vendor kwargs['notes_to_vendor'] = batch.notes_to_vendor
return kwargs return kwargs
@ -155,7 +169,7 @@ class OrderingBatchView(PurchasingBatchView):
* ``cases_ordered`` * ``cases_ordered``
* ``units_ordered`` * ``units_ordered``
""" """
super(OrderingBatchView, self).configure_row_form(f) super().configure_row_form(f)
# when editing, only certain fields should allow changes # when editing, only certain fields should allow changes
if self.editing: if self.editing:
@ -308,7 +322,7 @@ class OrderingBatchView(PurchasingBatchView):
title = self.get_instance_title(batch) title = self.get_instance_title(batch)
order_date = batch.date_ordered order_date = batch.date_ordered
if not order_date: if not order_date:
order_date = localtime(self.rattail_config).date() order_date = self.app.today()
return self.render_to_response('worksheet', { return self.render_to_response('worksheet', {
'batch': batch, 'batch': batch,
@ -369,6 +383,7 @@ class OrderingBatchView(PurchasingBatchView):
of being updated. If a matching row is not found, it will not be of being updated. If a matching row is not found, it will not be
created. created.
""" """
model = self.app.model
batch = self.get_instance() batch = self.get_instance()
try: try:
@ -478,13 +493,75 @@ class OrderingBatchView(PurchasingBatchView):
return self.file_response(path) return self.file_response(path)
def get_execute_success_url(self, batch, result, **kwargs): def get_execute_success_url(self, batch, result, **kwargs):
model = self.app.model
if isinstance(result, model.Purchase): if isinstance(result, model.Purchase):
return self.request.route_url('purchases.view', uuid=result.uuid) return self.request.route_url('purchases.view', uuid=result.uuid)
return super(OrderingBatchView, self).get_execute_success_url(batch, result, **kwargs) return super().get_execute_success_url(batch, result, **kwargs)
def configure_get_simple_settings(self):
return [
# workflows
{'section': 'rattail.batch',
'option': 'purchase.allow_ordering_from_scratch',
'type': bool,
'default': True},
{'section': 'rattail.batch',
'option': 'purchase.allow_ordering_from_file',
'type': bool,
'default': True},
# vendors
{'section': 'rattail.batch',
'option': 'purchase.allow_ordering_any_vendor',
'type': bool,
'default': True,
},
]
def configure_get_context(self):
context = super().configure_get_context()
vendor_handler = self.app.get_vendor_handler()
Parsers = vendor_handler.get_all_order_parsers()
Supported = vendor_handler.get_supported_order_parsers()
context['order_parsers'] = Parsers
context['order_parsers_data'] = dict([(Parser.key, Parser in Supported)
for Parser in Parsers])
return context
def configure_gather_settings(self, data):
settings = super().configure_gather_settings(data)
vendor_handler = self.app.get_vendor_handler()
supported = []
for Parser in vendor_handler.get_all_order_parsers():
name = f'order_parser_{Parser.key}'
if data.get(name) == 'true':
supported.append(Parser.key)
settings.append({'name': 'rattail.vendors.supported_order_parsers',
'value': ', '.join(supported)})
return settings
def configure_remove_settings(self):
super().configure_remove_settings()
names = [
'rattail.vendors.supported_order_parsers',
]
# nb. using thread-local session here; we do not use
# self.Session b/c it may not point to Rattail
session = Session()
for name in names:
self.app.delete_setting(session, name)
@classmethod @classmethod
def defaults(cls, config): def defaults(cls, config):
cls._ordering_defaults(config) cls._ordering_defaults(config)
cls._purchase_batch_defaults(config)
cls._batch_defaults(config) cls._batch_defaults(config)
cls._defaults(config) cls._defaults(config)

View file

@ -108,7 +108,7 @@ class ReceivingBatchView(PurchasingBatchView):
'store', 'store',
'vendor', 'vendor',
'description', 'description',
'receiving_workflow', 'workflow',
'truck_dump', 'truck_dump',
'truck_dump_children_first', 'truck_dump_children_first',
'truck_dump_children', 'truck_dump_children',
@ -235,75 +235,9 @@ class ReceivingBatchView(PurchasingBatchView):
if not self.handler.allow_truck_dump_receiving(): if not self.handler.allow_truck_dump_receiving():
g.remove('truck_dump') g.remove('truck_dump')
def create(self, form=None, **kwargs): def get_supported_vendors(self):
""" """ """
Custom view for creating a new receiving batch. We split the process vendor_handler = self.app.get_vendor_handler()
into two steps, 1) choose and 2) create. This is because the specific
form details for creating a batch will depend on which "type" of batch
creation is to be done, and it's much easier to keep conditional logic
for that in the server instead of client-side etc.
See also
:meth:`tailbone.views.purchasing.costing:CostingBatchView.create()`
which uses similar logic.
"""
model = self.model
route_prefix = self.get_route_prefix()
workflows = self.handler.supported_receiving_workflows()
valid_workflows = [workflow['workflow_key']
for workflow in workflows]
# if user has already identified their desired workflow, then we can
# just farm out to the default logic. we will of course configure our
# form differently, based on workflow, but this create() method at
# least will not need customization for that.
if self.request.matched_route.name.endswith('create_workflow'):
redirect = self.redirect(self.request.route_url('{}.create'.format(route_prefix)))
# however we do have one more thing to check - the workflow
# requested must of course be valid!
workflow_key = self.request.matchdict['workflow_key']
if workflow_key not in valid_workflows:
self.request.session.flash(
"Not a supported workflow: {}".format(workflow_key),
'error')
raise redirect
# also, we require vendor to be correctly identified. if
# someone e.g. navigates to a URL by accident etc. we want
# to gracefully handle and redirect
uuid = self.request.matchdict['vendor_uuid']
vendor = self.Session.get(model.Vendor, uuid)
if not vendor:
self.request.session.flash("Invalid vendor selection. "
"Please choose an existing vendor.",
'warning')
raise redirect
# okay now do the normal thing, per workflow
return super().create(**kwargs)
# on the other hand, if caller provided a form, that means we are in
# the middle of some other custom workflow, e.g. "add child to truck
# dump parent" or some such. in which case we also defer to the normal
# logic, so as to not interfere with that.
if form:
return super().create(form=form, **kwargs)
# okay, at this point we need the user to select a vendor and workflow
self.creating = True
context = {}
# form to accept user choice of vendor/workflow
schema = NewReceivingBatch().bind(valid_workflows=valid_workflows)
form = forms.Form(schema=schema, request=self.request)
# configure vendor field
app = self.get_rattail_app()
vendor_handler = app.get_vendor_handler()
if self.rattail_config.getbool('rattail.batch', 'purchase.supported_vendors_only'):
# only show vendors for which we have dedicated invoice parsers
vendors = {} vendors = {}
for parser in self.batch_handler.get_supported_invoice_parsers(): for parser in self.batch_handler.get_supported_invoice_parsers():
if parser.vendor_key: if parser.vendor_key:
@ -312,58 +246,7 @@ class ReceivingBatchView(PurchasingBatchView):
if vendor: if vendor:
vendors[vendor.uuid] = vendor vendors[vendor.uuid] = vendor
vendors = sorted(vendors.values(), key=lambda v: v.name) vendors = sorted(vendors.values(), key=lambda v: v.name)
vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor)) return vendors
for vendor in vendors]
form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
else:
# user may choose *any* available vendor
use_dropdown = vendor_handler.choice_uses_dropdown()
if use_dropdown:
vendors = self.Session.query(model.Vendor)\
.order_by(model.Vendor.id)\
.all()
vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name))
for vendor in vendors]
form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
if len(vendors) == 1:
form.set_default('vendor', vendors[0].uuid)
else:
vendor_display = ""
if self.request.method == 'POST':
if self.request.POST.get('vendor'):
vendor = self.Session.get(model.Vendor, self.request.POST['vendor'])
if vendor:
vendor_display = str(vendor)
vendors_url = self.request.route_url('vendors.autocomplete')
form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget(
field_display=vendor_display, service_url=vendors_url))
form.set_validator('vendor', self.valid_vendor_uuid)
# configure workflow field
values = [(workflow['workflow_key'], workflow['display'])
for workflow in workflows]
form.set_widget('workflow',
dfwidget.SelectWidget(values=values))
if len(workflows) == 1:
form.set_default('workflow', workflows[0]['workflow_key'])
form.submit_label = "Continue"
form.cancel_url = self.get_index_url()
# if form validates, that means user has chosen a creation type, so we
# just redirect to the appropriate "new batch of type X" page
if form.validate():
workflow_key = form.validated['workflow']
vendor_uuid = form.validated['vendor']
url = self.request.route_url('{}.create_workflow'.format(route_prefix),
workflow_key=workflow_key,
vendor_uuid=vendor_uuid)
raise self.redirect(url)
context['form'] = form
if hasattr(form, 'make_deform_form'):
context['dform'] = form.make_deform_form()
return self.render_to_response('create', context)
def row_deletable(self, row): def row_deletable(self, row):
@ -404,13 +287,7 @@ class ReceivingBatchView(PurchasingBatchView):
# cancel should take us back to choosing a workflow # cancel should take us back to choosing a workflow
f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) f.cancel_url = self.request.route_url('{}.create'.format(route_prefix))
# receiving_workflow # TODO: remove this
if self.creating and workflow:
f.set_readonly('receiving_workflow')
f.set_renderer('receiving_workflow', self.render_receiving_workflow)
else:
f.remove('receiving_workflow')
# batch_type # batch_type
if self.creating: if self.creating:
f.set_widget('batch_type', dfwidget.HiddenWidget()) f.set_widget('batch_type', dfwidget.HiddenWidget())
@ -525,7 +402,7 @@ class ReceivingBatchView(PurchasingBatchView):
# multiple invoice files (if applicable) # multiple invoice files (if applicable)
if (not self.creating if (not self.creating
and batch.get_param('receiving_workflow') == 'from_multi_invoice'): and batch.get_param('workflow') == 'from_multi_invoice'):
if 'invoice_files' not in f: if 'invoice_files' not in f:
f.insert_before('invoice_file', 'invoice_files') f.insert_before('invoice_file', 'invoice_files')
@ -624,12 +501,6 @@ class ReceivingBatchView(PurchasingBatchView):
items.append(HTML.tag('li', c=[link])) items.append(HTML.tag('li', c=[link]))
return HTML.tag('ul', c=items) return HTML.tag('ul', c=items)
def render_receiving_workflow(self, batch, field):
key = self.request.matchdict['workflow_key']
info = self.handler.receiving_workflow_info(key)
if info:
return info['display']
def get_visible_params(self, batch): def get_visible_params(self, batch):
params = super().get_visible_params(batch) params = super().get_visible_params(batch)
@ -654,42 +525,40 @@ class ReceivingBatchView(PurchasingBatchView):
def get_batch_kwargs(self, batch, **kwargs): def get_batch_kwargs(self, batch, **kwargs):
kwargs = super().get_batch_kwargs(batch, **kwargs) kwargs = super().get_batch_kwargs(batch, **kwargs)
batch_type = self.request.POST['batch_type']
# must pull vendor from URL if it was not in form data # must pull vendor from URL if it was not in form data
if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs: if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs:
if 'vendor_uuid' in self.request.matchdict: if 'vendor_uuid' in self.request.matchdict:
kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid'] kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid']
# TODO: ugh should just have workflow and no batch_type workflow = kwargs['workflow']
kwargs['receiving_workflow'] = batch_type if workflow == 'from_scratch':
if batch_type == 'from_scratch':
kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch', None)
kwargs.pop('truck_dump_batch_uuid', None) kwargs.pop('truck_dump_batch_uuid', None)
elif batch_type == 'from_invoice': elif workflow == 'from_invoice':
pass pass
elif batch_type == 'from_multi_invoice': elif workflow == 'from_multi_invoice':
pass pass
elif batch_type == 'from_po': elif workflow == 'from_po':
# TODO: how to best handle this field? this doesn't seem flexible # TODO: how to best handle this field? this doesn't seem flexible
kwargs['purchase_key'] = batch.purchase_uuid kwargs['purchase_key'] = batch.purchase_uuid
elif batch_type == 'from_po_with_invoice': elif workflow == 'from_po_with_invoice':
# TODO: how to best handle this field? this doesn't seem flexible # TODO: how to best handle this field? this doesn't seem flexible
kwargs['purchase_key'] = batch.purchase_uuid kwargs['purchase_key'] = batch.purchase_uuid
elif batch_type == 'truck_dump_children_first': elif workflow == 'truck_dump_children_first':
kwargs['truck_dump'] = True kwargs['truck_dump'] = True
kwargs['truck_dump_children_first'] = True kwargs['truck_dump_children_first'] = True
kwargs['order_quantities_known'] = True kwargs['order_quantities_known'] = True
# TODO: this makes sense in some cases, but all? # TODO: this makes sense in some cases, but all?
# (should just omit that field when not relevant) # (should just omit that field when not relevant)
kwargs['date_ordered'] = None kwargs['date_ordered'] = None
elif batch_type == 'truck_dump_children_last': elif workflow == 'truck_dump_children_last':
kwargs['truck_dump'] = True kwargs['truck_dump'] = True
kwargs['truck_dump_ready'] = True kwargs['truck_dump_ready'] = True
# TODO: this makes sense in some cases, but all? # TODO: this makes sense in some cases, but all?
# (should just omit that field when not relevant) # (should just omit that field when not relevant)
kwargs['date_ordered'] = None kwargs['date_ordered'] = None
elif batch_type.startswith('truck_dump_child'): elif workflow.startswith('truck_dump_child'):
truck_dump = self.get_instance() truck_dump = self.get_instance()
kwargs['store'] = truck_dump.store kwargs['store'] = truck_dump.store
kwargs['vendor'] = truck_dump.vendor kwargs['vendor'] = truck_dump.vendor
@ -1986,6 +1855,12 @@ class ReceivingBatchView(PurchasingBatchView):
'type': bool}, 'type': bool},
# vendors # vendors
{'section': 'rattail.batch',
'option': 'purchase.allow_receiving_any_vendor',
'type': bool},
# TODO: deprecated; can remove this once all live config
# is updated. but for now it remains so this setting is
# auto-deleted
{'section': 'rattail.batch', {'section': 'rattail.batch',
'option': 'purchase.supported_vendors_only', 'option': 'purchase.supported_vendors_only',
'type': bool}, 'type': bool},
@ -2036,6 +1911,7 @@ class ReceivingBatchView(PurchasingBatchView):
@classmethod @classmethod
def defaults(cls, config): def defaults(cls, config):
cls._receiving_defaults(config) cls._receiving_defaults(config)
cls._purchase_batch_defaults(config)
cls._batch_defaults(config) cls._batch_defaults(config)
cls._defaults(config) cls._defaults(config)
@ -2043,17 +1919,11 @@ class ReceivingBatchView(PurchasingBatchView):
def _receiving_defaults(cls, config): def _receiving_defaults(cls, config):
rattail_config = config.registry.settings.get('rattail_config') rattail_config = config.registry.settings.get('rattail_config')
route_prefix = cls.get_route_prefix() route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix()
instance_url_prefix = cls.get_instance_url_prefix() instance_url_prefix = cls.get_instance_url_prefix()
model_key = cls.get_model_key() model_key = cls.get_model_key()
model_title = cls.get_model_title() model_title = cls.get_model_title()
permission_prefix = cls.get_permission_prefix() permission_prefix = cls.get_permission_prefix()
# new receiving batch using workflow X
config.add_route('{}.create_workflow'.format(route_prefix), '{}/new/{{workflow_key}}/{{vendor_uuid}}'.format(url_prefix))
config.add_view(cls, attr='create', route_name='{}.create_workflow'.format(route_prefix),
permission='{}.create'.format(permission_prefix))
# row-level receiving # row-level receiving
config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix)) config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix))
config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix), config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix),
@ -2106,33 +1976,6 @@ class ReceivingBatchView(PurchasingBatchView):
permission='{}.auto_receive'.format(permission_prefix)) permission='{}.auto_receive'.format(permission_prefix))
@colander.deferred
def valid_workflow(node, kw):
"""
Deferred validator for ``workflow`` field, for new batches.
"""
valid_workflows = kw['valid_workflows']
def validate(node, value):
# we just need to provide possible values, and let stock validator
# handle the rest
oneof = colander.OneOf(valid_workflows)
return oneof(node, value)
return validate
class NewReceivingBatch(colander.Schema):
"""
Schema for choosing which "type" of new receiving batch should be created.
"""
vendor = colander.SchemaNode(colander.String(),
label="Vendor")
workflow = colander.SchemaNode(colander.String(),
validator=valid_workflow)
class ReceiveRowForm(colander.MappingSchema): class ReceiveRowForm(colander.MappingSchema):
mode = colander.SchemaNode(colander.String(), mode = colander.SchemaNode(colander.String(),