Compare commits

...

32 commits

Author SHA1 Message Date
e150453801 fix: add startup hack for tempmon DB model 2025-03-05 10:34:52 -06:00
e2582ffec5 bump: version 0.22.6 → 0.22.7 2025-02-19 10:33:39 -06:00
a6508154cb docs: update intersphinx doc links per server migration 2025-02-18 12:13:28 -06:00
7348eec671 fix: stop using old config for logo image url on login page 2025-02-18 11:16:23 -06:00
4221fa50dd fix: fix warning msg for deprecated Grid param 2025-02-14 11:37:21 -06:00
e0ebd43e7a bump: version 0.22.5 → 0.22.6 2025-02-01 15:18:12 -06:00
c7ee9de9eb fix: register vue3 form component for products -> make batch 2024-12-28 16:43:22 -06:00
950db697a0 bump: version 0.22.4 → 0.22.5 2024-12-16 12:46:45 -06:00
358b3b75a5 fix: whoops this is latest rattail 2024-12-10 13:05:32 -06:00
7e559a01b3 fix: require newer rattail lib 2024-12-10 12:52:49 -06:00
23bdde245a fix: require newer wuttaweb 2024-12-10 12:34:34 -06:00
2c269b640b fix: let caller request safe HTML literal for rendered grid table
mostly just for convenience
2024-12-01 18:12:30 -06:00
Lance Edgar
f1c8ffedda bump: version 0.22.3 → 0.22.4 2024-11-22 12:57:04 -06:00
Lance Edgar
aace6033c5 fix: avoid error in product search for duplicated key 2024-11-20 20:17:21 -06:00
Lance Edgar
7171c7fb06 fix: use vmodel for confirm password widget input
since previously this did not work at all for butterball (vue3 +
oruga) - although it was never clear why per se..

Refs: #1
2024-11-19 20:53:23 -06:00
Lance Edgar
993f066f2c bump: version 0.22.2 → 0.22.3 2024-11-19 15:45:37 -06:00
Lance Edgar
980031f524 fix: avoid error for trainwreck query when not a customer
when viewing a person's profile, who does not have a customer record,
the trainwreck query can't really return anything since it normally
should be matching on the customer ID
2024-11-18 14:59:50 -06:00
Lance Edgar
bcaf0d08bc bump: version 0.22.1 → 0.22.2 2024-11-18 14:08:10 -06:00
Lance Edgar
ac439c949b fix: use local/custom enum for continuum operations
since we can't rely on that existing in rattail proper, due to it not
always having sqlalchemy
2024-11-12 19:45:24 -06:00
Lance Edgar
20b3f87dbe fix: add basic master view for Product Costs 2024-11-12 18:30:50 -06:00
Lance Edgar
9e55717041 fix: show continuum operation type when viewing version history 2024-11-12 18:28:41 -06:00
Lance Edgar
772b6610cb fix: always define app attr for ViewSupplement 2024-11-12 18:26:36 -06:00
Lance Edgar
3f27f626df fix: avoid deprecated import 2024-11-10 19:16:45 -06:00
Lance Edgar
29743e70b7 bump: version 0.22.0 → 0.22.1 2024-11-02 16:56:28 -05:00
Lance Edgar
54220601ed fix: fix submit button for running problem report
esp. on Chrome(-based) browsers
2024-11-01 17:47:46 -05:00
Lance Edgar
9a6f8970ae fix: avoid deprecated grid method 2024-10-23 09:46:14 -05:00
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
Lance Edgar
c6365f2631 bump: version 0.21.10 → 0.21.11 2024-10-03 09:05:46 -05:00
Lance Edgar
d520f64fee fix: custom method for adding grid action
since for now, we are using custom grid action class
2024-10-03 08:56:52 -05:00
Lance Edgar
2308d2e240 fix: become/stop root should redirect to previous url
for default theme; butterball already did that
2024-09-16 12:55:58 -05:00
25 changed files with 766 additions and 314 deletions

View file

