Coverage for src/sideshow/web/views/orders.py: 100%
297 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-06 15:04 -0600
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-06 15:04 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# Sideshow -- Case/Special Order Tracker
5# Copyright © 2024 Lance Edgar
6#
7# This file is part of Sideshow.
8#
9# Sideshow is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# Sideshow is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17# General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Views for Orders
25"""
27import decimal
28import logging
30import colander
31from sqlalchemy import orm
33from wuttaweb.views import MasterView
34from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum
36from sideshow.db.model import Order, OrderItem
37from sideshow.batch.neworder import NewOrderBatchHandler
38from sideshow.web.forms.schema import OrderRef, PendingCustomerRef, PendingProductRef
41log = logging.getLogger(__name__)
44class OrderView(MasterView):
45 """
46 Master view for :class:`~sideshow.db.model.orders.Order`; route
47 prefix is ``orders``.
49 Notable URLs provided by this class:
51 * ``/orders/``
52 * ``/orders/new``
53 * ``/orders/XXX``
54 * ``/orders/XXX/delete``
56 Note that the "edit" view is not exposed here; user must perform
57 various other workflow actions to modify the order.
58 """
59 model_class = Order
60 editable = False
62 labels = {
63 'order_id': "Order ID",
64 'store_id': "Store ID",
65 'customer_id': "Customer ID",
66 }
68 grid_columns = [
69 'order_id',
70 'store_id',
71 'customer_id',
72 'customer_name',
73 'total_price',
74 'created',
75 'created_by',
76 ]
78 sort_defaults = ('order_id', 'desc')
80 form_fields = [
81 'order_id',
82 'store_id',
83 'customer_id',
84 'pending_customer',
85 'customer_name',
86 'phone_number',
87 'email_address',
88 'total_price',
89 'created',
90 'created_by',
91 ]
93 has_rows = True
94 row_model_class = OrderItem
95 rows_title = "Order Items"
96 rows_sort_defaults = 'sequence'
97 rows_viewable = True
99 row_labels = {
100 'product_scancode': "Scancode",
101 'product_brand': "Brand",
102 'product_description': "Description",
103 'product_size': "Size",
104 'department_name': "Department",
105 'order_uom': "Order UOM",
106 'status_code': "Status",
107 }
109 row_grid_columns = [
110 'sequence',
111 'product_scancode',
112 'product_brand',
113 'product_description',
114 'product_size',
115 'department_name',
116 'special_order',
117 'order_qty',
118 'order_uom',
119 'total_price',
120 'status_code',
121 ]
123 PENDING_PRODUCT_ENTRY_FIELDS = [
124 'scancode',
125 'department_id',
126 'department_name',
127 'brand_name',
128 'description',
129 'size',
130 'vendor_name',
131 'vendor_item_code',
132 'unit_cost',
133 'case_size',
134 'unit_price_reg',
135 ]
137 def configure_grid(self, g):
138 """ """
139 super().configure_grid(g)
141 # order_id
142 g.set_link('order_id')
144 # customer_id
145 g.set_link('customer_id')
147 # customer_name
148 g.set_link('customer_name')
150 # total_price
151 g.set_renderer('total_price', g.render_currency)
153 def create(self):
154 """
155 Instead of the typical "create" view, this displays a "wizard"
156 of sorts.
158 Under the hood a
159 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` is
160 automatically created for the user when they first visit this
161 page. They can select a customer, add items etc.
163 When user is finished assembling the order (i.e. populating
164 the batch), they submit it. This of course executes the
165 batch, which in turn creates a true
166 :class:`~sideshow.db.model.orders.Order`, and user is
167 redirected to the "view order" page.
168 """
169 enum = self.app.enum
170 self.creating = True
171 self.batch_handler = NewOrderBatchHandler(self.config)
172 batch = self.get_current_batch()
174 context = self.get_context_customer(batch)
176 if self.request.method == 'POST':
178 # first we check for traditional form post
179 action = self.request.POST.get('action')
180 post_actions = [
181 'start_over',
182 'cancel_order',
183 ]
184 if action in post_actions:
185 return getattr(self, action)(batch)
187 # okay then, we'll assume newer JSON-style post params
188 data = dict(self.request.json_body)
189 action = data.pop('action')
190 json_actions = [
191 # 'assign_contact',
192 # 'unassign_contact',
193 # 'update_phone_number',
194 # 'update_email_address',
195 'set_pending_customer',
196 # 'get_customer_info',
197 # # 'set_customer_data',
198 # 'get_product_info',
199 # 'get_past_items',
200 'add_item',
201 'update_item',
202 'delete_item',
203 'submit_new_order',
204 ]
205 if action in json_actions:
206 result = getattr(self, action)(batch, data)
207 return self.json_response(result)
209 return self.json_response({'error': "unknown form action"})
211 context.update({
212 'batch': batch,
213 'normalized_batch': self.normalize_batch(batch),
214 'order_items': [self.normalize_row(row)
215 for row in batch.rows],
217 'allow_unknown_product': True, # TODO
218 'default_uom_choices': self.get_default_uom_choices(),
219 'default_uom': None, # TODO?
220 'pending_product_required_fields': self.get_pending_product_required_fields(),
221 })
222 return self.render_to_response('create', context)
224 def get_current_batch(self):
225 """
226 Returns the current batch for the current user.
228 This looks for a new order batch which was created by the
229 user, but not yet executed. If none is found, a new batch is
230 created.
232 :returns:
233 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
234 instance
235 """
236 model = self.app.model
237 session = self.Session()
239 user = self.request.user
240 if not user:
241 raise self.forbidden()
243 try:
244 # there should be at most *one* new batch per user
245 batch = session.query(model.NewOrderBatch)\
246 .filter(model.NewOrderBatch.created_by == user)\
247 .filter(model.NewOrderBatch.executed == None)\
248 .one()
250 except orm.exc.NoResultFound:
251 # no batch yet for this user, so make one
252 batch = self.batch_handler.make_batch(session, created_by=user)
253 session.add(batch)
254 session.flush()
256 return batch
258 def get_pending_product_required_fields(self):
259 """ """
260 required = []
261 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
262 require = self.config.get_bool(
263 f'sideshow.orders.unknown_product.fields.{field}.required')
264 if require is None and field == 'description':
265 require = True
266 if require:
267 required.append(field)
268 return required
270 def start_over(self, batch):
271 """
272 This will delete the user's current batch, then redirect user
273 back to "Create Order" page, which in turn will auto-create a
274 new batch for them.
276 This is a "batch action" method which may be called from
277 :meth:`create()`.
278 """
279 # drop current batch
280 self.batch_handler.do_delete(batch, self.request.user)
281 self.Session.flush()
283 # send back to "create order" which makes new batch
284 route_prefix = self.get_route_prefix()
285 url = self.request.route_url(f'{route_prefix}.create')
286 return self.redirect(url)
288 def cancel_order(self, batch):
289 """
290 This will delete the user's current batch, then redirect user
291 back to "List Orders" page.
293 This is a "batch action" method which may be called from
294 :meth:`create()`.
295 """
296 self.batch_handler.do_delete(batch, self.request.user)
297 self.Session.flush()
299 # set flash msg just to be more obvious
300 self.request.session.flash("New order has been deleted.")
302 # send user back to orders list, w/ no new batch generated
303 url = self.get_index_url()
304 return self.redirect(url)
306 def get_context_customer(self, batch):
307 """ """
308 context = {
309 'customer_id': batch.customer_id,
310 'customer_name': batch.customer_name,
311 'phone_number': batch.phone_number,
312 'email_address': batch.email_address,
313 'new_customer_name': None,
314 'new_customer_first_name': None,
315 'new_customer_last_name': None,
316 'new_customer_phone': None,
317 'new_customer_email': None,
318 }
320 pending = batch.pending_customer
321 if pending:
322 context.update({
323 'new_customer_first_name': pending.first_name,
324 'new_customer_last_name': pending.last_name,
325 'new_customer_name': pending.full_name,
326 'new_customer_phone': pending.phone_number,
327 'new_customer_email': pending.email_address,
328 })
330 # figure out if customer is "known" from user's perspective.
331 # if we have an ID then it's definitely known, otherwise if we
332 # have a pending customer then it's definitely *not* known,
333 # but if no pending customer yet then we can still "assume" it
334 # is known, by default, until user specifies otherwise.
335 if batch.customer_id:
336 context['customer_is_known'] = True
337 else:
338 context['customer_is_known'] = not pending
340 return context
342 def set_pending_customer(self, batch, data):
343 """
344 This will set/update the batch pending customer info.
346 This calls
347 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_pending_customer()`
348 for the heavy lifting.
350 This is a "batch action" method which may be called from
351 :meth:`create()`.
352 """
353 data['created_by'] = self.request.user
354 try:
355 self.batch_handler.set_pending_customer(batch, data)
356 except Exception as error:
357 return {'error': self.app.render_error(error)}
359 self.Session.flush()
360 context = self.get_context_customer(batch)
361 return context
363 def add_item(self, batch, data):
364 """
365 This adds a row to the user's current new order batch.
367 This is a "batch action" method which may be called from
368 :meth:`create()`.
369 """
370 order_qty = decimal.Decimal(data.get('order_qty') or '0')
371 order_uom = data['order_uom']
373 if data.get('product_is_known'):
374 raise NotImplementedError
376 else: # unknown product; add pending
377 pending = data['pending_product']
379 for field in ('unit_cost', 'unit_price_reg', 'case_size'):
380 if field in pending:
381 try:
382 pending[field] = decimal.Decimal(pending[field])
383 except decimal.InvalidOperation:
384 return {'error': f"Invalid entry for field: {field}"}
386 pending['created_by'] = self.request.user
387 row = self.batch_handler.add_pending_product(batch, pending,
388 order_qty, order_uom)
390 return {'batch': self.normalize_batch(batch),
391 'row': self.normalize_row(row)}
393 def update_item(self, batch, data):
394 """
395 This updates a row in the user's current new order batch.
397 This is a "batch action" method which may be called from
398 :meth:`create()`.
399 """
400 model = self.app.model
401 enum = self.app.enum
402 session = self.Session()
404 uuid = data.get('uuid')
405 if not uuid:
406 return {'error': "Must specify a row UUID"}
408 row = session.get(model.NewOrderBatchRow, uuid)
409 if not row:
410 return {'error': "Row not found"}
412 if row.batch is not batch:
413 return {'error': "Row is for wrong batch"}
415 order_qty = decimal.Decimal(data.get('order_qty') or '0')
416 order_uom = data['order_uom']
418 if data.get('product_is_known'):
419 raise NotImplementedError
421 else: # pending product
423 # set these first, since row will be refreshed below
424 row.order_qty = order_qty
425 row.order_uom = order_uom
427 # nb. this will refresh the row
428 self.batch_handler.set_pending_product(row, data['pending_product'])
430 return {'batch': self.normalize_batch(batch),
431 'row': self.normalize_row(row)}
433 def delete_item(self, batch, data):
434 """
435 This deletes a row from the user's current new order batch.
437 This is a "batch action" method which may be called from
438 :meth:`create()`.
439 """
440 model = self.app.model
441 session = self.app.get_session(batch)
443 uuid = data.get('uuid')
444 if not uuid:
445 return {'error': "Must specify a row UUID"}
447 row = session.get(model.NewOrderBatchRow, uuid)
448 if not row:
449 return {'error': "Row not found"}
451 if row.batch is not batch:
452 return {'error': "Row is for wrong batch"}
454 self.batch_handler.do_remove_row(row)
455 session.flush()
456 return {'batch': self.normalize_batch(batch)}
458 def submit_new_order(self, batch, data):
459 """
460 This submits the user's current new order batch, hence
461 executing the batch and creating the true order.
463 This is a "batch action" method which may be called from
464 :meth:`create()`.
465 """
466 user = self.request.user
467 reason = self.batch_handler.why_not_execute(batch, user=user)
468 if reason:
469 return {'error': reason}
471 try:
472 order = self.batch_handler.do_execute(batch, user)
473 except Exception as error:
474 log.warning("failed to execute new order batch: %s", batch,
475 exc_info=True)
476 return {'error': self.app.render_error(error)}
478 return {
479 'next_url': self.get_action_url('view', order),
480 }
482 def normalize_batch(self, batch):
483 """ """
484 return {
485 'uuid': batch.uuid.hex,
486 'total_price': str(batch.total_price or 0),
487 'total_price_display': self.app.render_currency(batch.total_price),
488 'status_code': batch.status_code,
489 'status_text': batch.status_text,
490 }
492 def get_default_uom_choices(self):
493 """ """
494 enum = self.app.enum
495 return [{'key': key, 'value': val}
496 for key, val in enum.ORDER_UOM.items()]
498 def normalize_row(self, row):
499 """ """
500 enum = self.app.enum
502 data = {
503 'uuid': row.uuid.hex,
504 'sequence': row.sequence,
505 'product_scancode': row.product_scancode,
506 'product_brand': row.product_brand,
507 'product_description': row.product_description,
508 'product_size': row.product_size,
509 'product_weighed': row.product_weighed,
510 'department_display': row.department_name,
511 'special_order': row.special_order,
512 'case_size': self.app.render_quantity(row.case_size),
513 'order_qty': self.app.render_quantity(row.order_qty),
514 'order_uom': row.order_uom,
515 'order_uom_choices': self.get_default_uom_choices(),
516 'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None,
517 'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted),
518 'case_price_quoted': float(row.case_price_quoted) if row.case_price_quoted is not None else None,
519 'case_price_quoted_display': self.app.render_currency(row.case_price_quoted),
520 'total_price': float(row.total_price) if row.total_price is not None else None,
521 'total_price_display': self.app.render_currency(row.total_price),
522 'status_code': row.status_code,
523 'status_text': row.status_text,
524 }
526 if row.unit_price_reg:
527 data['unit_price_reg'] = float(row.unit_price_reg)
528 data['unit_price_reg_display'] = self.app.render_currency(row.unit_price_reg)
530 if row.unit_price_sale:
531 data['unit_price_sale'] = float(row.unit_price_sale)
532 data['unit_price_sale_display'] = self.app.render_currency(row.unit_price_sale)
533 if row.sale_ends:
534 sale_ends = row.sale_ends
535 data['sale_ends'] = str(row.sale_ends)
536 data['sale_ends_display'] = self.app.render_date(row.sale_ends)
538 # if row.unit_price_sale and row.unit_price_quoted == row.unit_price_sale:
539 # data['pricing_reflects_sale'] = True
541 # TODO
542 if row.pending_product:
543 data['product_full_description'] = row.pending_product.full_description
544 # else:
545 # data['product_full_description'] = row.product_description
547 # if row.pending_product:
548 # data['vendor_display'] = row.pending_product.vendor_name
550 if row.pending_product:
551 pending = row.pending_product
552 # data['vendor_display'] = pending.vendor_name
553 data['pending_product'] = {
554 'uuid': pending.uuid.hex,
555 'scancode': pending.scancode,
556 'brand_name': pending.brand_name,
557 'description': pending.description,
558 'size': pending.size,
559 'department_id': pending.department_id,
560 'department_name': pending.department_name,
561 'unit_price_reg': float(pending.unit_price_reg) if pending.unit_price_reg is not None else None,
562 'vendor_name': pending.vendor_name,
563 'vendor_item_code': pending.vendor_item_code,
564 'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None,
565 'case_size': float(pending.case_size) if pending.case_size is not None else None,
566 'notes': pending.notes,
567 'special_order': pending.special_order,
568 }
570 # TODO: remove this
571 data['product_key'] = row.product_scancode
573 # display text for order qty/uom
574 if row.order_uom == enum.ORDER_UOM_CASE:
575 if row.case_size is None:
576 case_qty = unit_qty = '??'
577 else:
578 case_qty = data['case_size']
579 unit_qty = self.app.render_quantity(row.order_qty * row.case_size)
580 CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE]
581 EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
582 data['order_qty_display'] = (f"{data['order_qty']} {CS} "
583 f"(× {case_qty} = {unit_qty} {EA})")
584 else:
585 unit_qty = self.app.render_quantity(row.order_qty)
586 EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
587 data['order_qty_display'] = f"{unit_qty} {EA}"
589 return data
591 def get_instance_title(self, order):
592 """ """
593 return f"#{order.order_id} for {order.customer_name}"
595 def configure_form(self, f):
596 """ """
597 super().configure_form(f)
599 # pending_customer
600 f.set_node('pending_customer', PendingCustomerRef(self.request))
602 # total_price
603 f.set_node('total_price', WuttaMoney(self.request))
605 # created_by
606 f.set_node('created_by', UserRef(self.request))
607 f.set_readonly('created_by')
609 def get_xref_buttons(self, order):
610 """ """
611 buttons = super().get_xref_buttons(order)
612 model = self.app.model
613 session = self.Session()
615 if self.request.has_perm('neworder_batches.view'):
616 batch = session.query(model.NewOrderBatch)\
617 .filter(model.NewOrderBatch.id == order.order_id)\
618 .first()
619 if batch:
620 url = self.request.route_url('neworder_batches.view', uuid=batch.uuid)
621 buttons.append(
622 self.make_button("View the Batch", primary=True, icon_left='eye', url=url))
624 return buttons
626 def get_row_grid_data(self, order):
627 """ """
628 model = self.app.model
629 session = self.Session()
630 return session.query(model.OrderItem)\
631 .filter(model.OrderItem.order == order)
633 def configure_row_grid(self, g):
634 """ """
635 super().configure_row_grid(g)
636 enum = self.app.enum
638 # sequence
639 g.set_label('sequence', "Seq.", column_only=True)
640 g.set_link('sequence')
642 # product_scancode
643 g.set_link('product_scancode')
645 # product_brand
646 g.set_link('product_brand')
648 # product_description
649 g.set_link('product_description')
651 # product_size
652 g.set_link('product_size')
654 # TODO
655 # order_uom
656 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
658 # total_price
659 g.set_renderer('total_price', g.render_currency)
661 # status_code
662 g.set_renderer('status_code', self.render_status_code)
664 def render_status_code(self, item, key, value):
665 """ """
666 enum = self.app.enum
667 return enum.ORDER_ITEM_STATUS[value]
669 def get_row_action_url_view(self, item, i):
670 """ """
671 return self.request.route_url('order_items.view', uuid=item.uuid)
674class OrderItemView(MasterView):
675 """
676 Master view for :class:`~sideshow.db.model.orders.OrderItem`;
677 route prefix is ``order_items``.
679 Notable URLs provided by this class:
681 * ``/order-items/``
682 * ``/order-items/XXX``
684 Note that this does not expose create, edit or delete. The user
685 must perform various other workflow actions to modify the item.
686 """
687 model_class = OrderItem
688 model_title = "Order Item"
689 route_prefix = 'order_items'
690 url_prefix = '/order-items'
691 creatable = False
692 editable = False
693 deletable = False
695 labels = {
696 'order_id': "Order ID",
697 'product_id': "Product ID",
698 'product_scancode': "Scancode",
699 'product_brand': "Brand",
700 'product_description': "Description",
701 'product_size': "Size",
702 'department_name': "Department",
703 'order_uom': "Order UOM",
704 'status_code': "Status",
705 }
707 grid_columns = [
708 'order_id',
709 'customer_name',
710 # 'sequence',
711 'product_scancode',
712 'product_brand',
713 'product_description',
714 'product_size',
715 'department_name',
716 'special_order',
717 'order_qty',
718 'order_uom',
719 'total_price',
720 'status_code',
721 ]
723 sort_defaults = ('order_id', 'desc')
725 form_fields = [
726 'order',
727 # 'customer_name',
728 'sequence',
729 'product_id',
730 'pending_product',
731 'product_scancode',
732 'product_brand',
733 'product_description',
734 'product_size',
735 'product_weighed',
736 'department_id',
737 'department_name',
738 'special_order',
739 'order_qty',
740 'order_uom',
741 'case_size',
742 'unit_cost',
743 'unit_price_reg',
744 'unit_price_sale',
745 'sale_ends',
746 'unit_price_quoted',
747 'case_price_quoted',
748 'discount_percent',
749 'total_price',
750 'status_code',
751 'paid_amount',
752 'payment_transaction_number',
753 ]
755 def get_query(self, session=None):
756 """ """
757 query = super().get_query(session=session)
758 model = self.app.model
759 return query.join(model.Order)
761 def configure_grid(self, g):
762 """ """
763 super().configure_grid(g)
764 model = self.app.model
765 # enum = self.app.enum
767 # order_id
768 g.set_sorter('order_id', model.Order.order_id)
769 g.set_renderer('order_id', self.render_order_id)
770 g.set_link('order_id')
772 # customer_name
773 g.set_label('customer_name', "Customer", column_only=True)
775 # # sequence
776 # g.set_label('sequence', "Seq.", column_only=True)
778 # product_scancode
779 g.set_link('product_scancode')
781 # product_brand
782 g.set_link('product_brand')
784 # product_description
785 g.set_link('product_description')
787 # product_size
788 g.set_link('product_size')
790 # order_uom
791 # TODO
792 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
794 # total_price
795 g.set_renderer('total_price', g.render_currency)
797 # status_code
798 g.set_renderer('status_code', self.render_status_code)
800 def render_order_id(self, item, key, value):
801 """ """
802 return item.order.order_id
804 def render_status_code(self, item, key, value):
805 """ """
806 enum = self.app.enum
807 return enum.ORDER_ITEM_STATUS[value]
809 def configure_form(self, f):
810 """ """
811 super().configure_form(f)
812 enum = self.app.enum
814 # order
815 f.set_node('order', OrderRef(self.request))
817 # pending_product
818 f.set_node('pending_product', PendingProductRef(self.request))
820 # order_qty
821 f.set_node('order_qty', WuttaQuantity(self.request))
823 # order_uom
824 # TODO
825 #f.set_node('order_uom', WuttaEnum(self.request, enum.OrderUOM))
827 # case_size
828 f.set_node('case_size', WuttaQuantity(self.request))
830 # unit_price_quoted
831 f.set_node('unit_price_quoted', WuttaMoney(self.request))
833 # case_price_quoted
834 f.set_node('case_price_quoted', WuttaMoney(self.request))
836 # total_price
837 f.set_node('total_price', WuttaMoney(self.request))
839 # paid_amount
840 f.set_node('paid_amount', WuttaMoney(self.request))
842 def get_xref_buttons(self, item):
843 """ """
844 buttons = super().get_xref_buttons(item)
845 model = self.app.model
847 if self.request.has_perm('orders.view'):
848 url = self.request.route_url('orders.view', uuid=item.order_uuid)
849 buttons.append(
850 self.make_button("View the Order", primary=True, icon_left='eye', url=url))
852 return buttons
855def defaults(config, **kwargs):
856 base = globals()
858 OrderView = kwargs.get('OrderView', base['OrderView'])
859 OrderView.defaults(config)
861 OrderItemView = kwargs.get('OrderItemView', base['OrderItemView'])
862 OrderItemView.defaults(config)
865def includeme(config):
866 defaults(config)