feat: add tools to change order item status; add notes

This commit is contained in:
Lance Edgar 2025-01-15 21:49:17 -06:00
parent b4deea76e0
commit 9d378a0c5f
6 changed files with 520 additions and 18 deletions

View file

@ -370,8 +370,8 @@ ORDER_ITEM_EVENT = OrderedDict([
(ORDER_ITEM_EVENT_CONTACTED, "customer contacted"),
(ORDER_ITEM_EVENT_CONTACT_FAILED, "contact failed"),
(ORDER_ITEM_EVENT_DELIVERED, "delivered"),
(ORDER_ITEM_EVENT_STATUS_CHANGE, "status change"),
(ORDER_ITEM_EVENT_NOTE_ADDED, "note added"),
(ORDER_ITEM_EVENT_STATUS_CHANGE, "changed status"),
(ORDER_ITEM_EVENT_NOTE_ADDED, "added note"),
(ORDER_ITEM_EVENT_CANCELED, "canceled"),
(ORDER_ITEM_EVENT_REFUND_PENDING, "refund pending"),
(ORDER_ITEM_EVENT_REFUNDED, "refunded"),

View file

@ -75,3 +75,24 @@ class OrderHandler(GenericHandler):
unit_qty = self.app.render_quantity(order_qty)
EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
return f"{unit_qty} {EA}"
def item_status_to_variant(self, status_code):
"""
Return a Buefy style variant for the given status code.
Default logic will return ``None`` for "normal" item status,
but may return ``'warning'`` for some (e.g. canceled).
:param status_code: The status code for an order item.
:returns: Style variant string (e.g. ``'warning'``) or
``None``.
"""
enum = self.app.enum
if status_code in (enum.ORDER_ITEM_STATUS_CANCELED,
enum.ORDER_ITEM_STATUS_REFUND_PENDING,
enum.ORDER_ITEM_STATUS_REFUNDED,
enum.ORDER_ITEM_STATUS_RESTOCKED,
enum.ORDER_ITEM_STATUS_EXPIRED,
enum.ORDER_ITEM_STATUS_INACTIVE):
return 'warning'

View file

@ -1,11 +1,16 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/view.mako" />
<%def name="content_title()">
(${app.enum.ORDER_ITEM_STATUS[item.status_code]})
${instance_title}
</%def>
<%def name="extra_styles()">
${parent.extra_styles()}
<style>
.field .field-label .label {
nav .field .field-label .label {
white-space: nowrap;
width: 10rem;
}
@ -39,7 +44,86 @@
<span>${app.render_currency(item.paid_amount)}</span>
</b-field>
<b-field horizontal label="Status">
<span>${app.enum.ORDER_ITEM_STATUS[item.status_code]}</span>
<div style="display: flex; gap: 1rem; align-items: center;">
<span
% if item_status_variant:
class="has-background-${item_status_variant}"
% endif
% if master.has_perm('change_status'):
style="padding: 0.25rem;"
% endif
>
${app.enum.ORDER_ITEM_STATUS[item.status_code]}
</span>
% if master.has_perm('change_status'):
<b-button type="is-primary"
icon-pack="fas"
icon-left="edit"
@click="changeStatusInit()">
Change Status
</b-button>
<${b}-modal
% if request.use_oruga:
v-model:active="changeStatusShowDialog"
% else:
:active.sync="changeStatusShowDialog"
% endif
>
<div class="card">
<div class="card-content">
<h4 class="block is-size-4">Change Item Status</h4>
<b-field horizontal label="Current Status">
<span>{{ changeStatusCodes[changeStatusOldCode] }}</span>
</b-field>
<br />
<b-field horizontal label="New Status"
:type="changeStatusNewCode ? null : 'is-danger'">
<b-select v-model="changeStatusNewCode">
<option v-for="status in changeStatusCodeOptions"
:key="status.key"
:value="status.key">
{{ status.label }}
</option>
</b-select>
</b-field>
<b-field label="Note">
<b-input v-model="changeStatusNote"
type="textarea" rows="4" />
</b-field>
<br />
<div class="buttons">
<b-button type="is-primary"
:disabled="changeStatusSaveDisabled"
icon-pack="fas"
icon-left="save"
@click="changeStatusSave()">
{{ changeStatusSubmitting ? "Working, please wait..." : "Update Status" }}
</b-button>
<b-button @click="changeStatusShowDialog = false">
Cancel
</b-button>
</div>
</div>
</div>
</${b}-modal>
${h.form(master.get_action_url('change_status', item), ref='changeStatusForm')}
${h.csrf_token(request)}
${h.hidden('new_status', **{'v-model': 'changeStatusNewCode'})}
## ${h.hidden('uuids', **{':value': 'changeStatusCheckedRows.map((row) => {return row.uuid}).join()'})}
${h.hidden('note', **{':value': 'changeStatusNote'})}
${h.end_form()}
% endif
</div>
</b-field>
</div>
</div>
@ -154,7 +238,70 @@
<div style="padding: 0 2rem;">
<nav class="panel" style="width: 100%;">
<p class="panel-heading">Events</p>
<p class="panel-heading"
% if master.has_perm('add_note'):
style="display: flex; gap: 2rem; align-items: center;"
% endif
>
<span>Events</span>
% if master.has_perm('add_note'):
<b-button type="is-primary"
icon-pack="fas"
icon-left="plus"
@click="addNoteInit()">
Add Note
</b-button>
<${b}-modal has-modal-card
% if request.use_oruga:
v-model:active="addNoteShowDialog"
% else:
:active.sync="addNoteShowDialog"
% endif
>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Add Note</p>
</header>
<section class="modal-card-body">
<b-field>
<b-input type="textarea" rows="8"
v-model="addNoteText"
ref="addNoteText"
expanded />
</b-field>
## <b-field>
## <b-checkbox v-model="addNoteApplyAll">
## Apply to all products on this order
## </b-checkbox>
## </b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="addNoteSave()"
:disabled="addNoteSaveDisabled"
icon-pack="fas"
icon-left="save">
{{ addNoteSubmitting ? "Working, please wait..." : "Add Note" }}
</b-button>
<b-button @click="addNoteShowDialog = false">
Cancel
</b-button>
</footer>
</div>
</${b}-modal>
${h.form(master.get_action_url('add_note', item), ref='addNoteForm')}
${h.csrf_token(request)}
${h.hidden('note', **{':value': 'addNoteText'})}
## ${h.hidden('uuids', **{':value': 'changeStatusCheckedRows.map((row) => {return row.uuid}).join()'})}
${h.end_form()}
% endif
</p>
<div class="panel-block">
<div style="width: 100%;">
${events_grid.render_table_element()}
@ -174,6 +321,80 @@
${parent.modify_vue_vars()}
<script>
% if master.has_perm('add_note'):
ThisPageData.addNoteShowDialog = false
ThisPageData.addNoteText = null
## ThisPageData.addNoteApplyAll = false
ThisPageData.addNoteSubmitting = false
ThisPage.computed.addNoteSaveDisabled = function() {
if (!this.addNoteText) {
return true
}
if (this.addNoteSubmitting) {
return true
}
return false
}
ThisPage.methods.addNoteInit = function() {
this.addNoteText = null
## this.addNoteApplyAll = false
this.addNoteShowDialog = true
this.$nextTick(() => {
this.$refs.addNoteText.focus()
})
}
ThisPage.methods.addNoteSave = function() {
this.addNoteSubmitting = true
this.$refs.addNoteForm.submit()
}
% endif
% if master.has_perm('change_status'):
ThisPageData.changeStatusCodes = ${json.dumps(app.enum.ORDER_ITEM_STATUS)|n}
ThisPageData.changeStatusCodeOptions = ${json.dumps([dict(key=k, label=v) for k, v in app.enum.ORDER_ITEM_STATUS.items()])|n}
ThisPageData.changeStatusShowDialog = false
ThisPageData.changeStatusOldCode = ${instance.status_code}
ThisPageData.changeStatusNewCode = null
ThisPageData.changeStatusNote = null
ThisPageData.changeStatusSubmitting = false
ThisPage.computed.changeStatusSaveDisabled = function() {
if (!this.changeStatusNewCode) {
return true
}
if (this.changeStatusSubmitting) {
return true
}
return false
}
ThisPage.methods.changeStatusInit = function() {
this.changeStatusNewCode = null
// clear out any checked rows
// this.changeStatusCheckedRows.length = 0
this.changeStatusNote = null
this.changeStatusShowDialog = true
}
ThisPage.methods.changeStatusSave = function() {
if (this.changeStatusNewCode == this.changeStatusOldCode) {
alert("You chose the same status it already had...")
return
}
this.changeStatusSubmitting = true
this.$refs.changeStatusForm.submit()
}
% endif
## TODO: ugh the hackiness
ThisPageData.gridContext = {
% for key, data in form.grid_vue_context.items():

View file

@ -30,7 +30,7 @@ import logging
import colander
from sqlalchemy import orm
from webhelpers2.html import tags
from webhelpers2.html import tags, HTML
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum
@ -862,6 +862,15 @@ class OrderView(MasterView):
# status_code
g.set_renderer('status_code', self.render_status_code)
# TODO: upstream should set this automatically
g.row_class = self.row_grid_row_class
def row_grid_row_class(self, item, data, i):
""" """
variant = self.order_handler.item_status_to_variant(item.status_code)
if variant:
return f'has-background-{variant}'
def render_status_code(self, item, key, value):
""" """
enum = self.app.enum
@ -1117,12 +1126,11 @@ class OrderItemView(MasterView):
enum = self.app.enum
return enum.ORDER_ITEM_STATUS[value]
def get_instance_title(self, item):
def grid_row_class(self, item, data, i):
""" """
enum = self.app.enum
title = str(item)
status = enum.ORDER_ITEM_STATUS[item.status_code]
return f"({status}) {title}"
variant = self.order_handler.item_status_to_variant(item.status_code)
if variant:
return f'has-background-{variant}'
def configure_form(self, f):
""" """
@ -1185,6 +1193,7 @@ class OrderItemView(MasterView):
context['order'] = item.order
context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text(
item.order_qty, item.order_uom, case_size=item.case_size, html=True)
context['item_status_variant'] = self.order_handler.item_status_to_variant(item.status_code)
grid = self.make_grid(key=f'{route_prefix}.view.events',
model_class=model.OrderItemEvent,
@ -1201,6 +1210,7 @@ class OrderItemView(MasterView):
'type_code': "Event Type",
})
grid.set_renderer('type_code', lambda e, k, v: enum.ORDER_ITEM_EVENT[v])
grid.set_renderer('note', self.render_event_note)
if self.request.has_perm('users.view'):
grid.set_renderer('actor', lambda e, k, v: tags.link_to(
e.actor, self.request.route_url('users.view', uuid=e.actor.uuid)))
@ -1209,6 +1219,15 @@ class OrderItemView(MasterView):
return context
def render_event_note(self, event, key, value):
""" """
enum = self.app.enum
if event.type_code == enum.ORDER_ITEM_EVENT_NOTE_ADDED:
return HTML.tag('span', class_='has-background-info-light',
style='padding: 0.25rem 0.5rem;',
c=[value])
return value
def get_xref_buttons(self, item):
""" """
buttons = super().get_xref_buttons(item)
@ -1221,6 +1240,112 @@ class OrderItemView(MasterView):
return buttons
def add_note(self):
"""
View which adds a note to an order item. This is POST-only;
will redirect back to the item view.
"""
enum = self.app.enum
item = self.get_instance()
item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, self.request.user,
note=self.request.POST['note'])
return self.redirect(self.get_action_url('view', item))
def change_status(self):
"""
View which changes status for an order item. This is
POST-only; will redirect back to the item view.
"""
model = self.app.model
enum = self.app.enum
main_item = self.get_instance()
session = self.Session()
redirect = self.redirect(self.get_action_url('view', main_item))
extra_note = self.request.POST.get('note')
# validate new status
new_status_code = int(self.request.POST['new_status'])
if new_status_code not in enum.ORDER_ITEM_STATUS:
self.request.session.flash("Invalid status code", 'error')
return redirect
new_status_text = enum.ORDER_ITEM_STATUS[new_status_code]
# locate all items to which new status will be applied
items = [main_item]
# uuids = self.request.POST.get('uuids')
# if uuids:
# for uuid in uuids.split(','):
# item = Session.get(model.OrderItem, uuid)
# if item:
# items.append(item)
# update item(s)
for item in items:
if item.status_code != new_status_code:
# event: change status
note = 'status changed from "{}" to "{}"'.format(
enum.ORDER_ITEM_STATUS[item.status_code],
new_status_text)
item.add_event(enum.ORDER_ITEM_EVENT_STATUS_CHANGE,
self.request.user, note=note)
# event: add note
if extra_note:
item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED,
self.request.user, note=extra_note)
# new status
item.status_code = new_status_code
self.request.session.flash(f"Status has been updated to: {new_status_text}")
return redirect
@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()
permission_prefix = cls.get_permission_prefix()
instance_url_prefix = cls.get_instance_url_prefix()
model_title = cls.get_model_title()
model_title_plural = cls.get_model_title_plural()
# fix perm group
config.add_wutta_permission_group(permission_prefix,
model_title_plural,
overwrite=False)
# add note
config.add_route(f'{route_prefix}.add_note',
f'{instance_url_prefix}/add_note',
request_method='POST')
config.add_view(cls, attr='add_note',
route_name=f'{route_prefix}.add_note',
renderer='json',
permission=f'{permission_prefix}.add_note')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.add_note',
f"Add note for {model_title}")
# change status
config.add_route(f'{route_prefix}.change_status',
f'{instance_url_prefix}/change-status',
request_method='POST')
config.add_view(cls, attr='change_status',
route_name=f'{route_prefix}.change_status',
renderer='json',
permission=f'{permission_prefix}.change_status')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.change_status',
f"Change status for {model_title}")
def defaults(config, **kwargs):
base = globals()