@ -5,6 +5,75 @@ 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/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.22.7 (2025-02-19)
### Fix
- stop using old config for logo image url on login page
- fix warning msg for deprecated Grid param
## v0.22.6 (2025-02-01)
### Fix
- register vue3 form component for products -> make batch
## v0.22.5 (2024-12-16)
### Fix
- whoops this is latest rattail
- require newer rattail lib
- require newer wuttaweb
- let caller request safe HTML literal for rendered grid table
## v0.22.4 (2024-11-22)
### Fix
- avoid error in product search for duplicated key
- use vmodel for confirm password widget input
## v0.22.3 (2024-11-19)
### Fix
- avoid error for trainwreck query when not a customer
## v0.22.2 (2024-11-18)
### Fix
- use local/custom enum for continuum operations
- add basic master view for Product Costs
- show continuum operation type when viewing version history
- always define `app` attr for ViewSupplement
- avoid deprecated import
## v0.22.1 (2024-11-02)
### Fix
- fix submit button for running problem report
- avoid deprecated grid method
## 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)
### Fix
- custom method for adding grid action
- become/stop root should redirect to previous url
## v0.21.10 (2024-09-15)
### Fix

View file

@ -27,10 +27,10 @@ templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
intersphinx_mapping = {
'rattail': ('https://rattailproject.org/docs/rattail/', None),
'rattail': ('https://docs.wuttaproject.org/rattail/', None),
'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None),
'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None),
'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None),
}
# allow todo entries to show up

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "Tailbone"
version = "0.21.10"
version = "0.22.7"
description = "Backoffice Web Application for Rattail"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@ -53,13 +53,13 @@ dependencies = [
"pyramid_mako",
"pyramid_retry",
"pyramid_tm",
"rattail[db,bouncer]>=0.18.5",
"rattail[db,bouncer]>=0.20.1",
"sa-filters",
"simplejson",
"transaction",
"waitress",
"WebHelpers2",
"WuttaWeb>=0.14.0",
"WuttaWeb>=0.21.0",
"zope.sqlalchemy>=1.5",
]

View file

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

View file

@ -26,7 +26,6 @@ Tailbone Web API - Master View
import json
from rattail.config import parse_bool
from rattail.db.util import get_fieldnames
from cornice import resource, Service
@ -185,7 +184,7 @@ class APIMasterView(APIView):
if sortcol:
spec = {
'field': sortcol.field_name,
'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc',
'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc',
}
if sortcol.model_name:
spec['model'] = sortcol.model_name

View file

