Coverage for src/sideshow/web/views/orders.py: 100%
700 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-23 16:52 -0600
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-23 16:52 -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
31import sqlalchemy as sa
32from sqlalchemy import orm
34from webhelpers2.html import tags, HTML
36from wuttaweb.views import MasterView
37from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum
39from sideshow.db.model import Order, OrderItem
40from sideshow.orders import OrderHandler
41from sideshow.batch.neworder import NewOrderBatchHandler
42from sideshow.web.forms.schema import (OrderRef,
43 LocalCustomerRef, LocalProductRef,
44 PendingCustomerRef, PendingProductRef)
47log = logging.getLogger(__name__)
50class OrderView(MasterView):
51 """
52 Master view for :class:`~sideshow.db.model.orders.Order`; route
53 prefix is ``orders``.
55 Notable URLs provided by this class:
57 * ``/orders/``
58 * ``/orders/new``
59 * ``/orders/XXX``
60 * ``/orders/XXX/delete``
62 Note that the "edit" view is not exposed here; user must perform
63 various other workflow actions to modify the order.
65 .. attribute:: order_handler
67 Reference to the :term:`order handler` as returned by
68 :meth:`get_order_handler()`. This gets set in the constructor.
70 .. attribute:: batch_handler
72 Reference to the :term:`new order batch` handler, as returned
73 by :meth:`get_batch_handler()`. This gets set in the
74 constructor.
75 """
76 model_class = Order
77 editable = False
78 configurable = True
80 labels = {
81 'order_id': "Order ID",
82 'store_id': "Store ID",
83 'customer_id': "Customer ID",
84 }
86 grid_columns = [
87 'order_id',
88 'store_id',
89 'customer_id',
90 'customer_name',
91 'total_price',
92 'created',
93 'created_by',
94 ]
96 sort_defaults = ('order_id', 'desc')
98 form_fields = [
99 'order_id',
100 'store_id',
101 'customer_id',
102 'local_customer',
103 'pending_customer',
104 'customer_name',
105 'phone_number',
106 'email_address',
107 'total_price',
108 'created',
109 'created_by',
110 ]
112 has_rows = True
113 row_model_class = OrderItem
114 rows_title = "Order Items"
115 rows_sort_defaults = 'sequence'
116 rows_viewable = True
118 row_labels = {
119 'product_scancode': "Scancode",
120 'product_brand': "Brand",
121 'product_description': "Description",
122 'product_size': "Size",
123 'department_name': "Department",
124 'order_uom': "Order UOM",
125 'status_code': "Status",
126 }
128 row_grid_columns = [
129 'sequence',
130 'product_scancode',
131 'product_brand',
132 'product_description',
133 'product_size',
134 'department_name',
135 'special_order',
136 'order_qty',
137 'order_uom',
138 'total_price',
139 'status_code',
140 ]
142 PENDING_PRODUCT_ENTRY_FIELDS = [
143 'scancode',
144 'brand_name',
145 'description',
146 'size',
147 'department_name',
148 'vendor_name',
149 'vendor_item_code',
150 'case_size',
151 'unit_cost',
152 'unit_price_reg',
153 ]
155 def __init__(self, request, context=None):
156 super().__init__(request, context=context)
157 self.order_handler = self.get_order_handler()
159 def get_order_handler(self):
160 """
161 Returns the configured :term:`order handler`.
163 You normally would not need to call this, and can use
164 :attr:`order_handler` instead.
166 :rtype: :class:`~sideshow.orders.OrderHandler`
167 """
168 if hasattr(self, 'order_handler'):
169 return self.order_handler
170 return OrderHandler(self.config)
172 def get_batch_handler(self):
173 """
174 Returns the configured :term:`handler` for :term:`new order
175 batches <new order batch>`.
177 You normally would not need to call this, and can use
178 :attr:`batch_handler` instead.
180 :returns:
181 :class:`~sideshow.batch.neworder.NewOrderBatchHandler`
182 instance.
183 """
184 if hasattr(self, 'batch_handler'):
185 return self.batch_handler
186 return self.app.get_batch_handler('neworder')
188 def configure_grid(self, g):
189 """ """
190 super().configure_grid(g)
192 # order_id
193 g.set_link('order_id')
195 # customer_id
196 g.set_link('customer_id')
198 # customer_name
199 g.set_link('customer_name')
201 # total_price
202 g.set_renderer('total_price', g.render_currency)
204 def create(self):
205 """
206 Instead of the typical "create" view, this displays a "wizard"
207 of sorts.
209 Under the hood a
210 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` is
211 automatically created for the user when they first visit this
212 page. They can select a customer, add items etc.
214 When user is finished assembling the order (i.e. populating
215 the batch), they submit it. This of course executes the
216 batch, which in turn creates a true
217 :class:`~sideshow.db.model.orders.Order`, and user is
218 redirected to the "view order" page.
220 See also these methods which may be called from this one,
221 based on user actions:
223 * :meth:`start_over()`
224 * :meth:`cancel_order()`
225 * :meth:`assign_customer()`
226 * :meth:`unassign_customer()`
227 * :meth:`set_pending_customer()`
228 * :meth:`get_product_info()`
229 * :meth:`add_item()`
230 * :meth:`update_item()`
231 * :meth:`delete_item()`
232 * :meth:`submit_order()`
233 """
234 enum = self.app.enum
235 self.creating = True
236 self.batch_handler = self.get_batch_handler()
237 batch = self.get_current_batch()
239 context = self.get_context_customer(batch)
241 if self.request.method == 'POST':
243 # first we check for traditional form post
244 action = self.request.POST.get('action')
245 post_actions = [
246 'start_over',
247 'cancel_order',
248 ]
249 if action in post_actions:
250 return getattr(self, action)(batch)
252 # okay then, we'll assume newer JSON-style post params
253 data = dict(self.request.json_body)
254 action = data.pop('action')
255 json_actions = [
256 'assign_customer',
257 'unassign_customer',
258 # 'update_phone_number',
259 # 'update_email_address',
260 'set_pending_customer',
261 # 'get_customer_info',
262 # # 'set_customer_data',
263 'get_product_info',
264 # 'get_past_items',
265 'add_item',
266 'update_item',
267 'delete_item',
268 'submit_order',
269 ]
270 if action in json_actions:
271 try:
272 result = getattr(self, action)(batch, data)
273 except Exception as error:
274 log.warning("error calling json action for order", exc_info=True)
275 result = {'error': self.app.render_error(error)}
276 return self.json_response(result)
278 return self.json_response({'error': "unknown form action"})
280 context.update({
281 'batch': batch,
282 'normalized_batch': self.normalize_batch(batch),
283 'order_items': [self.normalize_row(row)
284 for row in batch.rows],
285 'default_uom_choices': self.get_default_uom_choices(),
286 'default_uom': None, # TODO?
287 'allow_unknown_products': (self.batch_handler.allow_unknown_products()
288 and self.has_perm('create_unknown_product')),
289 'pending_product_required_fields': self.get_pending_product_required_fields(),
290 })
291 return self.render_to_response('create', context)
293 def get_current_batch(self):
294 """
295 Returns the current batch for the current user.
297 This looks for a new order batch which was created by the
298 user, but not yet executed. If none is found, a new batch is
299 created.
301 :returns:
302 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
303 instance
304 """
305 model = self.app.model
306 session = self.Session()
308 user = self.request.user
309 if not user:
310 raise self.forbidden()
312 try:
313 # there should be at most *one* new batch per user
314 batch = session.query(model.NewOrderBatch)\
315 .filter(model.NewOrderBatch.created_by == user)\
316 .filter(model.NewOrderBatch.executed == None)\
317 .one()
319 except orm.exc.NoResultFound:
320 # no batch yet for this user, so make one
321 batch = self.batch_handler.make_batch(session, created_by=user)
322 session.add(batch)
323 session.flush()
325 return batch
327 def customer_autocomplete(self):
328 """
329 AJAX view for customer autocomplete, when entering new order.
331 This invokes one of the following on the
332 :attr:`batch_handler`:
334 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_customers_external()`
335 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_customers_local()`
337 :returns: List of search results; each should be a dict with
338 ``value`` and ``label`` keys.
339 """
340 session = self.Session()
341 term = self.request.GET.get('term', '').strip()
342 if not term:
343 return []
345 handler = self.get_batch_handler()
346 if handler.use_local_customers():
347 return handler.autocomplete_customers_local(session, term, user=self.request.user)
348 else:
349 return handler.autocomplete_customers_external(session, term, user=self.request.user)
351 def product_autocomplete(self):
352 """
353 AJAX view for product autocomplete, when entering new order.
355 This invokes one of the following on the
356 :attr:`batch_handler`:
358 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_products_external()`
359 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_products_local()`
361 :returns: List of search results; each should be a dict with
362 ``value`` and ``label`` keys.
363 """
364 session = self.Session()
365 term = self.request.GET.get('term', '').strip()
366 if not term:
367 return []
369 handler = self.get_batch_handler()
370 if handler.use_local_products():
371 return handler.autocomplete_products_local(session, term, user=self.request.user)
372 else:
373 return handler.autocomplete_products_external(session, term, user=self.request.user)
375 def get_pending_product_required_fields(self):
376 """ """
377 required = []
378 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
379 require = self.config.get_bool(
380 f'sideshow.orders.unknown_product.fields.{field}.required')
381 if require is None and field == 'description':
382 require = True
383 if require:
384 required.append(field)
385 return required
387 def start_over(self, batch):
388 """
389 This will delete the user's current batch, then redirect user
390 back to "Create Order" page, which in turn will auto-create a
391 new batch for them.
393 This is a "batch action" method which may be called from
394 :meth:`create()`. See also:
396 * :meth:`cancel_order()`
397 * :meth:`submit_order()`
398 """
399 # drop current batch
400 self.batch_handler.do_delete(batch, self.request.user)
401 self.Session.flush()
403 # send back to "create order" which makes new batch
404 route_prefix = self.get_route_prefix()
405 url = self.request.route_url(f'{route_prefix}.create')
406 return self.redirect(url)
408 def cancel_order(self, batch):
409 """
410 This will delete the user's current batch, then redirect user
411 back to "List Orders" page.
413 This is a "batch action" method which may be called from
414 :meth:`create()`. See also:
416 * :meth:`start_over()`
417 * :meth:`submit_order()`
418 """
419 self.batch_handler.do_delete(batch, self.request.user)
420 self.Session.flush()
422 # set flash msg just to be more obvious
423 self.request.session.flash("New order has been deleted.")
425 # send user back to orders list, w/ no new batch generated
426 url = self.get_index_url()
427 return self.redirect(url)
429 def get_context_customer(self, batch):
430 """ """
431 context = {
432 'customer_is_known': True,
433 'customer_id': None,
434 'customer_name': batch.customer_name,
435 'phone_number': batch.phone_number,
436 'email_address': batch.email_address,
437 }
439 # customer_id
440 use_local = self.batch_handler.use_local_customers()
441 if use_local:
442 local = batch.local_customer
443 if local:
444 context['customer_id'] = local.uuid.hex
445 else: # use external
446 context['customer_id'] = batch.customer_id
448 # pending customer
449 pending = batch.pending_customer
450 if pending:
451 context.update({
452 'new_customer_first_name': pending.first_name,
453 'new_customer_last_name': pending.last_name,
454 'new_customer_full_name': pending.full_name,
455 'new_customer_phone': pending.phone_number,
456 'new_customer_email': pending.email_address,
457 })
459 # declare customer "not known" only if pending is in use
460 if (pending
461 and not batch.customer_id and not batch.local_customer
462 and batch.customer_name):
463 context['customer_is_known'] = False
465 return context
467 def assign_customer(self, batch, data):
468 """
469 Assign the true customer account for a batch.
471 This calls
472 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
473 for the heavy lifting.
475 This is a "batch action" method which may be called from
476 :meth:`create()`. See also:
478 * :meth:`unassign_customer()`
479 * :meth:`set_pending_customer()`
480 """
481 customer_id = data.get('customer_id')
482 if not customer_id:
483 return {'error': "Must provide customer_id"}
485 self.batch_handler.set_customer(batch, customer_id)
486 return self.get_context_customer(batch)
488 def unassign_customer(self, batch, data):
489 """
490 Clear the customer info for a batch.
492 This calls
493 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
494 for the heavy lifting.
496 This is a "batch action" method which may be called from
497 :meth:`create()`. See also:
499 * :meth:`assign_customer()`
500 * :meth:`set_pending_customer()`
501 """
502 self.batch_handler.set_customer(batch, None)
503 return self.get_context_customer(batch)
505 def set_pending_customer(self, batch, data):
506 """
507 This will set/update the batch pending customer info.
509 This calls
510 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
511 for the heavy lifting.
513 This is a "batch action" method which may be called from
514 :meth:`create()`. See also:
516 * :meth:`assign_customer()`
517 * :meth:`unassign_customer()`
518 """
519 self.batch_handler.set_customer(batch, data, user=self.request.user)
520 return self.get_context_customer(batch)
522 def get_product_info(self, batch, data):
523 """
524 Fetch data for a specific product. (Nothing is modified.)
526 Depending on config, this will fetch a :term:`local product`
527 or :term:`external product` to get the data.
529 This should invoke a configured handler for the query
530 behavior, but that is not yet implemented. For now it uses
531 built-in logic only, which queries the
532 :class:`~sideshow.db.model.products.LocalProduct` table.
534 This is a "batch action" method which may be called from
535 :meth:`create()`.
536 """
537 product_id = data.get('product_id')
538 if not product_id:
539 return {'error': "Must specify a product ID"}
541 session = self.Session()
542 use_local = self.batch_handler.use_local_products()
543 if use_local:
544 data = self.batch_handler.get_product_info_local(session, product_id)
545 else:
546 data = self.batch_handler.get_product_info_external(session, product_id)
548 if 'error' in data:
549 return data
551 if 'unit_price_reg' in data and 'unit_price_reg_display' not in data:
552 data['unit_price_reg_display'] = self.app.render_currency(data['unit_price_reg'])
554 if 'unit_price_reg' in data and 'unit_price_quoted' not in data:
555 data['unit_price_quoted'] = data['unit_price_reg']
557 if 'unit_price_quoted' in data and 'unit_price_quoted_display' not in data:
558 data['unit_price_quoted_display'] = self.app.render_currency(data['unit_price_quoted'])
560 if 'case_price_quoted' not in data:
561 if data.get('unit_price_quoted') is not None and data.get('case_size') is not None:
562 data['case_price_quoted'] = data['unit_price_quoted'] * data['case_size']
564 if 'case_price_quoted' in data and 'case_price_quoted_display' not in data:
565 data['case_price_quoted_display'] = self.app.render_currency(data['case_price_quoted'])
567 decimal_fields = [
568 'case_size',
569 'unit_price_reg',
570 'unit_price_quoted',
571 'case_price_quoted',
572 ]
574 for field in decimal_fields:
575 if field in list(data):
576 value = data[field]
577 if isinstance(value, decimal.Decimal):
578 data[field] = float(value)
580 return data
582 def add_item(self, batch, data):
583 """
584 This adds a row to the user's current new order batch.
586 This is a "batch action" method which may be called from
587 :meth:`create()`. See also:
589 * :meth:`update_item()`
590 * :meth:`delete_item()`
591 """
592 row = self.batch_handler.add_item(batch, data['product_info'],
593 data['order_qty'], data['order_uom'])
595 return {'batch': self.normalize_batch(batch),
596 'row': self.normalize_row(row)}
598 def update_item(self, batch, data):
599 """
600 This updates a row in the user's current new order batch.
602 This is a "batch action" method which may be called from
603 :meth:`create()`. See also:
605 * :meth:`add_item()`
606 * :meth:`delete_item()`
607 """
608 model = self.app.model
609 session = self.Session()
611 uuid = data.get('uuid')
612 if not uuid:
613 return {'error': "Must specify row UUID"}
615 row = session.get(model.NewOrderBatchRow, uuid)
616 if not row:
617 return {'error': "Row not found"}
619 if row.batch is not batch:
620 return {'error': "Row is for wrong batch"}
622 self.batch_handler.update_item(row, data['product_info'],
623 data['order_qty'], data['order_uom'])
625 return {'batch': self.normalize_batch(batch),
626 'row': self.normalize_row(row)}
628 def delete_item(self, batch, data):
629 """
630 This deletes a row from the user's current new order batch.
632 This is a "batch action" method which may be called from
633 :meth:`create()`. See also:
635 * :meth:`add_item()`
636 * :meth:`update_item()`
637 """
638 model = self.app.model
639 session = self.app.get_session(batch)
641 uuid = data.get('uuid')
642 if not uuid:
643 return {'error': "Must specify a row UUID"}
645 row = session.get(model.NewOrderBatchRow, uuid)
646 if not row:
647 return {'error': "Row not found"}
649 if row.batch is not batch:
650 return {'error': "Row is for wrong batch"}
652 self.batch_handler.do_remove_row(row)
653 return {'batch': self.normalize_batch(batch)}
655 def submit_order(self, batch, data):
656 """
657 This submits the user's current new order batch, hence
658 executing the batch and creating the true order.
660 This is a "batch action" method which may be called from
661 :meth:`create()`. See also:
663 * :meth:`start_over()`
664 * :meth:`cancel_order()`
665 """
666 user = self.request.user
667 reason = self.batch_handler.why_not_execute(batch, user=user)
668 if reason:
669 return {'error': reason}
671 try:
672 order = self.batch_handler.do_execute(batch, user)
673 except Exception as error:
674 log.warning("failed to execute new order batch: %s", batch,
675 exc_info=True)
676 return {'error': self.app.render_error(error)}
678 return {
679 'next_url': self.get_action_url('view', order),
680 }
682 def normalize_batch(self, batch):
683 """ """
684 return {
685 'uuid': batch.uuid.hex,
686 'total_price': str(batch.total_price or 0),
687 'total_price_display': self.app.render_currency(batch.total_price),
688 'status_code': batch.status_code,
689 'status_text': batch.status_text,
690 }
692 def get_default_uom_choices(self):
693 """ """
694 enum = self.app.enum
695 return [{'key': key, 'value': val}
696 for key, val in enum.ORDER_UOM.items()]
698 def normalize_row(self, row):
699 """ """
700 data = {
701 'uuid': row.uuid.hex,
702 'sequence': row.sequence,
703 'product_id': None,
704 'product_scancode': row.product_scancode,
705 'product_brand': row.product_brand,
706 'product_description': row.product_description,
707 'product_size': row.product_size,
708 'product_full_description': self.app.make_full_name(row.product_brand,
709 row.product_description,
710 row.product_size),
711 'product_weighed': row.product_weighed,
712 'department_display': row.department_name,
713 'special_order': row.special_order,
714 'case_size': float(row.case_size) if row.case_size is not None else None,
715 'order_qty': float(row.order_qty),
716 'order_uom': row.order_uom,
717 'order_uom_choices': self.get_default_uom_choices(),
718 'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None,
719 'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted),
720 'case_price_quoted': float(row.case_price_quoted) if row.case_price_quoted is not None else None,
721 'case_price_quoted_display': self.app.render_currency(row.case_price_quoted),
722 'total_price': float(row.total_price) if row.total_price is not None else None,
723 'total_price_display': self.app.render_currency(row.total_price),
724 'status_code': row.status_code,
725 'status_text': row.status_text,
726 }
728 use_local = self.batch_handler.use_local_products()
730 # product_id
731 if use_local:
732 if row.local_product:
733 data['product_id'] = row.local_product.uuid.hex
734 else:
735 data['product_id'] = row.product_id
737 # vendor_name
738 if use_local:
739 if row.local_product:
740 data['vendor_name'] = row.local_product.vendor_name
741 else: # use external
742 pass # TODO
743 if not data.get('product_id') and row.pending_product:
744 data['vendor_name'] = row.pending_product.vendor_name
746 if row.unit_price_reg:
747 data['unit_price_reg'] = float(row.unit_price_reg)
748 data['unit_price_reg_display'] = self.app.render_currency(row.unit_price_reg)
750 if row.unit_price_sale:
751 data['unit_price_sale'] = float(row.unit_price_sale)
752 data['unit_price_sale_display'] = self.app.render_currency(row.unit_price_sale)
753 if row.sale_ends:
754 sale_ends = row.sale_ends
755 data['sale_ends'] = str(row.sale_ends)
756 data['sale_ends_display'] = self.app.render_date(row.sale_ends)
758 if row.pending_product:
759 pending = row.pending_product
760 data['pending_product'] = {
761 'uuid': pending.uuid.hex,
762 'scancode': pending.scancode,
763 'brand_name': pending.brand_name,
764 'description': pending.description,
765 'size': pending.size,
766 'department_id': pending.department_id,
767 'department_name': pending.department_name,
768 'unit_price_reg': float(pending.unit_price_reg) if pending.unit_price_reg is not None else None,
769 'vendor_name': pending.vendor_name,
770 'vendor_item_code': pending.vendor_item_code,
771 'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None,
772 'case_size': float(pending.case_size) if pending.case_size is not None else None,
773 'notes': pending.notes,
774 'special_order': pending.special_order,
775 }
777 # display text for order qty/uom
778 data['order_qty_display'] = self.order_handler.get_order_qty_uom_text(
779 row.order_qty, row.order_uom, case_size=row.case_size, html=True)
781 return data
783 def get_instance_title(self, order):
784 """ """
785 return f"#{order.order_id} for {order.customer_name}"
787 def configure_form(self, f):
788 """ """
789 super().configure_form(f)
790 order = f.model_instance
792 # local_customer
793 if order.customer_id and not order.local_customer:
794 f.remove('local_customer')
795 else:
796 f.set_node('local_customer', LocalCustomerRef(self.request))
798 # pending_customer
799 if order.customer_id or order.local_customer:
800 f.remove('pending_customer')
801 else:
802 f.set_node('pending_customer', PendingCustomerRef(self.request))
804 # total_price
805 f.set_node('total_price', WuttaMoney(self.request))
807 # created_by
808 f.set_node('created_by', UserRef(self.request))
809 f.set_readonly('created_by')
811 def get_xref_buttons(self, order):
812 """ """
813 buttons = super().get_xref_buttons(order)
814 model = self.app.model
815 session = self.Session()
817 if self.request.has_perm('neworder_batches.view'):
818 batch = session.query(model.NewOrderBatch)\
819 .filter(model.NewOrderBatch.id == order.order_id)\
820 .first()
821 if batch:
822 url = self.request.route_url('neworder_batches.view', uuid=batch.uuid)
823 buttons.append(
824 self.make_button("View the Batch", primary=True, icon_left='eye', url=url))
826 return buttons
828 def get_row_grid_data(self, order):
829 """ """
830 model = self.app.model
831 session = self.Session()
832 return session.query(model.OrderItem)\
833 .filter(model.OrderItem.order == order)
835 def configure_row_grid(self, g):
836 """ """
837 super().configure_row_grid(g)
838 # enum = self.app.enum
840 # sequence
841 g.set_label('sequence', "Seq.", column_only=True)
842 g.set_link('sequence')
844 # product_scancode
845 g.set_link('product_scancode')
847 # product_brand
848 g.set_link('product_brand')
850 # product_description
851 g.set_link('product_description')
853 # product_size
854 g.set_link('product_size')
856 # TODO
857 # order_uom
858 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
860 # total_price
861 g.set_renderer('total_price', g.render_currency)
863 # status_code
864 g.set_renderer('status_code', self.render_status_code)
866 # TODO: upstream should set this automatically
867 g.row_class = self.row_grid_row_class
869 def row_grid_row_class(self, item, data, i):
870 """ """
871 variant = self.order_handler.item_status_to_variant(item.status_code)
872 if variant:
873 return f'has-background-{variant}'
875 def render_status_code(self, item, key, value):
876 """ """
877 enum = self.app.enum
878 return enum.ORDER_ITEM_STATUS[value]
880 def get_row_action_url_view(self, item, i):
881 """ """
882 return self.request.route_url('order_items.view', uuid=item.uuid)
884 def configure_get_simple_settings(self):
885 """ """
886 settings = [
888 # batches
889 {'name': 'wutta.batch.neworder.handler.spec'},
891 # customers
892 {'name': 'sideshow.orders.use_local_customers',
893 # nb. this is really a bool but we present as string in config UI
894 #'type': bool,
895 'default': 'true'},
897 # products
898 {'name': 'sideshow.orders.use_local_products',
899 # nb. this is really a bool but we present as string in config UI
900 #'type': bool,
901 'default': 'true'},
902 {'name': 'sideshow.orders.allow_unknown_products',
903 'type': bool,
904 'default': True},
905 ]
907 # required fields for new product entry
908 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
909 setting = {'name': f'sideshow.orders.unknown_product.fields.{field}.required',
910 'type': bool}
911 if field == 'description':
912 setting['default'] = True
913 settings.append(setting)
915 return settings
917 def configure_get_context(self, **kwargs):
918 """ """
919 context = super().configure_get_context(**kwargs)
921 context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS
923 handlers = self.app.get_batch_handler_specs('neworder')
924 handlers = [{'spec': spec} for spec in handlers]
925 context['batch_handlers'] = handlers
927 return context
929 @classmethod
930 def defaults(cls, config):
931 cls._order_defaults(config)
932 cls._defaults(config)
934 @classmethod
935 def _order_defaults(cls, config):
936 route_prefix = cls.get_route_prefix()
937 permission_prefix = cls.get_permission_prefix()
938 url_prefix = cls.get_url_prefix()
939 model_title = cls.get_model_title()
940 model_title_plural = cls.get_model_title_plural()
942 # fix perm group
943 config.add_wutta_permission_group(permission_prefix,
944 model_title_plural,
945 overwrite=False)
947 # extra perm required to create order with unknown/pending product
948 config.add_wutta_permission(permission_prefix,
949 f'{permission_prefix}.create_unknown_product',
950 f"Create new {model_title} for unknown/pending product")
952 # customer autocomplete
953 config.add_route(f'{route_prefix}.customer_autocomplete',
954 f'{url_prefix}/customer-autocomplete',
955 request_method='GET')
956 config.add_view(cls, attr='customer_autocomplete',
957 route_name=f'{route_prefix}.customer_autocomplete',
958 renderer='json',
959 permission=f'{permission_prefix}.list')
961 # product autocomplete
962 config.add_route(f'{route_prefix}.product_autocomplete',
963 f'{url_prefix}/product-autocomplete',
964 request_method='GET')
965 config.add_view(cls, attr='product_autocomplete',
966 route_name=f'{route_prefix}.product_autocomplete',
967 renderer='json',
968 permission=f'{permission_prefix}.list')
971class OrderItemView(MasterView):
972 """
973 Master view for :class:`~sideshow.db.model.orders.OrderItem`;
974 route prefix is ``order_items``.
976 Notable URLs provided by this class:
978 * ``/order-items/``
979 * ``/order-items/XXX``
981 This class serves both as a proper master view (for "all" order
982 items) as well as a base class for other "workflow" master views,
983 each of which auto-filters by order item status:
985 * :class:`PlacementView`
986 * :class:`ReceivingView`
987 * :class:`ContactView`
988 * :class:`DeliveryView`
990 Note that this does not expose create, edit or delete. The user
991 must perform various other workflow actions to modify the item.
993 .. attribute:: order_handler
995 Reference to the :term:`order handler` as returned by
996 :meth:`get_order_handler()`.
997 """
998 model_class = OrderItem
999 model_title = "Order Item (All)"
1000 model_title_plural = "Order Items (All)"
1001 route_prefix = 'order_items'
1002 url_prefix = '/order-items'
1003 creatable = False
1004 editable = False
1005 deletable = False
1007 labels = {
1008 'order_id': "Order ID",
1009 'product_id': "Product ID",
1010 'product_scancode': "Scancode",
1011 'product_brand': "Brand",
1012 'product_description': "Description",
1013 'product_size': "Size",
1014 'product_weighed': "Sold by Weight",
1015 'department_id': "Department ID",
1016 'order_uom': "Order UOM",
1017 'status_code': "Status",
1018 }
1020 grid_columns = [
1021 'order_id',
1022 'customer_name',
1023 # 'sequence',
1024 'product_scancode',
1025 'product_brand',
1026 'product_description',
1027 'product_size',
1028 'department_name',
1029 'special_order',
1030 'order_qty',
1031 'order_uom',
1032 'total_price',
1033 'status_code',
1034 ]
1036 sort_defaults = ('order_id', 'desc')
1038 form_fields = [
1039 'order',
1040 # 'customer_name',
1041 'sequence',
1042 'product_id',
1043 'local_product',
1044 'pending_product',
1045 'product_scancode',
1046 'product_brand',
1047 'product_description',
1048 'product_size',
1049 'product_weighed',
1050 'department_id',
1051 'department_name',
1052 'special_order',
1053 'case_size',
1054 'unit_cost',
1055 'unit_price_reg',
1056 'unit_price_sale',
1057 'sale_ends',
1058 'unit_price_quoted',
1059 'case_price_quoted',
1060 'order_qty',
1061 'order_uom',
1062 'discount_percent',
1063 'total_price',
1064 'status_code',
1065 'paid_amount',
1066 'payment_transaction_number',
1067 ]
1069 def __init__(self, request, context=None):
1070 super().__init__(request, context=context)
1071 self.order_handler = self.get_order_handler()
1073 def get_order_handler(self):
1074 """
1075 Returns the configured :term:`order handler`.
1077 You normally would not need to call this, and can use
1078 :attr:`order_handler` instead.
1080 :rtype: :class:`~sideshow.orders.OrderHandler`
1081 """
1082 if hasattr(self, 'order_handler'):
1083 return self.order_handler
1084 return OrderHandler(self.config)
1086 def get_fallback_templates(self, template):
1087 """ """
1088 templates = super().get_fallback_templates(template)
1089 templates.insert(0, f'/order-items/{template}.mako')
1090 return templates
1092 def get_query(self, session=None):
1093 """ """
1094 query = super().get_query(session=session)
1095 model = self.app.model
1096 return query.join(model.Order)
1098 def configure_grid(self, g):
1099 """ """
1100 super().configure_grid(g)
1101 model = self.app.model
1102 # enum = self.app.enum
1104 # order_id
1105 g.set_sorter('order_id', model.Order.order_id)
1106 g.set_renderer('order_id', self.render_order_id)
1107 g.set_link('order_id')
1109 # customer_name
1110 g.set_label('customer_name', "Customer", column_only=True)
1112 # # sequence
1113 # g.set_label('sequence', "Seq.", column_only=True)
1115 # product_scancode
1116 g.set_link('product_scancode')
1118 # product_brand
1119 g.set_link('product_brand')
1121 # product_description
1122 g.set_link('product_description')
1124 # product_size
1125 g.set_link('product_size')
1127 # order_uom
1128 # TODO
1129 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
1131 # total_price
1132 g.set_renderer('total_price', g.render_currency)
1134 # status_code
1135 g.set_renderer('status_code', self.render_status_code)
1137 def render_order_id(self, item, key, value):
1138 """ """
1139 return item.order.order_id
1141 def render_status_code(self, item, key, value):
1142 """ """
1143 enum = self.app.enum
1144 return enum.ORDER_ITEM_STATUS[value]
1146 def grid_row_class(self, item, data, i):
1147 """ """
1148 variant = self.order_handler.item_status_to_variant(item.status_code)
1149 if variant:
1150 return f'has-background-{variant}'
1152 def configure_form(self, f):
1153 """ """
1154 super().configure_form(f)
1155 enum = self.app.enum
1156 item = f.model_instance
1158 # order
1159 f.set_node('order', OrderRef(self.request))
1161 # local_product
1162 f.set_node('local_product', LocalProductRef(self.request))
1164 # pending_product
1165 if item.product_id or item.local_product:
1166 f.remove('pending_product')
1167 else:
1168 f.set_node('pending_product', PendingProductRef(self.request))
1170 # order_qty
1171 f.set_node('order_qty', WuttaQuantity(self.request))
1173 # order_uom
1174 f.set_node('order_uom', WuttaDictEnum(self.request, enum.ORDER_UOM))
1176 # case_size
1177 f.set_node('case_size', WuttaQuantity(self.request))
1179 # unit_cost
1180 f.set_node('unit_cost', WuttaMoney(self.request, scale=4))
1182 # unit_price_reg
1183 f.set_node('unit_price_reg', WuttaMoney(self.request))
1185 # unit_price_quoted
1186 f.set_node('unit_price_quoted', WuttaMoney(self.request))
1188 # case_price_quoted
1189 f.set_node('case_price_quoted', WuttaMoney(self.request))
1191 # total_price
1192 f.set_node('total_price', WuttaMoney(self.request))
1194 # status
1195 f.set_node('status_code', WuttaDictEnum(self.request, enum.ORDER_ITEM_STATUS))
1197 # paid_amount
1198 f.set_node('paid_amount', WuttaMoney(self.request))
1200 def get_template_context(self, context):
1201 """ """
1202 if self.viewing:
1203 model = self.app.model
1204 enum = self.app.enum
1205 route_prefix = self.get_route_prefix()
1206 item = context['instance']
1207 form = context['form']
1209 context['item'] = item
1210 context['order'] = item.order
1211 context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text(
1212 item.order_qty, item.order_uom, case_size=item.case_size, html=True)
1213 context['item_status_variant'] = self.order_handler.item_status_to_variant(item.status_code)
1215 grid = self.make_grid(key=f'{route_prefix}.view.events',
1216 model_class=model.OrderItemEvent,
1217 data=item.events,
1218 columns=[
1219 'occurred',
1220 'actor',
1221 'type_code',
1222 'note',
1223 ],
1224 labels={
1225 'occurred': "Date/Time",
1226 'actor': "User",
1227 'type_code': "Event Type",
1228 })
1229 grid.set_renderer('type_code', lambda e, k, v: enum.ORDER_ITEM_EVENT[v])
1230 grid.set_renderer('note', self.render_event_note)
1231 if self.request.has_perm('users.view'):
1232 grid.set_renderer('actor', lambda e, k, v: tags.link_to(
1233 e.actor, self.request.route_url('users.view', uuid=e.actor.uuid)))
1234 form.add_grid_vue_context(grid)
1235 context['events_grid'] = grid
1237 return context
1239 def render_event_note(self, event, key, value):
1240 """ """
1241 enum = self.app.enum
1242 if event.type_code == enum.ORDER_ITEM_EVENT_NOTE_ADDED:
1243 return HTML.tag('span', class_='has-background-info-light',
1244 style='padding: 0.25rem 0.5rem;',
1245 c=[value])
1246 return value
1248 def get_xref_buttons(self, item):
1249 """ """
1250 buttons = super().get_xref_buttons(item)
1252 if self.request.has_perm('orders.view'):
1253 url = self.request.route_url('orders.view', uuid=item.order_uuid)
1254 buttons.append(
1255 self.make_button("View the Order", url=url,
1256 primary=True, icon_left='eye'))
1258 return buttons
1260 def add_note(self):
1261 """
1262 View which adds a note to an order item. This is POST-only;
1263 will redirect back to the item view.
1264 """
1265 enum = self.app.enum
1266 item = self.get_instance()
1268 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, self.request.user,
1269 note=self.request.POST['note'])
1271 return self.redirect(self.get_action_url('view', item))
1273 def change_status(self):
1274 """
1275 View which changes status for an order item. This is
1276 POST-only; will redirect back to the item view.
1277 """
1278 model = self.app.model
1279 enum = self.app.enum
1280 main_item = self.get_instance()
1281 session = self.Session()
1282 redirect = self.redirect(self.get_action_url('view', main_item))
1284 extra_note = self.request.POST.get('note')
1286 # validate new status
1287 new_status_code = int(self.request.POST['new_status'])
1288 if new_status_code not in enum.ORDER_ITEM_STATUS:
1289 self.request.session.flash("Invalid status code", 'error')
1290 return redirect
1291 new_status_text = enum.ORDER_ITEM_STATUS[new_status_code]
1293 # locate all items to which new status will be applied
1294 items = [main_item]
1295 # uuids = self.request.POST.get('uuids')
1296 # if uuids:
1297 # for uuid in uuids.split(','):
1298 # item = Session.get(model.OrderItem, uuid)
1299 # if item:
1300 # items.append(item)
1302 # update item(s)
1303 for item in items:
1304 if item.status_code != new_status_code:
1306 # event: change status
1307 note = 'status changed from "{}" to "{}"'.format(
1308 enum.ORDER_ITEM_STATUS[item.status_code],
1309 new_status_text)
1310 item.add_event(enum.ORDER_ITEM_EVENT_STATUS_CHANGE,
1311 self.request.user, note=note)
1313 # event: add note
1314 if extra_note:
1315 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED,
1316 self.request.user, note=extra_note)
1318 # new status
1319 item.status_code = new_status_code
1321 self.request.session.flash(f"Status has been updated to: {new_status_text}")
1322 return redirect
1324 def get_order_items(self, uuids):
1325 """
1326 This method provides common logic to fetch a list of order
1327 items based on a list of UUID keys. It is used by various
1328 workflow action methods.
1330 Note that if no order items are found, this will set a flash
1331 warning message and raise a redirect back to the index page.
1333 :param uuids: List (or comma-delimited string) of UUID keys.
1335 :returns: List of :class:`~sideshow.db.model.orders.OrderItem`
1336 records.
1337 """
1338 model = self.app.model
1339 session = self.Session()
1341 if uuids is None:
1342 uuids = []
1343 elif isinstance(uuids, str):
1344 uuids = uuids.split(',')
1346 items = []
1347 for uuid in uuids:
1348 if isinstance(uuid, str):
1349 uuid = uuid.strip()
1350 if uuid:
1351 try:
1352 item = session.get(model.OrderItem, uuid)
1353 except sa.exc.StatementError:
1354 pass # nb. invalid UUID
1355 else:
1356 if item:
1357 items.append(item)
1359 if not items:
1360 self.request.session.flash("Must specify valid order item(s).", 'warning')
1361 raise self.redirect(self.get_index_url())
1363 return items
1365 @classmethod
1366 def defaults(cls, config):
1367 """ """
1368 cls._order_item_defaults(config)
1369 cls._defaults(config)
1371 @classmethod
1372 def _order_item_defaults(cls, config):
1373 """ """
1374 route_prefix = cls.get_route_prefix()
1375 permission_prefix = cls.get_permission_prefix()
1376 instance_url_prefix = cls.get_instance_url_prefix()
1377 model_title = cls.get_model_title()
1378 model_title_plural = cls.get_model_title_plural()
1380 # fix perm group
1381 config.add_wutta_permission_group(permission_prefix,
1382 model_title_plural,
1383 overwrite=False)
1385 # add note
1386 config.add_route(f'{route_prefix}.add_note',
1387 f'{instance_url_prefix}/add_note',
1388 request_method='POST')
1389 config.add_view(cls, attr='add_note',
1390 route_name=f'{route_prefix}.add_note',
1391 renderer='json',
1392 permission=f'{permission_prefix}.add_note')
1393 config.add_wutta_permission(permission_prefix,
1394 f'{permission_prefix}.add_note',
1395 f"Add note for {model_title}")
1397 # change status
1398 config.add_route(f'{route_prefix}.change_status',
1399 f'{instance_url_prefix}/change-status',
1400 request_method='POST')
1401 config.add_view(cls, attr='change_status',
1402 route_name=f'{route_prefix}.change_status',
1403 renderer='json',
1404 permission=f'{permission_prefix}.change_status')
1405 config.add_wutta_permission(permission_prefix,
1406 f'{permission_prefix}.change_status',
1407 f"Change status for {model_title}")
1410class PlacementView(OrderItemView):
1411 """
1412 Master view for the "placement" phase of
1413 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1414 ``placement``. This is a subclass of :class:`OrderItemView`.
1416 This class auto-filters so only order items with the following
1417 status codes are shown:
1419 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY`
1421 Notable URLs provided by this class:
1423 * ``/placement/``
1424 * ``/placement/XXX``
1425 """
1426 model_title = "Order Item (Placement)"
1427 model_title_plural = "Order Items (Placement)"
1428 route_prefix = 'order_items_placement'
1429 url_prefix = '/placement'
1431 def get_query(self, session=None):
1432 """ """
1433 query = super().get_query(session=session)
1434 model = self.app.model
1435 enum = self.app.enum
1436 return query.filter(model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_READY)
1438 def configure_grid(self, g):
1439 """ """
1440 super().configure_grid(g)
1442 # checkable
1443 if self.has_perm('process_placement'):
1444 g.checkable = True
1446 # tool button: Order Placed
1447 if self.has_perm('process_placement'):
1448 button = self.make_button("Order Placed", primary=True,
1449 icon_left='arrow-circle-right',
1450 **{'@click': "$emit('process-placement', checkedRows)",
1451 ':disabled': '!checkedRows.length'})
1452 g.add_tool(button, key='process_placement')
1454 def process_placement(self):
1455 """
1456 View to process the "placement" step for some order item(s).
1458 This requires a POST request with data:
1460 :param item_uuids: Comma-delimited list of
1461 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1463 :param vendor_name: Optional name of vendor.
1465 :param po_number: Optional PO number.
1467 :param note: Optional note text from the user.
1469 This invokes
1470 :meth:`~sideshow.orders.OrderHandler.process_placement()` on
1471 the :attr:`~OrderItemView.order_handler`, then redirects user
1472 back to the index page.
1473 """
1474 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1475 vendor_name = self.request.POST.get('vendor_name', '').strip() or None
1476 po_number = self.request.POST.get('po_number', '').strip() or None
1477 note = self.request.POST.get('note', '').strip() or None
1479 self.order_handler.process_placement(items, self.request.user,
1480 vendor_name=vendor_name,
1481 po_number=po_number,
1482 note=note)
1484 self.request.session.flash(f"{len(items)} Order Items were marked as placed")
1485 return self.redirect(self.get_index_url())
1487 @classmethod
1488 def defaults(cls, config):
1489 cls._order_item_defaults(config)
1490 cls._placement_defaults(config)
1491 cls._defaults(config)
1493 @classmethod
1494 def _placement_defaults(cls, config):
1495 route_prefix = cls.get_route_prefix()
1496 permission_prefix = cls.get_permission_prefix()
1497 url_prefix = cls.get_url_prefix()
1498 model_title_plural = cls.get_model_title_plural()
1500 # process placement
1501 config.add_wutta_permission(permission_prefix,
1502 f'{permission_prefix}.process_placement',
1503 f"Process placement for {model_title_plural}")
1504 config.add_route(f'{route_prefix}.process_placement',
1505 f'{url_prefix}/process-placement',
1506 request_method='POST')
1507 config.add_view(cls, attr='process_placement',
1508 route_name=f'{route_prefix}.process_placement',
1509 permission=f'{permission_prefix}.process_placement')
1512class ReceivingView(OrderItemView):
1513 """
1514 Master view for the "receiving" phase of
1515 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1516 ``receiving``. This is a subclass of :class:`OrderItemView`.
1518 This class auto-filters so only order items with the following
1519 status codes are shown:
1521 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_PLACED`
1523 Notable URLs provided by this class:
1525 * ``/receiving/``
1526 * ``/receiving/XXX``
1527 """
1528 model_title = "Order Item (Receiving)"
1529 model_title_plural = "Order Items (Receiving)"
1530 route_prefix = 'order_items_receiving'
1531 url_prefix = '/receiving'
1533 def get_query(self, session=None):
1534 """ """
1535 query = super().get_query(session=session)
1536 model = self.app.model
1537 enum = self.app.enum
1538 return query.filter(model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_PLACED)
1540 def configure_grid(self, g):
1541 """ """
1542 super().configure_grid(g)
1544 # checkable
1545 if self.has_any_perm('process_receiving', 'process_reorder'):
1546 g.checkable = True
1548 # tool button: Received
1549 if self.has_perm('process_receiving'):
1550 button = self.make_button("Received", primary=True,
1551 icon_left='arrow-circle-right',
1552 **{'@click': "$emit('process-receiving', checkedRows)",
1553 ':disabled': '!checkedRows.length'})
1554 g.add_tool(button, key='process_receiving')
1556 # tool button: Re-Order
1557 if self.has_perm('process_reorder'):
1558 button = self.make_button("Re-Order",
1559 icon_left='redo',
1560 **{'@click': "$emit('process-reorder', checkedRows)",
1561 ':disabled': '!checkedRows.length'})
1562 g.add_tool(button, key='process_reorder')
1564 def process_receiving(self):
1565 """
1566 View to process the "receiving" step for some order item(s).
1568 This requires a POST request with data:
1570 :param item_uuids: Comma-delimited list of
1571 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1573 :param vendor_name: Optional name of vendor.
1575 :param invoice_number: Optional invoice number.
1577 :param po_number: Optional PO number.
1579 :param note: Optional note text from the user.
1581 This invokes
1582 :meth:`~sideshow.orders.OrderHandler.process_receiving()` on
1583 the :attr:`~OrderItemView.order_handler`, then redirects user
1584 back to the index page.
1585 """
1586 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1587 vendor_name = self.request.POST.get('vendor_name', '').strip() or None
1588 invoice_number = self.request.POST.get('invoice_number', '').strip() or None
1589 po_number = self.request.POST.get('po_number', '').strip() or None
1590 note = self.request.POST.get('note', '').strip() or None
1592 self.order_handler.process_receiving(items, self.request.user,
1593 vendor_name=vendor_name,
1594 invoice_number=invoice_number,
1595 po_number=po_number,
1596 note=note)
1598 self.request.session.flash(f"{len(items)} Order Items were marked as received")
1599 return self.redirect(self.get_index_url())
1601 def process_reorder(self):
1602 """
1603 View to process the "reorder" step for some order item(s).
1605 This requires a POST request with data:
1607 :param item_uuids: Comma-delimited list of
1608 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1610 :param note: Optional note text from the user.
1612 This invokes
1613 :meth:`~sideshow.orders.OrderHandler.process_reorder()` on the
1614 :attr:`~OrderItemView.order_handler`, then redirects user back
1615 to the index page.
1616 """
1617 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1618 note = self.request.POST.get('note', '').strip() or None
1620 self.order_handler.process_reorder(items, self.request.user, note=note)
1622 self.request.session.flash(f"{len(items)} Order Items were marked as ready for placement")
1623 return self.redirect(self.get_index_url())
1625 @classmethod
1626 def defaults(cls, config):
1627 cls._order_item_defaults(config)
1628 cls._receiving_defaults(config)
1629 cls._defaults(config)
1631 @classmethod
1632 def _receiving_defaults(cls, config):
1633 route_prefix = cls.get_route_prefix()
1634 permission_prefix = cls.get_permission_prefix()
1635 url_prefix = cls.get_url_prefix()
1636 model_title_plural = cls.get_model_title_plural()
1638 # process receiving
1639 config.add_wutta_permission(permission_prefix,
1640 f'{permission_prefix}.process_receiving',
1641 f"Process receiving for {model_title_plural}")
1642 config.add_route(f'{route_prefix}.process_receiving',
1643 f'{url_prefix}/process-receiving',
1644 request_method='POST')
1645 config.add_view(cls, attr='process_receiving',
1646 route_name=f'{route_prefix}.process_receiving',
1647 permission=f'{permission_prefix}.process_receiving')
1649 # process reorder
1650 config.add_wutta_permission(permission_prefix,
1651 f'{permission_prefix}.process_reorder',
1652 f"Process re-order for {model_title_plural}")
1653 config.add_route(f'{route_prefix}.process_reorder',
1654 f'{url_prefix}/process-reorder',
1655 request_method='POST')
1656 config.add_view(cls, attr='process_reorder',
1657 route_name=f'{route_prefix}.process_reorder',
1658 permission=f'{permission_prefix}.process_reorder')
1661class ContactView(OrderItemView):
1662 """
1663 Master view for the "contact" phase of
1664 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1665 ``contact``. This is a subclass of :class:`OrderItemView`.
1667 This class auto-filters so only order items with the following
1668 status codes are shown:
1670 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED`
1671 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACT_FAILED`
1673 Notable URLs provided by this class:
1675 * ``/contact/``
1676 * ``/contact/XXX``
1677 """
1678 model_title = "Order Item (Contact)"
1679 model_title_plural = "Order Items (Contact)"
1680 route_prefix = 'order_items_contact'
1681 url_prefix = '/contact'
1683 def get_query(self, session=None):
1684 """ """
1685 query = super().get_query(session=session)
1686 model = self.app.model
1687 enum = self.app.enum
1688 return query.filter(model.OrderItem.status_code.in_((
1689 enum.ORDER_ITEM_STATUS_RECEIVED,
1690 enum.ORDER_ITEM_STATUS_CONTACT_FAILED)))
1692 def configure_grid(self, g):
1693 """ """
1694 super().configure_grid(g)
1696 # checkable
1697 if self.has_perm('process_contact'):
1698 g.checkable = True
1700 # tool button: Contact Success
1701 if self.has_perm('process_contact'):
1702 button = self.make_button("Contact Success", primary=True,
1703 icon_left='phone',
1704 **{'@click': "$emit('process-contact-success', checkedRows)",
1705 ':disabled': '!checkedRows.length'})
1706 g.add_tool(button, key='process_contact_success')
1708 # tool button: Contact Failure
1709 if self.has_perm('process_contact'):
1710 button = self.make_button("Contact Failure", variant='is-warning',
1711 icon_left='phone',
1712 **{'@click': "$emit('process-contact-failure', checkedRows)",
1713 ':disabled': '!checkedRows.length'})
1714 g.add_tool(button, key='process_contact_failure')
1716 def process_contact_success(self):
1717 """
1718 View to process the "contact success" step for some order
1719 item(s).
1721 This requires a POST request with data:
1723 :param item_uuids: Comma-delimited list of
1724 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1726 :param note: Optional note text from the user.
1728 This invokes
1729 :meth:`~sideshow.orders.OrderHandler.process_contact_success()`
1730 on the :attr:`~OrderItemView.order_handler`, then redirects
1731 user back to the index page.
1732 """
1733 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1734 note = self.request.POST.get('note', '').strip() or None
1736 self.order_handler.process_contact_success(items, self.request.user, note=note)
1738 self.request.session.flash(f"{len(items)} Order Items were marked as contacted")
1739 return self.redirect(self.get_index_url())
1741 def process_contact_failure(self):
1742 """
1743 View to process the "contact failure" step for some order
1744 item(s).
1746 This requires a POST request with data:
1748 :param item_uuids: Comma-delimited list of
1749 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1751 :param note: Optional note text from the user.
1753 This invokes
1754 :meth:`~sideshow.orders.OrderHandler.process_contact_failure()`
1755 on the :attr:`~OrderItemView.order_handler`, then redirects
1756 user back to the index page.
1757 """
1758 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1759 note = self.request.POST.get('note', '').strip() or None
1761 self.order_handler.process_contact_failure(items, self.request.user, note=note)
1763 self.request.session.flash(f"{len(items)} Order Items were marked as contact failed")
1764 return self.redirect(self.get_index_url())
1766 @classmethod
1767 def defaults(cls, config):
1768 cls._order_item_defaults(config)
1769 cls._contact_defaults(config)
1770 cls._defaults(config)
1772 @classmethod
1773 def _contact_defaults(cls, config):
1774 route_prefix = cls.get_route_prefix()
1775 permission_prefix = cls.get_permission_prefix()
1776 url_prefix = cls.get_url_prefix()
1777 model_title_plural = cls.get_model_title_plural()
1779 # common perm for processing contact success + failure
1780 config.add_wutta_permission(permission_prefix,
1781 f'{permission_prefix}.process_contact',
1782 f"Process contact success/failure for {model_title_plural}")
1784 # process contact success
1785 config.add_route(f'{route_prefix}.process_contact_success',
1786 f'{url_prefix}/process-contact-success',
1787 request_method='POST')
1788 config.add_view(cls, attr='process_contact_success',
1789 route_name=f'{route_prefix}.process_contact_success',
1790 permission=f'{permission_prefix}.process_contact')
1792 # process contact failure
1793 config.add_route(f'{route_prefix}.process_contact_failure',
1794 f'{url_prefix}/process-contact-failure',
1795 request_method='POST')
1796 config.add_view(cls, attr='process_contact_failure',
1797 route_name=f'{route_prefix}.process_contact_failure',
1798 permission=f'{permission_prefix}.process_contact')
1801class DeliveryView(OrderItemView):
1802 """
1803 Master view for the "delivery" phase of
1804 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1805 ``delivery``. This is a subclass of :class:`OrderItemView`.
1807 This class auto-filters so only order items with the following
1808 status codes are shown:
1810 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED`
1811 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACTED`
1813 Notable URLs provided by this class:
1815 * ``/delivery/``
1816 * ``/delivery/XXX``
1817 """
1818 model_title = "Order Item (Delivery)"
1819 model_title_plural = "Order Items (Delivery)"
1820 route_prefix = 'order_items_delivery'
1821 url_prefix = '/delivery'
1823 def get_query(self, session=None):
1824 """ """
1825 query = super().get_query(session=session)
1826 model = self.app.model
1827 enum = self.app.enum
1828 return query.filter(model.OrderItem.status_code.in_((
1829 enum.ORDER_ITEM_STATUS_RECEIVED,
1830 enum.ORDER_ITEM_STATUS_CONTACTED)))
1832 def configure_grid(self, g):
1833 """ """
1834 super().configure_grid(g)
1836 # checkable
1837 if self.has_any_perm('process_delivery', 'process_restock'):
1838 g.checkable = True
1840 # tool button: Delivered
1841 if self.has_perm('process_delivery'):
1842 button = self.make_button("Delivered", primary=True,
1843 icon_left='check',
1844 **{'@click': "$emit('process-delivery', checkedRows)",
1845 ':disabled': '!checkedRows.length'})
1846 g.add_tool(button, key='process_delivery')
1848 # tool button: Restocked
1849 if self.has_perm('process_restock'):
1850 button = self.make_button("Restocked",
1851 icon_left='redo',
1852 **{'@click': "$emit('process-restock', checkedRows)",
1853 ':disabled': '!checkedRows.length'})
1854 g.add_tool(button, key='process_restock')
1856 def process_delivery(self):
1857 """
1858 View to process the "delivery" step for some order item(s).
1860 This requires a POST request with data:
1862 :param item_uuids: Comma-delimited list of
1863 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1865 :param note: Optional note text from the user.
1867 This invokes
1868 :meth:`~sideshow.orders.OrderHandler.process_delivery()` on
1869 the :attr:`~OrderItemView.order_handler`, then redirects user
1870 back to the index page.
1871 """
1872 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1873 note = self.request.POST.get('note', '').strip() or None
1875 self.order_handler.process_delivery(items, self.request.user, note=note)
1877 self.request.session.flash(f"{len(items)} Order Items were marked as delivered")
1878 return self.redirect(self.get_index_url())
1880 def process_restock(self):
1881 """
1882 View to process the "restock" step for some order item(s).
1884 This requires a POST request with data:
1886 :param item_uuids: Comma-delimited list of
1887 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1889 :param note: Optional note text from the user.
1891 This invokes
1892 :meth:`~sideshow.orders.OrderHandler.process_restock()` on the
1893 :attr:`~OrderItemView.order_handler`, then redirects user back
1894 to the index page.
1895 """
1896 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1897 note = self.request.POST.get('note', '').strip() or None
1899 self.order_handler.process_restock(items, self.request.user, note=note)
1901 self.request.session.flash(f"{len(items)} Order Items were marked as restocked")
1902 return self.redirect(self.get_index_url())
1904 @classmethod
1905 def defaults(cls, config):
1906 cls._order_item_defaults(config)
1907 cls._delivery_defaults(config)
1908 cls._defaults(config)
1910 @classmethod
1911 def _delivery_defaults(cls, config):
1912 route_prefix = cls.get_route_prefix()
1913 permission_prefix = cls.get_permission_prefix()
1914 url_prefix = cls.get_url_prefix()
1915 model_title_plural = cls.get_model_title_plural()
1917 # process delivery
1918 config.add_wutta_permission(permission_prefix,
1919 f'{permission_prefix}.process_delivery',
1920 f"Process delivery for {model_title_plural}")
1921 config.add_route(f'{route_prefix}.process_delivery',
1922 f'{url_prefix}/process-delivery',
1923 request_method='POST')
1924 config.add_view(cls, attr='process_delivery',
1925 route_name=f'{route_prefix}.process_delivery',
1926 permission=f'{permission_prefix}.process_delivery')
1928 # process restock
1929 config.add_wutta_permission(permission_prefix,
1930 f'{permission_prefix}.process_restock',
1931 f"Process restock for {model_title_plural}")
1932 config.add_route(f'{route_prefix}.process_restock',
1933 f'{url_prefix}/process-restock',
1934 request_method='POST')
1935 config.add_view(cls, attr='process_restock',
1936 route_name=f'{route_prefix}.process_restock',
1937 permission=f'{permission_prefix}.process_restock')
1940def defaults(config, **kwargs):
1941 base = globals()
1943 OrderView = kwargs.get('OrderView', base['OrderView'])
1944 OrderView.defaults(config)
1946 OrderItemView = kwargs.get('OrderItemView', base['OrderItemView'])
1947 OrderItemView.defaults(config)
1949 PlacementView = kwargs.get('PlacementView', base['PlacementView'])
1950 PlacementView.defaults(config)
1952 ReceivingView = kwargs.get('ReceivingView', base['ReceivingView'])
1953 ReceivingView.defaults(config)
1955 ContactView = kwargs.get('ContactView', base['ContactView'])
1956 ContactView.defaults(config)
1958 DeliveryView = kwargs.get('DeliveryView', base['DeliveryView'])
1959 DeliveryView.defaults(config)
1962def includeme(config):
1963 defaults(config)