View file

@ -38,3 +38,23 @@ class TestOrderHandler(DataTestCase):
self.assertEqual(text, "2 Units")
text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_UNIT, html=True)
self.assertEqual(text, "2 Units")
def test_item_status_to_variant(self):
enum = self.app.enum
handler = self.make_handler()
# typical
self.assertIsNone(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_INITIATED))
self.assertIsNone(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_READY))
self.assertIsNone(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_PLACED))
self.assertIsNone(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_RECEIVED))
self.assertIsNone(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_CONTACTED))
self.assertIsNone(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_PAID))
# warning
self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_CANCELED), 'warning')
self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_REFUND_PENDING), 'warning')
self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_REFUNDED), 'warning')
self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_RESTOCKED), 'warning')
self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_EXPIRED), 'warning')
self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_INACTIVE), 'warning')

View file

@ -1183,6 +1183,19 @@ class TestOrderView(WebTestCase):
view.configure_row_grid(grid)
self.assertIn('product_scancode', grid.linked_columns)
def test_row_grid_row_class(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
# typical
item = model.OrderItem(status_code=enum.ORDER_ITEM_STATUS_READY)
self.assertIsNone(view.row_grid_row_class(item, {}, 1))
# warning
item = model.OrderItem(status_code=enum.ORDER_ITEM_STATUS_CANCELED)
self.assertEqual(view.row_grid_row_class(item, {}, 1), 'has-background-warning')
def test_render_status_code(self):
enum = self.app.enum
view = self.make_view()
@ -1291,17 +1304,18 @@ class TestOrderItemView(WebTestCase):
self.assertEqual(view.render_status_code(None, None, enum.ORDER_ITEM_STATUS_INITIATED),
'initiated')
def test_get_instance_title(self):
def test_grid_row_class(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
item = model.OrderItem(product_brand='Bragg',
product_description='Vinegar',
product_size='32oz',
status_code=enum.ORDER_ITEM_STATUS_INITIATED)
title = view.get_instance_title(item)
self.assertEqual(title, "(initiated) Bragg Vinegar 32oz")
# typical
item = model.OrderItem(status_code=enum.ORDER_ITEM_STATUS_READY)
self.assertIsNone(view.grid_row_class(item, {}, 1))
# warning
item = model.OrderItem(status_code=enum.ORDER_ITEM_STATUS_CANCELED)
self.assertEqual(view.grid_row_class(item, {}, 1), 'has-background-warning')
def test_configure_form(self):
model = self.app.model
@ -1349,6 +1363,24 @@ class TestOrderItemView(WebTestCase):
self.assertIn('order_qty_uom_text', context)
self.assertEqual(context['order_qty_uom_text'], "2 Cases (&times; 8 = 16 Units)")
def test_render_event_note(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
# typical
event = model.OrderItemEvent(type_code=enum.ORDER_ITEM_EVENT_READY, note='testing')
result = view.render_event_note(event, 'note', 'testing')
self.assertEqual(result, 'testing')
# user note
event = model.OrderItemEvent(type_code=enum.ORDER_ITEM_EVENT_NOTE_ADDED, note='testing2')
result = view.render_event_note(event, 'note', 'testing2')
self.assertNotEqual(result, 'testing2')
self.assertIn('<span', result)
self.assertIn('class="has-background-info-light"', result)
self.assertIn('testing2', result)
def test_get_xref_buttons(self):
self.pyramid_config.add_route('orders.view', '/orders/{uuid}')
model = self.app.model
@ -1371,3 +1403,86 @@ class TestOrderItemView(WebTestCase):
buttons = view.get_xref_buttons(item)
self.assertEqual(len(buttons), 1)
self.assertIn("View the Order", buttons[0])
def test_add_note(self):
self.pyramid_config.add_route('order_items.view', '/order-items/{uuid}')
model = self.app.model
enum = self.app.enum
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
order = model.Order(order_id=42, created_by=user)
self.session.add(order)
item = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_INITIATED)
order.items.append(item)
self.session.flush()
with patch.object(view, 'Session', return_value=self.session):
with patch.object(self.request, 'matchdict', new={'uuid': item.uuid}):
with patch.object(self.request, 'POST', new={'note': 'testing'}):
self.assertEqual(len(item.events), 0)
result = view.add_note()
self.assertEqual(len(item.events), 1)
self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
self.assertEqual(item.events[0].note, 'testing')
def test_change_status(self):
self.pyramid_config.add_route('order_items.view', '/order-items/{uuid}')
model = self.app.model
enum = self.app.enum
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
order = model.Order(order_id=42, created_by=user)
self.session.add(order)
item = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_INITIATED)
order.items.append(item)
self.session.flush()
with patch.object(view, 'Session', return_value=self.session):
with patch.object(self.request, 'user', new=user):
with patch.object(self.request, 'matchdict', new={'uuid': item.uuid}):
# just status change, no note
with patch.object(self.request, 'POST', new={
'new_status': enum.ORDER_ITEM_STATUS_PLACED}):
self.assertEqual(len(item.events), 0)
result = view.change_status()
self.assertIsInstance(result, HTTPFound)
self.assertFalse(self.request.session.peek_flash('error'))
self.assertEqual(len(item.events), 1)
self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_STATUS_CHANGE)
self.assertEqual(item.events[0].note,
"status changed from 'initiated' to 'placed'")
# status change plus note
with patch.object(self.request, 'POST', new={
'new_status': enum.ORDER_ITEM_STATUS_RECEIVED,
'note': 'check it out'}):
self.assertEqual(len(item.events), 1)
result = view.change_status()
self.assertIsInstance(result, HTTPFound)
self.assertFalse(self.request.session.peek_flash('error'))
self.assertEqual(len(item.events), 3)
self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_STATUS_CHANGE)
self.assertEqual(item.events[0].note,
"status changed from 'initiated' to 'placed'")
self.assertEqual(item.events[1].type_code, enum.ORDER_ITEM_EVENT_STATUS_CHANGE)
self.assertEqual(item.events[1].note,
"status changed from 'placed' to 'received'")
self.assertEqual(item.events[2].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
self.assertEqual(item.events[2].note, "check it out")
# invalid status
with patch.object(self.request, 'POST', new={'new_status': 23432143}):
self.assertEqual(len(item.events), 3)
result = view.change_status()
self.assertIsInstance(result, HTTPFound)
self.assertTrue(self.request.session.peek_flash('error'))
self.assertEqual(len(item.events), 3)