@ -62,6 +62,17 @@ def make_rattail_config(settings):
# nb. this is for compaibility with wuttaweb
settings['wutta_config'] = rattail_config
# must import all sqlalchemy models before things get rolling,
# otherwise can have errors about continuum TransactionMeta class
# not yet mapped, when relevant pages are first requested...
# cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models
# hat tip to https://stackoverflow.com/a/59241485
if getattr(rattail_config, 'tempmon_engine', None):
from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession
tempmon_session = TempmonSession()
tempmon_session.query(tempmon_model.Appliance).first()
tempmon_session.close()
# configure database sessions
if hasattr(rattail_config, 'appdb_engine'):
tailbone.db.Session.configure(bind=rattail_config.appdb_engine)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
@ -270,9 +270,21 @@ class VersionDiff(Diff):
for field in self.fields:
values[field] = {'before': self.render_old_value(field),
'after': self.render_new_value(field)}
operation = None
if self.version.operation_type == continuum.Operation.INSERT:
operation = 'INSERT'
elif self.version.operation_type == continuum.Operation.UPDATE:
operation = 'UPDATE'
elif self.version.operation_type == continuum.Operation.DELETE:
operation = 'DELETE'
else:
operation = self.version.operation_type
return {
'key': id(self.version),
'model_title': self.title,
'operation': operation,
'diff_class': self.nature,
'fields': self.fields,
'values': values,

View file

@ -235,7 +235,7 @@ class Grid(WuttaGrid):
if 'pageable' in kwargs:
warnings.warn("pageable param is deprecated for Grid(); "
"please use vue_tagname param instead",
"please use paginated param instead",
DeprecationWarning, stacklevel=2)
kwargs.setdefault('paginated', kwargs.pop('pageable'))
@ -1223,6 +1223,7 @@ class Grid(WuttaGrid):
def render_table_element(self, template='/grids/b-table.mako',
data_prop='gridData', empty_labels=False,
literal=False,
**kwargs):
"""
This is intended for ad-hoc "small" grids with static data. Renders
@ -1239,7 +1240,10 @@ class Grid(WuttaGrid):
if context['paginated']:
context.setdefault('per_page', 20)
context['view_click_handler'] = self.get_view_click_handler()
return render(template, context)
result = render(template, context)
if literal:
result = HTML.literal(result)
return result
def get_view_click_handler(self):
""" """
@ -1544,6 +1548,11 @@ class Grid(WuttaGrid):
self._table_data = results
return self._table_data
# TODO: remove this when we use upstream GridAction
def add_action(self, key, **kwargs):
""" """
self.actions.append(GridAction(self.request, key, **kwargs))
def set_action_urls(self, row, rowobj, i):
"""
Pre-generate all action URLs for the given data row. Meant for use

View file

@ -394,6 +394,11 @@ class TailboneMenuHandler(WuttaMenuHandler):
'route': 'products',
'perm': 'products.list',
},
{
'title': "Product Costs",
'route': 'product_costs',
'perm': 'product_costs.list',
},
{
'title': "Departments",
'route': 'departments',
@ -451,6 +456,11 @@ class TailboneMenuHandler(WuttaMenuHandler):
'route': 'vendors',
'perm': 'vendors.list',
},
{
'title': "Product Costs",
'route': 'product_costs',
'perm': 'product_costs.list',
},
{'type': 'sep'},
{
'title': "Ordering",

View file

@ -632,9 +632,23 @@
% endif
<div class="navbar-dropdown">
% if request.is_root:
${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')}
${h.form(url('stop_root'), ref='stopBeingRootForm')}
${h.csrf_token(request)}
<input type="hidden" name="referrer" value="${request.current_route_url()}" />
<a @click="$refs.stopBeingRootForm.submit()"
class="navbar-item root-user">
Stop being root
</a>
${h.end_form()}
% elif request.is_admin:
${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')}
${h.form(url('become_root'), ref='startBeingRootForm')}
${h.csrf_token(request)}
<input type="hidden" name="referrer" value="${request.current_route_url()}" />
<a @click="$refs.startBeingRootForm.submit()"
class="navbar-item root-user">
Become root
</a>
${h.end_form()}
% endif
% if messaging_enabled:
${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')}

View file

@ -1,6 +1,7 @@
<div i18n:domain="deform" tal:omit-tag=""
tal:define="oid oid|field.oid;
name name|field.name;
vmodel vmodel|'field_model_' + name;
css_class css_class|field.widget.css_class;
style style|field.widget.style;">
@ -8,7 +9,7 @@
${field.start_mapping()}
<b-input type="password"
name="${name}"
value="${field.widget.redisplay and cstruct or ''}"
v-model="${vmodel}"
tal:attributes="class string: form-control ${css_class or ''};
style style;
attributes|field.widget.attributes|{};"
@ -18,7 +19,6 @@
</b-input>
<b-input type="password"
name="${name}-confirm"
value="${field.widget.redisplay and confirm or ''}"
tal:attributes="class string: form-control ${css_class or ''};
style style;
confirm_attributes|field.widget.confirm_attributes|{};"

View file

@ -196,6 +196,7 @@
<p class="block has-text-weight-bold">
{{ version.model_title }}
({{ version.operation }})
</p>
<table class="diff monospace is-size-7"

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

@ -55,19 +55,20 @@
</%def>
<%def name="render_form_template()">
<script type="text/x-template" id="${form.component}-template">
<script type="text/x-template" id="${form.vue_tagname}-template">
${self.render_form_innards()}
</script>
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<% request.register_component(form.vue_tagname, form.vue_component) %>
<script>
## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako)
let ${form.vue_component} = {
template: '#${form.component}-template',
template: '#${form.vue_tagname}-template',
methods: {
## TODO: deprecate / remove the latter option here

View file

@ -69,12 +69,12 @@
<h3 class="block is-size-3">Vendors</h3>
<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-checkbox name="rattail.batch.purchase.supported_vendors_only"
v-model="simpleSettings['rattail.batch.purchase.supported_vendors_only']"
<b-field message="If not set, user must choose a &quot;supported&quot; vendor.">
<b-checkbox name="rattail.batch.purchase.allow_receiving_any_vendor"
v-model="simpleSettings['rattail.batch.purchase.allow_receiving_any_vendor']"
native-value="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-field>

View file

@ -45,11 +45,10 @@
<b-button @click="runReportShowDialog = false">
Cancel
</b-button>
${h.form(master.get_action_url('execute', instance))}
${h.form(master.get_action_url('execute', instance), **{'@submit': 'runReportSubmitting = true'})}
${h.csrf_token(request)}
<b-button type="is-primary"
native-type="submit"
@click="runReportSubmitting = true"
:disabled="runReportSubmitting"
icon-pack="fas"
icon-left="arrow-circle-right">

View file

@ -909,7 +909,7 @@
${h.form(url('stop_root'), ref='stopBeingRootForm')}
${h.csrf_token(request)}
<input type="hidden" name="referrer" value="${request.current_route_url()}" />
<a @click="stopBeingRoot()"
<a @click="$refs.stopBeingRootForm.submit()"
class="navbar-item has-background-danger has-text-white">
Stop being root
</a>
@ -918,7 +918,7 @@
${h.form(url('become_root'), ref='startBeingRootForm')}
${h.csrf_token(request)}
<input type="hidden" name="referrer" value="${request.current_route_url()}" />
<a @click="startBeingRoot()"
<a @click="$refs.startBeingRootForm.submit()"
class="navbar-item has-background-danger has-text-white">
Become root
</a>
@ -1103,18 +1103,6 @@
const key = 'menu_' + hash + '_shown'
this[key] = !this[key]
},
% if request.is_admin:
startBeingRoot() {
this.$refs.startBeingRootForm.submit()
},
stopBeingRoot() {
this.$refs.stopBeingRootForm.submit()
},
% endif
},
}

View file

@ -44,28 +44,6 @@ class UserLogin(colander.MappingSchema):
widget=dfwidget.PasswordWidget())
@colander.deferred
def current_password_correct(node, kw):
request = kw['request']
app = request.rattail_config.get_app()
auth = app.get_auth_handler()
user = kw['user']
def validate(node, value):
if not auth.authenticate_user(Session(), user.username, value):
raise colander.Invalid(node, "The password is incorrect")
return validate
class ChangePassword(colander.MappingSchema):
current_password = colander.SchemaNode(colander.String(),
widget=dfwidget.PasswordWidget(),
validator=current_password_correct)
new_password = colander.SchemaNode(colander.String(),
widget=dfwidget.CheckedPasswordWidget())
class AuthenticationView(View):
def forbidden(self):
@ -116,10 +94,6 @@ class AuthenticationView(View):
else:
self.request.session.flash("Invalid username or password", 'error')
image_url = self.rattail_config.get(
'tailbone', 'main_image_url',
default=self.request.static_url('tailbone:static/img/home_logo.png'))
# nb. hacky..but necessary, to add the refs, for autofocus
# (also add key handler, so ENTER acts like TAB)
dform = form.make_deform_form()
@ -132,7 +106,6 @@ class AuthenticationView(View):
return {
'form': form,
'referrer': referrer,
'image_url': image_url,
'index_title': app.get_node_title(),
'help_url': global_help_url(self.rattail_config),
}
@ -181,7 +154,23 @@ class AuthenticationView(View):
self.request.user))
return self.redirect(self.request.get_referrer())
schema = ChangePassword().bind(user=self.request.user, request=self.request)
def check_user_password(node, value):
auth = self.app.get_auth_handler()
user = self.request.user
if not auth.check_user_password(user, value):
node.raise_invalid("The password is incorrect")
schema = colander.Schema()
schema.add(colander.SchemaNode(colander.String(),
name='current_password',
widget=dfwidget.PasswordWidget(),
validator=check_user_password))
schema.add(colander.SchemaNode(colander.String(),
name='new_password',
widget=dfwidget.CheckedPasswordWidget()))
form = forms.Form(schema=schema, request=self.request)
if form.validate():
auth = self.app.get_auth_handler()

