tailbone/tailbone/views/custorders/items.py
Lance Edgar b64f6c7884 Use newer config strategy for all views
to make inheritance easier
2022-11-23 12:20:58 -06:00

578 lines
21 KiB
Python

# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Customer order item views
"""
from __future__ import unicode_literals, absolute_import
import datetime
import six
from sqlalchemy import orm
from rattail.db import model
from rattail.time import localtime
from webhelpers2.html import HTML, tags
from tailbone.views import MasterView
from tailbone.util import raw_datetime
class CustomerOrderItemView(MasterView):
"""
Master view for customer order items
"""
model_class = model.CustomerOrderItem
route_prefix = 'custorders.items'
url_prefix = '/custorders/items'
creatable = False
editable = False
deletable = False
labels = {
'order': "Customer Order",
'order_id': "Order ID",
'order_uom': "Order UOM",
'status_code': "Status",
}
grid_columns = [
'order_id',
'person',
'product_brand',
'product_description',
'product_size',
'order_quantity',
'order_uom',
'case_quantity',
'order_created',
'status_code',
]
has_rows = True
model_row_class = model.CustomerOrderItemEvent
rows_title = "Event History"
rows_filterable = False
rows_sortable = False
rows_pageable = False
rows_viewable = False
row_grid_columns = [
'occurred',
'type_code',
'user',
'note',
]
form_fields = [
'order',
'sequence',
'person',
'product',
'pending_product',
'product_brand',
'product_description',
'product_size',
'order_quantity',
'order_uom',
'case_quantity',
'unit_price',
'total_price',
'price_needs_confirmation',
'paid_amount',
'status_code',
'notes',
]
def query(self, session):
return session.query(model.CustomerOrderItem)\
.join(model.CustomerOrder)\
.options(orm.joinedload(model.CustomerOrderItem.order)\
.joinedload(model.CustomerOrder.person))
def configure_grid(self, g):
super(CustomerOrderItemView, self).configure_grid(g)
g.set_renderer('order_id', self.render_order_id)
g.set_joiner('person', lambda q: q.outerjoin(model.Person))
g.filters['person'] = g.make_filter('person', model.Person.display_name,
default_active=True, default_verb='contains')
g.set_sorter('person', model.Person.display_name)
g.set_sorter('order_created', model.CustomerOrder.created)
g.set_sort_defaults('order_created', 'desc')
g.set_type('case_quantity', 'quantity')
g.set_type('cases_ordered', 'quantity')
g.set_type('units_ordered', 'quantity')
g.set_type('total_price', 'currency')
g.set_type('order_quantity', 'quantity')
g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE)
g.set_renderer('person', self.render_person_text)
g.set_renderer('order_created', self.render_order_created)
g.set_renderer('status_code', self.render_status_code_column)
g.set_label('person', "Person Name")
g.set_label('product_brand', "Brand")
g.set_label('product_description', "Description")
g.set_label('product_size', "Size")
g.set_link('order_id')
g.set_link('person')
g.set_link('product_brand')
g.set_link('product_description')
def render_order_id(self, item, field):
return item.order.id
def render_person_text(self, item, field):
person = item.order.person
if person:
text = six.text_type(person)
return text
def render_order_created(self, item, column):
value = localtime(self.rattail_config, item.order.created, from_utc=True)
return raw_datetime(self.rattail_config, value)
def render_status_code_column(self, item, field):
text = self.enum.CUSTORDER_ITEM_STATUS.get(item.status_code,
six.text_type(item.status_code))
if item.status_text:
return HTML.tag('span', title=item.status_text, c=[text])
return text
def configure_form(self, f):
super(CustomerOrderItemView, self).configure_form(f)
use_buefy = self.get_use_buefy()
item = f.model_instance
# order
f.set_renderer('order', self.render_order)
# (pending) product
f.set_renderer('product', self.render_product)
f.set_renderer('pending_product', self.render_pending_product)
if self.viewing:
if item.product and not item.pending_product:
f.remove('pending_product')
elif item.pending_product and not item.product:
f.remove('product')
# product uom
f.set_enum('product_unit_of_measure', self.enum.UNIT_OF_MEASURE)
# highlight pending fields
f.set_renderer('product_brand', self.highlight_pending_field)
f.set_renderer('product_description', self.highlight_pending_field)
f.set_renderer('product_size', self.highlight_pending_field)
f.set_renderer('case_quantity', self.highlight_pending_field_quantity)
'unit_price',
'total_price',
'price_needs_confirmation',
'paid_amount',
'status_code',
'notes',
# quantity fields
f.set_type('cases_ordered', 'quantity')
f.set_type('units_ordered', 'quantity')
f.set_type('order_quantity', 'quantity')
f.set_enum('order_uom', self.enum.UNIT_OF_MEASURE)
# price fields
f.set_renderer('unit_price', self.render_price_with_confirmation)
f.set_renderer('total_price', self.render_price_with_confirmation)
f.set_renderer('price_needs_confirmation', self.render_price_needs_confirmation)
f.set_type('paid_amount', 'currency')
# person
f.set_renderer('person', self.render_person)
# status_code
f.set_renderer('status_code', self.render_status_code)
# notes
if use_buefy:
f.set_renderer('notes', self.render_notes)
else:
f.remove('notes')
def highlight_pending_field(self, item, field, value=None):
if value is None:
value = getattr(item, field)
if not item.product_uuid and item.pending_product_uuid:
return HTML.tag('span', c=[value],
class_='has-text-success')
return value
def highlight_pending_field_quantity(self, item, field):
app = self.get_rattail_app()
value = getattr(item, field)
value = app.render_quantity(value)
return self.highlight_pending_field(item, field, value)
def render_price_with_confirmation(self, item, field):
price = getattr(item, field)
app = self.get_rattail_app()
text = app.render_currency(price)
if not item.product_uuid and item.pending_product_uuid:
text = HTML.tag('span', c=[text],
class_='has-text-success')
if item.price_needs_confirmation:
return HTML.tag('span', class_='has-background-warning',
c=[text])
return text
def render_price_needs_confirmation(self, item, field):
value = item.price_needs_confirmation
text = "Yes" if value else "No"
items = [text]
if value and self.has_perm('confirm_price'):
button = HTML.tag('b-button', type='is-primary', c="Confirm Price",
style='margin-left: 1rem;',
icon_pack='fas', icon_left='check',
**{'@click': "$emit('confirm-price')"})
items.append(button)
left = HTML.tag('div', class_='level-left', c=items)
outer = HTML.tag('div', class_='level', c=[left])
return outer
def render_status_code(self, item, field):
use_buefy = self.get_use_buefy()
text = self.enum.CUSTORDER_ITEM_STATUS[item.status_code]
if item.status_text:
text = "{} ({})".format(text, item.status_text)
items = [HTML.tag('span', c=[text])]
if use_buefy and self.has_perm('change_status'):
button = HTML.tag('b-button', type='is-primary', c="Change Status",
style='margin-left: 1rem;',
icon_pack='fas', icon_left='edit',
**{'@click': "$emit('change-status')"})
items.append(button)
left = HTML.tag('div', class_='level-left', c=items)
outer = HTML.tag('div', class_='level', c=[left])
return outer
def render_notes(self, item, field):
route_prefix = self.get_route_prefix()
factory = self.get_grid_factory()
g = factory(
key='{}.notes'.format(route_prefix),
data=[],
columns=[
'text',
'created_by',
'created',
],
labels={
'text': "Note",
},
)
table = HTML.literal(
g.render_buefy_table_element(data_prop='notesData'))
elements = [table]
if self.has_perm('add_note'):
button = HTML.tag('b-button', type='is-primary', c="Add Note",
class_='is-pulled-right',
icon_pack='fas', icon_left='plus',
**{'@click': "$emit('add-note')"})
button_wrapper = HTML.tag('div', c=[button],
style='margin-top: 0.5rem;')
elements.append(button_wrapper)
return HTML.tag('div',
style='display: flex; flex-direction: column;',
c=elements)
def template_kwargs_view(self, **kwargs):
kwargs = super(CustomerOrderItemView, self).template_kwargs_view(**kwargs)
app = self.get_rattail_app()
item = kwargs['instance']
# fetch notes for current item
kwargs['notes_data'] = self.get_context_notes(item)
# fetch "other" order items, siblings of current one
order = item.order
other_items = self.Session.query(model.CustomerOrderItem)\
.filter(model.CustomerOrderItem.order == order)\
.filter(model.CustomerOrderItem.uuid != item.uuid)\
.all()
other_data = []
for other in other_items:
order_date = None
if order.created:
order_date = localtime(self.rattail_config, order.created, from_utc=True).date()
other_data.append({
'uuid': other.uuid,
'brand_name': other.product_brand,
'product_description': other.product_description,
'product_case_quantity': app.render_quantity(other.case_quantity),
'order_quantity': app.render_quantity(other.order_quantity),
'order_uom': self.enum.UNIT_OF_MEASURE[other.order_uom],
'department_name': other.department_name,
'product_barcode': other.product_upc.pretty() if other.product_upc else None,
'unit_price': app.render_currency(other.unit_price),
'total_price': app.render_currency(other.total_price),
'order_date': app.render_date(order_date),
'status_code': self.enum.CUSTORDER_ITEM_STATUS[other.status_code],
})
kwargs['other_order_items_data'] = other_data
return kwargs
def get_context_notes(self, item):
notes = []
for note in reversed(item.notes):
created = localtime(self.rattail_config, note.created, from_utc=True)
notes.append({
'created': raw_datetime(self.rattail_config, created),
'created_by': note.created_by.display_name,
'text': note.text,
})
return notes
def confirm_price(self):
"""
View for confirming price of an order item.
"""
item = self.get_instance()
redirect = self.redirect(self.get_action_url('view', item))
# locate user responsible for change
user = self.request.user
# grab user-provided note to attach to event
note = self.request.POST.get('note')
# declare item no longer in need of price confirmation
item.price_needs_confirmation = False
item.add_event(self.enum.CUSTORDER_ITEM_EVENT_PRICE_CONFIRMED,
user, note=note)
# advance item to next status
if item.status_code == self.enum.CUSTORDER_ITEM_STATUS_INITIATED:
item.status_code = self.enum.CUSTORDER_ITEM_STATUS_READY
item.status_text = "price has been confirmed"
self.request.session.flash("Price has been confirmed.")
return redirect
def change_status(self):
"""
View for changing status of one or more order items.
"""
order_item = self.get_instance()
redirect = self.redirect(self.get_action_url('view', order_item))
# validate new status
new_status_code = int(self.request.POST['new_status_code'])
if new_status_code not in self.enum.CUSTORDER_ITEM_STATUS:
self.request.session.flash("Invalid status code", 'error')
return redirect
# locate order items to which new status will be applied
order_items = [order_item]
uuids = self.request.POST['uuids']
if uuids:
for uuid in uuids.split(','):
item = self.Session.query(model.CustomerOrderItem).get(uuid)
if item:
order_items.append(item)
# locate user responsible for change
user = self.request.user
# maybe grab extra user-provided note to attach
extra_note = self.request.POST.get('note')
# apply new status to order item(s)
for item in order_items:
if item.status_code != new_status_code:
# attach event
note = "status changed from \"{}\" to \"{}\"".format(
self.enum.CUSTORDER_ITEM_STATUS[item.status_code],
self.enum.CUSTORDER_ITEM_STATUS[new_status_code])
if extra_note:
note = "{} - NOTE: {}".format(note, extra_note)
item.events.append(model.CustomerOrderItemEvent(
type_code=self.enum.CUSTORDER_ITEM_EVENT_STATUS_CHANGE,
user=user, note=note))
# change status
item.status_code = new_status_code
# nb. must blank this out, b/c user cannot specify new
# text and the old text no longer applies
item.status_text = None
self.request.session.flash("Status has been updated to: {}".format(
self.enum.CUSTORDER_ITEM_STATUS[new_status_code]))
return redirect
def add_note(self):
"""
View for adding a new note to current order item, optinally
also adding it to all other items under the parent order.
"""
order_item = self.get_instance()
data = self.request.json_body
new_note = data['note']
apply_all = data['apply_all'] == True
user = self.request.user
if apply_all:
order_items = order_item.order.items
else:
order_items = [order_item]
for item in order_items:
item.notes.append(model.CustomerOrderItemNote(
created_by=user, text=new_note))
# # attach event
# item.events.append(model.CustomerOrderItemEvent(
# type_code=self.enum.CUSTORDER_ITEM_EVENT_ADDED_NOTE,
# user=user, note=new_note))
self.Session.flush()
self.Session.refresh(order_item)
return {'success': True,
'notes': self.get_context_notes(order_item)}
def render_order(self, item, field):
order = item.order
if not order:
return ""
text = six.text_type(order)
url = self.request.route_url('custorders.view', uuid=order.uuid)
return tags.link_to(text, url)
def render_person(self, item, field):
person = item.order.person
if person:
text = six.text_type(person)
url = self.request.route_url('people.view', uuid=person.uuid)
return tags.link_to(text, url)
def get_row_data(self, item):
return self.Session.query(model.CustomerOrderItemEvent)\
.filter(model.CustomerOrderItemEvent.item == item)\
.order_by(model.CustomerOrderItemEvent.occurred.desc(),
model.CustomerOrderItemEvent.type_code)
def configure_row_grid(self, g):
super(CustomerOrderItemView, self).configure_row_grid(g)
g.set_enum('type_code', self.enum.CUSTORDER_ITEM_EVENT)
g.set_label('occurred', "When")
g.set_label('type_code', "What") # TODO: enum renderer
g.set_label('user', "Who")
g.set_label('note', "Notes")
@classmethod
def defaults(cls, config):
cls._order_item_defaults(config)
cls._defaults(config)
@classmethod
def _order_item_defaults(cls, config):
route_prefix = cls.get_route_prefix()
instance_url_prefix = cls.get_instance_url_prefix()
permission_prefix = cls.get_permission_prefix()
model_title = cls.get_model_title()
model_title_plural = cls.get_model_title_plural()
# fix permission group name
config.add_tailbone_permission_group(permission_prefix, model_title_plural)
# confirm price
config.add_tailbone_permission(permission_prefix,
'{}.confirm_price'.format(permission_prefix),
"Confirm price for a {}".format(model_title))
config.add_route('{}.confirm_price'.format(route_prefix),
'{}/confirm-price'.format(instance_url_prefix),
request_method='POST')
config.add_view(cls, attr='confirm_price',
route_name='{}.confirm_price'.format(route_prefix),
permission='{}.confirm_price'.format(permission_prefix))
# change status
config.add_tailbone_permission(permission_prefix,
'{}.change_status'.format(permission_prefix),
"Change status for 1 or more {}".format(model_title_plural))
config.add_route('{}.change_status'.format(route_prefix),
'{}/change-status'.format(instance_url_prefix),
request_method='POST')
config.add_view(cls, attr='change_status',
route_name='{}.change_status'.format(route_prefix),
permission='{}.change_status'.format(permission_prefix))
# add note
config.add_tailbone_permission(permission_prefix,
'{}.add_note'.format(permission_prefix),
"Add arbitrary notes for {}".format(model_title_plural))
config.add_route('{}.add_note'.format(route_prefix),
'{}/add-note'.format(instance_url_prefix),
request_method='POST')
config.add_view(cls, attr='add_note',
route_name='{}.add_note'.format(route_prefix),
renderer='json',
permission='{}.add_note'.format(permission_prefix))
# TODO: deprecate / remove this
CustomerOrderItemsView = CustomerOrderItemView
def defaults(config, **kwargs):
base = globals()
CustomerOrderItemView = kwargs.get('CustomerOrderItemView', base['CustomerOrderItemView'])
CustomerOrderItemView.defaults(config)
def includeme(config):
defaults(config)