Compare commits

..

No commits in common. "master" and "v0.22.0" have entirely different histories.

16 changed files with 67 additions and 226 deletions

View file

@ -5,58 +5,6 @@ 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.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) ## v0.22.0 (2024-10-22)
### Feat ### Feat

View file

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

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "Tailbone" name = "Tailbone"
version = "0.22.7" 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"}]
@ -53,13 +53,13 @@ dependencies = [
"pyramid_mako", "pyramid_mako",
"pyramid_retry", "pyramid_retry",
"pyramid_tm", "pyramid_tm",
"rattail[db,bouncer]>=0.20.1", "rattail[db,bouncer]>=0.18.5",
"sa-filters", "sa-filters",
"simplejson", "simplejson",
"transaction", "transaction",
"waitress", "waitress",
"WebHelpers2", "WebHelpers2",
"WuttaWeb>=0.21.0", "WuttaWeb>=0.14.0",
"zope.sqlalchemy>=1.5", "zope.sqlalchemy>=1.5",
] ]

View file

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

View file

@ -62,17 +62,6 @@ def make_rattail_config(settings):
# nb. this is for compaibility with wuttaweb # nb. this is for compaibility with wuttaweb
settings['wutta_config'] = rattail_config 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 # configure database sessions
if hasattr(rattail_config, 'appdb_engine'): if hasattr(rattail_config, 'appdb_engine'):
tailbone.db.Session.configure(bind=rattail_config.appdb_engine) tailbone.db.Session.configure(bind=rattail_config.appdb_engine)

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar # Copyright © 2010-2023 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -270,21 +270,9 @@ class VersionDiff(Diff):
for field in self.fields: for field in self.fields:
values[field] = {'before': self.render_old_value(field), values[field] = {'before': self.render_old_value(field),
'after': self.render_new_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 { return {
'key': id(self.version), 'key': id(self.version),
'model_title': self.title, 'model_title': self.title,
'operation': operation,
'diff_class': self.nature, 'diff_class': self.nature,
'fields': self.fields, 'fields': self.fields,
'values': values, 'values': values,

View file

@ -235,7 +235,7 @@ class Grid(WuttaGrid):
if 'pageable' in kwargs: if 'pageable' in kwargs:
warnings.warn("pageable param is deprecated for Grid(); " warnings.warn("pageable param is deprecated for Grid(); "
"please use paginated param instead", "please use vue_tagname param instead",
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)
kwargs.setdefault('paginated', kwargs.pop('pageable')) kwargs.setdefault('paginated', kwargs.pop('pageable'))
@ -1223,7 +1223,6 @@ class Grid(WuttaGrid):
def render_table_element(self, template='/grids/b-table.mako', def render_table_element(self, template='/grids/b-table.mako',
data_prop='gridData', empty_labels=False, data_prop='gridData', empty_labels=False,
literal=False,
**kwargs): **kwargs):
""" """
This is intended for ad-hoc "small" grids with static data. Renders This is intended for ad-hoc "small" grids with static data. Renders
@ -1240,10 +1239,7 @@ class Grid(WuttaGrid):
if context['paginated']: if context['paginated']:
context.setdefault('per_page', 20) context.setdefault('per_page', 20)
context['view_click_handler'] = self.get_view_click_handler() context['view_click_handler'] = self.get_view_click_handler()
result = render(template, context) return render(template, context)
if literal:
result = HTML.literal(result)
return result
def get_view_click_handler(self): def get_view_click_handler(self):
""" """ """ """

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -44,6 +44,28 @@ class UserLogin(colander.MappingSchema):
widget=dfwidget.PasswordWidget()) 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): class AuthenticationView(View):
def forbidden(self): def forbidden(self):
@ -94,6 +116,10 @@ class AuthenticationView(View):
else: else:
self.request.session.flash("Invalid username or password", 'error') 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 # nb. hacky..but necessary, to add the refs, for autofocus
# (also add key handler, so ENTER acts like TAB) # (also add key handler, so ENTER acts like TAB)
dform = form.make_deform_form() dform = form.make_deform_form()
@ -106,6 +132,7 @@ class AuthenticationView(View):
return { return {
'form': form, 'form': form,
'referrer': referrer, 'referrer': referrer,
'image_url': image_url,
'index_title': app.get_node_title(), 'index_title': app.get_node_title(),
'help_url': global_help_url(self.rattail_config), 'help_url': global_help_url(self.rattail_config),
} }
@ -154,23 +181,7 @@ class AuthenticationView(View):
self.request.user)) self.request.user))
return self.redirect(self.request.get_referrer()) return self.redirect(self.request.get_referrer())
def check_user_password(node, value): schema = ChangePassword().bind(user=self.request.user, request=self.request)
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) form = forms.Form(schema=schema, request=self.request)
if form.validate(): if form.validate():
auth = self.app.get_auth_handler() auth = self.app.get_auth_handler()

View file

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

View file

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

View file

@ -34,7 +34,7 @@ import sqlalchemy_continuum as continuum
from rattail import enum, pod, sil from rattail import enum, pod, sil
from rattail.db import api, auth, Session as RattailSession from rattail.db import api, auth, Session as RattailSession
from rattail.db.model import Product, PendingProduct, ProductCost, CustomerOrderItem from rattail.db.model import Product, PendingProduct, CustomerOrderItem
from rattail.gpc import GPC from rattail.gpc import GPC
from rattail.threads import Thread from rattail.threads import Thread
from rattail.exceptions import LabelPrintingError from rattail.exceptions import LabelPrintingError
@ -1857,8 +1857,7 @@ class ProductView(MasterView):
lookup_fields.append('alt_code') lookup_fields.append('alt_code')
if lookup_fields: if lookup_fields:
product = self.products_handler.locate_product_for_entry( 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: if product:
final_results.append(self.search_normalize_result(product)) final_results.append(self.search_normalize_result(product))
@ -2669,78 +2668,6 @@ class PendingProductView(MasterView):
permission=f'{permission_prefix}.ignore_product') 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): def defaults(config, **kwargs):
base = globals() base = globals()
@ -2750,9 +2677,6 @@ def defaults(config, **kwargs):
PendingProductView = kwargs.get('PendingProductView', base['PendingProductView']) PendingProductView = kwargs.get('PendingProductView', base['PendingProductView'])
PendingProductView.defaults(config) PendingProductView.defaults(config)
ProductCostView = kwargs.get('ProductCostView', base['ProductCostView'])
ProductCostView.defaults(config)
def includeme(config): def includeme(config):
defaults(config) defaults(config)