View file

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

View file

@ -412,7 +412,7 @@ class MasterView(View):
session = self.Session()
kwargs.setdefault('paginated', False)
grid = self.make_grid(session=session, **kwargs)
return grid.make_visible_data()
return grid.get_visible_data()
def get_grid_columns(self):
"""
@ -903,7 +903,7 @@ class MasterView(View):
def valid_employee_uuid(self, node, value):
if value:
model = self.model
model = self.app.model
employee = self.Session.get(model.Employee, value)
if not employee:
node.raise_invalid("Employee not found")
@ -939,7 +939,7 @@ class MasterView(View):
def valid_vendor_uuid(self, node, value):
if value:
model = self.model
model = self.app.model
vendor = self.Session.get(model.Vendor, value)
if not vendor:
node.raise_invalid("Vendor not found")
@ -1382,7 +1382,7 @@ class MasterView(View):
return classes
def make_revisions_grid(self, obj, empty_data=False):
model = self.model
model = self.app.model
route_prefix = self.get_route_prefix()
row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version',
uuid=obj.uuid,
@ -1710,7 +1710,7 @@ class MasterView(View):
kwargs.setdefault('paginated', False)
kwargs.setdefault('sortable', sort)
grid = self.make_row_grid(session=session, **kwargs)
return grid.make_visible_data()
return grid.get_visible_data()
@classmethod
def get_row_url_prefix(cls):
@ -2153,7 +2153,7 @@ class MasterView(View):
Thread target for executing an object.
"""
app = self.get_rattail_app()
model = self.model
model = self.app.model
session = app.make_session()
obj = self.get_instance_for_key(key, session)
user = session.get(model.User, user_uuid)
@ -2594,7 +2594,7 @@ class MasterView(View):
"""
# nb. self.Session may differ, so use tailbone.db.Session
session = Session()
model = self.model
model = self.app.model
route_prefix = self.get_route_prefix()
info = session.query(model.TailbonePageHelp)\
@ -2617,7 +2617,7 @@ class MasterView(View):
"""
# nb. self.Session may differ, so use tailbone.db.Session
session = Session()
model = self.model
model = self.app.model
route_prefix = self.get_route_prefix()
info = session.query(model.TailbonePageHelp)\
@ -2639,7 +2639,7 @@ class MasterView(View):
# nb. self.Session may differ, so use tailbone.db.Session
session = Session()
model = self.model
model = self.app.model
route_prefix = self.get_route_prefix()
schema = colander.Schema()
@ -2673,7 +2673,7 @@ class MasterView(View):
# nb. self.Session may differ, so use tailbone.db.Session
session = Session()
model = self.model
model = self.app.model
route_prefix = self.get_route_prefix()
schema = colander.Schema()
@ -5541,7 +5541,7 @@ class MasterView(View):
input_file_templates=True,
output_file_templates=True):
app = self.get_rattail_app()
model = self.model
model = self.app.model
names = []
if simple_settings is None:
@ -6100,7 +6100,7 @@ class MasterView(View):
renderer='json')
class ViewSupplement(object):
class ViewSupplement:
"""
Base class for view "supplements" - which are sort of like plugins
which can "supplement" certain aspects of the view.
@ -6127,6 +6127,7 @@ class ViewSupplement(object):
def __init__(self, master):
self.master = master
self.request = master.request
self.app = master.app
self.model = master.model
self.rattail_config = master.rattail_config
self.Session = master.Session
@ -6160,7 +6161,7 @@ class ViewSupplement(object):
This is accomplished by subjecting the current base query to a
join, e.g. something like::
model = self.model
model = self.app.model
query = query.outerjoin(model.MyExtension)
return query
"""

View file

@ -564,15 +564,19 @@ class PersonView(MasterView):
Method which must return the base query for the profile's POS
Transactions grid data.
"""
app = self.get_rattail_app()
customer = app.get_customer(person)
customer = self.app.get_customer(person)
key_field = app.get_customer_key_field()
customer_key = getattr(customer, key_field)
if customer_key is not None:
customer_key = str(customer_key)
if customer:
key_field = self.app.get_customer_key_field()
customer_key = getattr(customer, key_field)
if customer_key is not None:
customer_key = str(customer_key)
else:
# nb. this should *not* match anything, so query returns
# no results..
customer_key = person.uuid
trainwreck = app.get_trainwreck_handler()
trainwreck = self.app.get_trainwreck_handler()
model = trainwreck.get_model()
query = TrainwreckSession.query(model.Transaction)\
.filter(model.Transaction.customer_id == customer_key)
@ -1382,8 +1386,8 @@ class PersonView(MasterView):
}
if not context['users']:
context['suggested_username'] = auth.generate_unique_username(self.Session(),
person=person)
context['suggested_username'] = auth.make_unique_username(self.Session(),
person=person)
return context

View file

@ -34,7 +34,7 @@ import sqlalchemy_continuum as continuum
from rattail import enum, pod, sil
from rattail.db import api, auth, Session as RattailSession
from rattail.db.model import Product, PendingProduct, CustomerOrderItem
from rattail.db.model import Product, PendingProduct, ProductCost, CustomerOrderItem
from rattail.gpc import GPC
from rattail.threads import Thread
from rattail.exceptions import LabelPrintingError
@ -1857,7 +1857,8 @@ class ProductView(MasterView):
lookup_fields.append('alt_code')
if lookup_fields:
product = self.products_handler.locate_product_for_entry(
session, term, lookup_fields=lookup_fields)
session, term, lookup_fields=lookup_fields,
first_if_multiple=True)
if product:
final_results.append(self.search_normalize_result(product))
@ -2668,6 +2669,78 @@ class PendingProductView(MasterView):
permission=f'{permission_prefix}.ignore_product')
class ProductCostView(MasterView):
"""
Master view for Product Costs
"""
model_class = ProductCost
route_prefix = 'product_costs'
url_prefix = '/products/costs'
has_versions = True
grid_columns = [
'_product_key_',
'vendor',
'preference',
'code',
'case_size',
'case_cost',
'pack_size',
'pack_cost',
'unit_cost',
]
def query(self, session):
""" """
query = super().query(session)
model = self.app.model
# always join on Product
return query.join(model.Product)
def configure_grid(self, g):
""" """
super().configure_grid(g)
model = self.app.model
# product key
field = self.get_product_key_field()
g.set_renderer(field, self.render_product_key)
g.set_sorter(field, getattr(model.Product, field))
g.set_sort_defaults(field)
g.set_filter(field, getattr(model.Product, field))
# vendor
g.set_joiner('vendor', lambda q: q.join(model.Vendor))
g.set_sorter('vendor', model.Vendor.name)
g.set_filter('vendor', model.Vendor.name, label="Vendor Name")
def render_product_key(self, cost, field):
""" """
handler = self.app.get_products_handler()
return handler.render_product_key(cost.product)
def configure_form(self, f):
""" """
super().configure_form(f)
# product
f.set_renderer('product', self.render_product)
if 'product_uuid' in f and 'product' in f:
f.remove('product')
f.replace('product_uuid', 'product')
# vendor
f.set_renderer('vendor', self.render_vendor)
if 'vendor_uuid' in f and 'vendor' in f:
f.remove('vendor')
f.replace('vendor_uuid', 'vendor')
# futures
# TODO: should eventually show a subgrid here?
f.remove('futures')
def defaults(config, **kwargs):
base = globals()
@ -2677,6 +2750,9 @@ def defaults(config, **kwargs):
PendingProductView = kwargs.get('PendingProductView', base['PendingProductView'])
PendingProductView.defaults(config)
ProductCostView = kwargs.get('ProductCostView', base['ProductCostView'])
ProductCostView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -24,6 +24,8 @@
Base class for purchasing batch views
"""
import warnings
from rattail.db.model import PurchaseBatch, PurchaseBatchRow
import colander
@ -67,6 +69,8 @@ class PurchasingBatchView(BatchMasterView):
'store',
'buyer',
'vendor',
'description',
'workflow',
'department',
'purchase',
'vendor_email',
@ -158,6 +162,174 @@ class PurchasingBatchView(BatchMasterView):
def batch_mode(self):
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):
model = self.model
return session.query(model.PurchaseBatch)\
@ -226,20 +398,40 @@ class PurchasingBatchView(BatchMasterView):
def configure_form(self, 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
app = self.get_rattail_app()
today = app.localtime().date()
workflow = self.request.matchdict.get('workflow_key')
vendor_handler = self.app.get_vendor_handler()
# 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
single_store = self.rattail_config.single_store()
single_store = self.config.single_store()
if self.creating:
f.replace('store', 'store_uuid')
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_default('store_uuid', store.uuid)
f.set_hidden('store_uuid')
@ -263,7 +455,6 @@ class PurchasingBatchView(BatchMasterView):
if self.creating:
f.replace('vendor', 'vendor_uuid')
f.set_label('vendor_uuid', "Vendor")
vendor_handler = app.get_vendor_handler()
use_dropdown = vendor_handler.choice_uses_dropdown()
if use_dropdown:
vendors = self.Session.query(model.Vendor)\
@ -313,7 +504,7 @@ class PurchasingBatchView(BatchMasterView):
if buyer:
buyer_display = str(buyer)
elif self.creating:
buyer = app.get_employee(self.request.user)
buyer = self.app.get_employee(self.request.user)
if buyer:
buyer_display = str(buyer)
f.set_default('buyer_uuid', buyer.uuid)
@ -324,6 +515,30 @@ class PurchasingBatchView(BatchMasterView):
field_display=buyer_display, service_url=buyers_url))
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
if self.creating:
f.set_type('invoice_file', 'file', required=False)
@ -341,7 +556,7 @@ class PurchasingBatchView(BatchMasterView):
if 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]
if len(parsers) == 1:
f.set_default('invoice_parser_key', parsers[0].key)
@ -400,6 +615,35 @@ class PurchasingBatchView(BatchMasterView):
'vendor_contact',
'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):
store = batch.store
if not store:
@ -515,10 +759,12 @@ class PurchasingBatchView(BatchMasterView):
def get_batch_kwargs(self, batch, **kwargs):
kwargs = super().get_batch_kwargs(batch, **kwargs)
model = self.model
model = self.app.model
kwargs['mode'] = self.batch_mode
kwargs['workflow'] = self.request.POST['workflow']
kwargs['truck_dump'] = batch.truck_dump
kwargs['order_parser_key'] = batch.order_parser_key
kwargs['invoice_parser_key'] = batch.invoice_parser_key
if batch.store:
@ -536,6 +782,11 @@ class PurchasingBatchView(BatchMasterView):
elif 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:
kwargs['department'] = batch.department
elif batch.department_uuid:
@ -919,6 +1170,25 @@ class PurchasingBatchView(BatchMasterView):
# # otherwise just view batch again
# 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):

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
@ -28,14 +28,10 @@ import os
import json
import openpyxl
from sqlalchemy import orm
from rattail.db import model, api
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
@ -51,6 +47,8 @@ class OrderingBatchView(PurchasingBatchView):
rows_editable = True
has_worksheet = True
default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html'
downloadable = True
configurable = True
labels = {
'po_total_calculated': "PO Total",
@ -59,9 +57,14 @@ class OrderingBatchView(PurchasingBatchView):
form_fields = [
'id',
'store',
'buyer',
'vendor',
'description',
'workflow',
'order_file',
'order_parser_key',
'buyer',
'department',
'params',
'purchase',
'vendor_email',
'vendor_fax',
@ -132,15 +135,26 @@ class OrderingBatchView(PurchasingBatchView):
return self.enum.PURCHASE_BATCH_MODE_ORDERING
def configure_form(self, f):
super(OrderingBatchView, self).configure_form(f)
super().configure_form(f)
batch = f.model_instance
workflow = self.request.matchdict.get('workflow_key')
# purchase
if self.creating or not batch.executed or not batch.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):
kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, **kwargs)
kwargs = super().get_batch_kwargs(batch, **kwargs)
kwargs['ship_method'] = batch.ship_method
kwargs['notes_to_vendor'] = batch.notes_to_vendor
return kwargs
@ -155,7 +169,7 @@ class OrderingBatchView(PurchasingBatchView):
* ``cases_ordered``
* ``units_ordered``
"""
super(OrderingBatchView, self).configure_row_form(f)
super().configure_row_form(f)
# when editing, only certain fields should allow changes
if self.editing:
@ -308,7 +322,7 @@ class OrderingBatchView(PurchasingBatchView):
title = self.get_instance_title(batch)
order_date = batch.date_ordered
if not order_date:
order_date = localtime(self.rattail_config).date()
order_date = self.app.today()
return self.render_to_response('worksheet', {
'batch': batch,
@ -369,6 +383,7 @@ class OrderingBatchView(PurchasingBatchView):
of being updated. If a matching row is not found, it will not be
created.
"""
model = self.app.model
batch = self.get_instance()
try:
@ -478,13 +493,75 @@ class OrderingBatchView(PurchasingBatchView):
return self.file_response(path)
def get_execute_success_url(self, batch, result, **kwargs):
model = self.app.model
if isinstance(result, model.Purchase):
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
def defaults(cls, config):
cls._ordering_defaults(config)
cls._purchase_batch_defaults(config)
cls._batch_defaults(config)
cls._defaults(config)

View file

@ -108,7 +108,7 @@ class ReceivingBatchView(PurchasingBatchView):
'store',
'vendor',
'description',
'receiving_workflow',
'workflow',
'truck_dump',
'truck_dump_children_first',
'truck_dump_children',
@ -235,135 +235,18 @@ class ReceivingBatchView(PurchasingBatchView):
if not self.handler.allow_truck_dump_receiving():
g.remove('truck_dump')
def create(self, form=None, **kwargs):
"""
Custom view for creating a new receiving batch. We split the process
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 = {}
for parser in self.batch_handler.get_supported_invoice_parsers():
if parser.vendor_key:
vendor = vendor_handler.get_vendor(self.Session(),
parser.vendor_key)
if vendor:
vendors[vendor.uuid] = vendor
vendors = sorted(vendors.values(), key=lambda v: v.name)
vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor))
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 get_supported_vendors(self):
""" """
vendor_handler = self.app.get_vendor_handler()
vendors = {}
for parser in self.batch_handler.get_supported_invoice_parsers():
if parser.vendor_key:
vendor = vendor_handler.get_vendor(self.Session(),
parser.vendor_key)
if vendor:
vendors[vendor.uuid] = vendor
vendors = sorted(vendors.values(), key=lambda v: v.name)
return vendors
def row_deletable(self, row):
@ -404,13 +287,7 @@ class ReceivingBatchView(PurchasingBatchView):
# cancel should take us back to choosing a workflow
f.cancel_url = self.request.route_url('{}.create'.format(route_prefix))
# receiving_workflow
if self.creating and workflow:
f.set_readonly('receiving_workflow')
f.set_renderer('receiving_workflow', self.render_receiving_workflow)
else:
f.remove('receiving_workflow')
# TODO: remove this
# batch_type
if self.creating:
f.set_widget('batch_type', dfwidget.HiddenWidget())
@ -525,7 +402,7 @@ class ReceivingBatchView(PurchasingBatchView):
# multiple invoice files (if applicable)
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:
f.insert_before('invoice_file', 'invoice_files')
@ -624,12 +501,6 @@ class ReceivingBatchView(PurchasingBatchView):
items.append(HTML.tag('li', c=[link]))
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):
params = super().get_visible_params(batch)
@ -654,42 +525,40 @@ class ReceivingBatchView(PurchasingBatchView):
def get_batch_kwargs(self, 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
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']
# TODO: ugh should just have workflow and no batch_type
kwargs['receiving_workflow'] = batch_type
if batch_type == 'from_scratch':
workflow = kwargs['workflow']
if workflow == 'from_scratch':
kwargs.pop('truck_dump_batch', None)
kwargs.pop('truck_dump_batch_uuid', None)
elif batch_type == 'from_invoice':
elif workflow == 'from_invoice':
pass
elif batch_type == 'from_multi_invoice':
elif workflow == 'from_multi_invoice':
pass
elif batch_type == 'from_po':
elif workflow == 'from_po':
# TODO: how to best handle this field? this doesn't seem flexible
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
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_children_first'] = True
kwargs['order_quantities_known'] = True
# TODO: this makes sense in some cases, but all?
# (should just omit that field when not relevant)
kwargs['date_ordered'] = None
elif batch_type == 'truck_dump_children_last':
elif workflow == 'truck_dump_children_last':
kwargs['truck_dump'] = True
kwargs['truck_dump_ready'] = True
# TODO: this makes sense in some cases, but all?
# (should just omit that field when not relevant)
kwargs['date_ordered'] = None
elif batch_type.startswith('truck_dump_child'):
elif workflow.startswith('truck_dump_child'):
truck_dump = self.get_instance()
kwargs['store'] = truck_dump.store
kwargs['vendor'] = truck_dump.vendor
@ -1986,6 +1855,12 @@ class ReceivingBatchView(PurchasingBatchView):
'type': bool},
# 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',
'option': 'purchase.supported_vendors_only',
'type': bool},
@ -2036,6 +1911,7 @@ class ReceivingBatchView(PurchasingBatchView):
@classmethod
def defaults(cls, config):
cls._receiving_defaults(config)
cls._purchase_batch_defaults(config)
cls._batch_defaults(config)
cls._defaults(config)
@ -2043,17 +1919,11 @@ class ReceivingBatchView(PurchasingBatchView):
def _receiving_defaults(cls, config):
rattail_config = config.registry.settings.get('rattail_config')
route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix()
instance_url_prefix = cls.get_instance_url_prefix()
model_key = cls.get_model_key()
model_title = cls.get_model_title()
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
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),
@ -2106,33 +1976,6 @@ class ReceivingBatchView(PurchasingBatchView):
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):
mode = colander.SchemaNode(colander.String(),