Coverage for src/sideshow/web/views/orders.py: 0%
713 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-26 13:16 -0600
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-26 13:16 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# Sideshow -- Case/Special Order Tracker
5# Copyright © 2024-2025 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 'discount_percent',
139 'total_price',
140 'status_code',
141 ]
143 PENDING_PRODUCT_ENTRY_FIELDS = [
144 'scancode',
145 'brand_name',
146 'description',
147 'size',
148 'department_name',
149 'vendor_name',
150 'vendor_item_code',
151 'case_size',
152 'unit_cost',
153 'unit_price_reg',
154 ]
156 def __init__(self, request, context=None):
157 super().__init__(request, context=context)
158 self.order_handler = self.get_order_handler()
160 def get_order_handler(self):
161 """
162 Returns the configured :term:`order handler`.
164 You normally would not need to call this, and can use
165 :attr:`order_handler` instead.
167 :rtype: :class:`~sideshow.orders.OrderHandler`
168 """
169 if hasattr(self, 'order_handler'):
170 return self.order_handler
171 return OrderHandler(self.config)
173 def get_batch_handler(self):
174 """
175 Returns the configured :term:`handler` for :term:`new order
176 batches <new order batch>`.
178 You normally would not need to call this, and can use
179 :attr:`batch_handler` instead.
181 :returns:
182 :class:`~sideshow.batch.neworder.NewOrderBatchHandler`
183 instance.
184 """
185 if hasattr(self, 'batch_handler'):
186 return self.batch_handler
187 return self.app.get_batch_handler('neworder')
189 def configure_grid(self, g):
190 """ """
191 super().configure_grid(g)
193 # order_id
194 g.set_link('order_id')
196 # customer_id
197 g.set_link('customer_id')
199 # customer_name
200 g.set_link('customer_name')
202 # total_price
203 g.set_renderer('total_price', g.render_currency)
205 def create(self):
206 """
207 Instead of the typical "create" view, this displays a "wizard"
208 of sorts.
210 Under the hood a
211 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` is
212 automatically created for the user when they first visit this
213 page. They can select a customer, add items etc.
215 When user is finished assembling the order (i.e. populating
216 the batch), they submit it. This of course executes the
217 batch, which in turn creates a true
218 :class:`~sideshow.db.model.orders.Order`, and user is
219 redirected to the "view order" page.
221 See also these methods which may be called from this one,
222 based on user actions:
224 * :meth:`start_over()`
225 * :meth:`cancel_order()`
226 * :meth:`assign_customer()`
227 * :meth:`unassign_customer()`
228 * :meth:`set_pending_customer()`
229 * :meth:`get_product_info()`
230 * :meth:`add_item()`
231 * :meth:`update_item()`
232 * :meth:`delete_item()`
233 * :meth:`submit_order()`
234 """
235 enum = self.app.enum
236 self.creating = True
237 self.batch_handler = self.get_batch_handler()
238 batch = self.get_current_batch()
240 context = self.get_context_customer(batch)
242 if self.request.method == 'POST':
244 # first we check for traditional form post
245 action = self.request.POST.get('action')
246 post_actions = [
247 'start_over',
248 'cancel_order',
249 ]
250 if action in post_actions:
251 return getattr(self, action)(batch)
253 # okay then, we'll assume newer JSON-style post params
254 data = dict(self.request.json_body)
255 action = data.pop('action')
256 json_actions = [
257 'assign_customer',
258 'unassign_customer',
259 # 'update_phone_number',
260 # 'update_email_address',
261 'set_pending_customer',
262 # 'get_customer_info',
263 # # 'set_customer_data',
264 'get_product_info',
265 # 'get_past_items',
266 'add_item',
267 'update_item',
268 'delete_item',
269 'submit_order',
270 ]
271 if action in json_actions:
272 try:
273 result = getattr(self, action)(batch, data)
274 except Exception as error:
275 log.warning("error calling json action for order", exc_info=True)
276 result = {'error': self.app.render_error(error)}
277 return self.json_response(result)
279 return self.json_response({'error': "unknown form action"})
281 context.update({
282 'batch': batch,
283 'normalized_batch': self.normalize_batch(batch),
284 'order_items': [self.normalize_row(row)
285 for row in batch.rows],
286 'default_uom_choices': self.get_default_uom_choices(),
287 'default_uom': None, # TODO?
288 'allow_item_discounts': self.batch_handler.allow_item_discounts(),
289 'allow_unknown_products': (self.batch_handler.allow_unknown_products()
290 and self.has_perm('create_unknown_product')),
291 'pending_product_required_fields': self.get_pending_product_required_fields(),
292 })
294 if context['allow_item_discounts']:
295 context['allow_item_discounts_if_on_sale'] = self.batch_handler\
296 .allow_item_discounts_if_on_sale()
297 # nb. render quantity so that '10.0' => '10'
298 context['default_item_discount'] = self.app.render_quantity(
299 self.batch_handler.get_default_item_discount())
301 return self.render_to_response('create', context)
303 def get_current_batch(self):
304 """
305 Returns the current batch for the current user.
307 This looks for a new order batch which was created by the
308 user, but not yet executed. If none is found, a new batch is
309 created.
311 :returns:
312 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
313 instance
314 """
315 model = self.app.model
316 session = self.Session()
318 user = self.request.user
319 if not user:
320 raise self.forbidden()
322 try:
323 # there should be at most *one* new batch per user
324 batch = session.query(model.NewOrderBatch)\
325 .filter(model.NewOrderBatch.created_by == user)\
326 .filter(model.NewOrderBatch.executed == None)\
327 .one()
329 except orm.exc.NoResultFound:
330 # no batch yet for this user, so make one
331 batch = self.batch_handler.make_batch(session, created_by=user)
332 session.add(batch)
333 session.flush()
335 return batch
337 def customer_autocomplete(self):
338 """
339 AJAX view for customer autocomplete, when entering new order.
341 This invokes one of the following on the
342 :attr:`batch_handler`:
344 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_customers_external()`
345 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_customers_local()`
347 :returns: List of search results; each should be a dict with
348 ``value`` and ``label`` keys.
349 """
350 session = self.Session()
351 term = self.request.GET.get('term', '').strip()
352 if not term:
353 return []
355 handler = self.get_batch_handler()
356 if handler.use_local_customers():
357 return handler.autocomplete_customers_local(session, term, user=self.request.user)
358 else:
359 return handler.autocomplete_customers_external(session, term, user=self.request.user)
361 def product_autocomplete(self):
362 """
363 AJAX view for product autocomplete, when entering new order.
365 This invokes one of the following on the
366 :attr:`batch_handler`:
368 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_products_external()`
369 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_products_local()`
371 :returns: List of search results; each should be a dict with
372 ``value`` and ``label`` keys.
373 """
374 session = self.Session()
375 term = self.request.GET.get('term', '').strip()
376 if not term:
377 return []
379 handler = self.get_batch_handler()
380 if handler.use_local_products():
381 return handler.autocomplete_products_local(session, term, user=self.request.user)
382 else:
383 return handler.autocomplete_products_external(session, term, user=self.request.user)
385 def get_pending_product_required_fields(self):
386 """ """
387 required = []
388 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
389 require = self.config.get_bool(
390 f'sideshow.orders.unknown_product.fields.{field}.required')
391 if require is None and field == 'description':
392 require = True
393 if require:
394 required.append(field)
395 return required
397 def start_over(self, batch):
398 """
399 This will delete the user's current batch, then redirect user
400 back to "Create Order" page, which in turn will auto-create a
401 new batch for them.
403 This is a "batch action" method which may be called from
404 :meth:`create()`. See also:
406 * :meth:`cancel_order()`
407 * :meth:`submit_order()`
408 """
409 # drop current batch
410 self.batch_handler.do_delete(batch, self.request.user)
411 self.Session.flush()
413 # send back to "create order" which makes new batch
414 route_prefix = self.get_route_prefix()
415 url = self.request.route_url(f'{route_prefix}.create')
416 return self.redirect(url)
418 def cancel_order(self, batch):
419 """
420 This will delete the user's current batch, then redirect user
421 back to "List Orders" page.
423 This is a "batch action" method which may be called from
424 :meth:`create()`. See also:
426 * :meth:`start_over()`
427 * :meth:`submit_order()`
428 """
429 self.batch_handler.do_delete(batch, self.request.user)
430 self.Session.flush()
432 # set flash msg just to be more obvious
433 self.request.session.flash("New order has been deleted.")
435 # send user back to orders list, w/ no new batch generated
436 url = self.get_index_url()
437 return self.redirect(url)
439 def get_context_customer(self, batch):
440 """ """
441 context = {
442 'customer_is_known': True,
443 'customer_id': None,
444 'customer_name': batch.customer_name,
445 'phone_number': batch.phone_number,
446 'email_address': batch.email_address,
447 }
449 # customer_id
450 use_local = self.batch_handler.use_local_customers()
451 if use_local:
452 local = batch.local_customer
453 if local:
454 context['customer_id'] = local.uuid.hex
455 else: # use external
456 context['customer_id'] = batch.customer_id
458 # pending customer
459 pending = batch.pending_customer
460 if pending:
461 context.update({
462 'new_customer_first_name': pending.first_name,
463 'new_customer_last_name': pending.last_name,
464 'new_customer_full_name': pending.full_name,
465 'new_customer_phone': pending.phone_number,
466 'new_customer_email': pending.email_address,
467 })
469 # declare customer "not known" only if pending is in use
470 if (pending
471 and not batch.customer_id and not batch.local_customer
472 and batch.customer_name):
473 context['customer_is_known'] = False
475 return context
477 def assign_customer(self, batch, data):
478 """
479 Assign the true customer account for a batch.
481 This calls
482 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
483 for the heavy lifting.
485 This is a "batch action" method which may be called from
486 :meth:`create()`. See also:
488 * :meth:`unassign_customer()`
489 * :meth:`set_pending_customer()`
490 """
491 customer_id = data.get('customer_id')
492 if not customer_id:
493 return {'error': "Must provide customer_id"}
495 self.batch_handler.set_customer(batch, customer_id)
496 return self.get_context_customer(batch)
498 def unassign_customer(self, batch, data):
499 """
500 Clear the customer info for a batch.
502 This calls
503 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
504 for the heavy lifting.
506 This is a "batch action" method which may be called from
507 :meth:`create()`. See also:
509 * :meth:`assign_customer()`
510 * :meth:`set_pending_customer()`
511 """
512 self.batch_handler.set_customer(batch, None)
513 return self.get_context_customer(batch)
515 def set_pending_customer(self, batch, data):
516 """
517 This will set/update the batch pending customer info.
519 This calls
520 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
521 for the heavy lifting.
523 This is a "batch action" method which may be called from
524 :meth:`create()`. See also:
526 * :meth:`assign_customer()`
527 * :meth:`unassign_customer()`
528 """
529 self.batch_handler.set_customer(batch, data, user=self.request.user)
530 return self.get_context_customer(batch)
532 def get_product_info(self, batch, data):
533 """
534 Fetch data for a specific product. (Nothing is modified.)
536 Depending on config, this will fetch a :term:`local product`
537 or :term:`external product` to get the data.
539 This should invoke a configured handler for the query
540 behavior, but that is not yet implemented. For now it uses
541 built-in logic only, which queries the
542 :class:`~sideshow.db.model.products.LocalProduct` table.
544 This is a "batch action" method which may be called from
545 :meth:`create()`.
546 """
547 product_id = data.get('product_id')
548 if not product_id:
549 return {'error': "Must specify a product ID"}
551 session = self.Session()
552 use_local = self.batch_handler.use_local_products()
553 if use_local:
554 data = self.batch_handler.get_product_info_local(session, product_id)
555 else:
556 data = self.batch_handler.get_product_info_external(session, product_id)
558 if 'error' in data:
559 return data
561 if 'unit_price_reg' in data and 'unit_price_reg_display' not in data:
562 data['unit_price_reg_display'] = self.app.render_currency(data['unit_price_reg'])
564 if 'unit_price_reg' in data and 'unit_price_quoted' not in data:
565 data['unit_price_quoted'] = data['unit_price_reg']
567 if 'unit_price_quoted' in data and 'unit_price_quoted_display' not in data:
568 data['unit_price_quoted_display'] = self.app.render_currency(data['unit_price_quoted'])
570 if 'case_price_quoted' not in data:
571 if data.get('unit_price_quoted') is not None and data.get('case_size') is not None:
572 data['case_price_quoted'] = data['unit_price_quoted'] * data['case_size']
574 if 'case_price_quoted' in data and 'case_price_quoted_display' not in data:
575 data['case_price_quoted_display'] = self.app.render_currency(data['case_price_quoted'])
577 if 'default_item_discount' not in data:
578 data['default_item_discount'] = self.batch_handler.get_default_item_discount()
580 decimal_fields = [
581 'case_size',
582 'unit_price_reg',
583 'unit_price_quoted',
584 'case_price_quoted',
585 'default_item_discount',
586 ]
588 for field in decimal_fields:
589 if field in list(data):
590 value = data[field]
591 if isinstance(value, decimal.Decimal):
592 data[field] = float(value)
594 return data
596 def add_item(self, batch, data):
597 """
598 This adds a row to the user's current new order batch.
600 This is a "batch action" method which may be called from
601 :meth:`create()`. See also:
603 * :meth:`update_item()`
604 * :meth:`delete_item()`
605 """
606 kw = {'user': self.request.user}
607 if 'discount_percent' in data and self.batch_handler.allow_item_discounts():
608 kw['discount_percent'] = data['discount_percent']
609 row = self.batch_handler.add_item(batch, data['product_info'],
610 data['order_qty'], data['order_uom'], **kw)
612 return {'batch': self.normalize_batch(batch),
613 'row': self.normalize_row(row)}
615 def update_item(self, batch, data):
616 """
617 This updates a row in the user's current new order batch.
619 This is a "batch action" method which may be called from
620 :meth:`create()`. See also:
622 * :meth:`add_item()`
623 * :meth:`delete_item()`
624 """
625 model = self.app.model
626 session = self.Session()
628 uuid = data.get('uuid')
629 if not uuid:
630 return {'error': "Must specify row UUID"}
632 row = session.get(model.NewOrderBatchRow, uuid)
633 if not row:
634 return {'error': "Row not found"}
636 if row.batch is not batch:
637 return {'error': "Row is for wrong batch"}
639 kw = {'user': self.request.user}
640 if 'discount_percent' in data and self.batch_handler.allow_item_discounts():
641 kw['discount_percent'] = data['discount_percent']
642 self.batch_handler.update_item(row, data['product_info'],
643 data['order_qty'], data['order_uom'], **kw)
645 return {'batch': self.normalize_batch(batch),
646 'row': self.normalize_row(row)}
648 def delete_item(self, batch, data):
649 """
650 This deletes a row from the user's current new order batch.
652 This is a "batch action" method which may be called from
653 :meth:`create()`. See also:
655 * :meth:`add_item()`
656 * :meth:`update_item()`
657 """
658 model = self.app.model
659 session = self.app.get_session(batch)
661 uuid = data.get('uuid')
662 if not uuid:
663 return {'error': "Must specify a row UUID"}
665 row = session.get(model.NewOrderBatchRow, uuid)
666 if not row:
667 return {'error': "Row not found"}
669 if row.batch is not batch:
670 return {'error': "Row is for wrong batch"}
672 self.batch_handler.do_remove_row(row)
673 return {'batch': self.normalize_batch(batch)}
675 def submit_order(self, batch, data):
676 """
677 This submits the user's current new order batch, hence
678 executing the batch and creating the true order.
680 This is a "batch action" method which may be called from
681 :meth:`create()`. See also:
683 * :meth:`start_over()`
684 * :meth:`cancel_order()`
685 """
686 user = self.request.user
687 reason = self.batch_handler.why_not_execute(batch, user=user)
688 if reason:
689 return {'error': reason}
691 try:
692 order = self.batch_handler.do_execute(batch, user)
693 except Exception as error:
694 log.warning("failed to execute new order batch: %s", batch,
695 exc_info=True)
696 return {'error': self.app.render_error(error)}
698 return {
699 'next_url': self.get_action_url('view', order),
700 }
702 def normalize_batch(self, batch):
703 """ """
704 return {
705 'uuid': batch.uuid.hex,
706 'total_price': str(batch.total_price or 0),
707 'total_price_display': self.app.render_currency(batch.total_price),
708 'status_code': batch.status_code,
709 'status_text': batch.status_text,
710 }
712 def get_default_uom_choices(self):
713 """ """
714 enum = self.app.enum
715 return [{'key': key, 'value': val}
716 for key, val in enum.ORDER_UOM.items()]
718 def normalize_row(self, row):
719 """ """
720 data = {
721 'uuid': row.uuid.hex,
722 'sequence': row.sequence,
723 'product_id': None,
724 'product_scancode': row.product_scancode,
725 'product_brand': row.product_brand,
726 'product_description': row.product_description,
727 'product_size': row.product_size,
728 'product_full_description': self.app.make_full_name(row.product_brand,
729 row.product_description,
730 row.product_size),
731 'product_weighed': row.product_weighed,
732 'department_display': row.department_name,
733 'special_order': row.special_order,
734 'case_size': float(row.case_size) if row.case_size is not None else None,
735 'order_qty': float(row.order_qty),
736 'order_uom': row.order_uom,
737 'order_uom_choices': self.get_default_uom_choices(),
738 'discount_percent': self.app.render_quantity(row.discount_percent),
739 'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None,
740 'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted),
741 'case_price_quoted': float(row.case_price_quoted) if row.case_price_quoted is not None else None,
742 'case_price_quoted_display': self.app.render_currency(row.case_price_quoted),
743 'total_price': float(row.total_price) if row.total_price is not None else None,
744 'total_price_display': self.app.render_currency(row.total_price),
745 'status_code': row.status_code,
746 'status_text': row.status_text,
747 }
749 use_local = self.batch_handler.use_local_products()
751 # product_id
752 if use_local:
753 if row.local_product:
754 data['product_id'] = row.local_product.uuid.hex
755 else:
756 data['product_id'] = row.product_id
758 # vendor_name
759 if use_local:
760 if row.local_product:
761 data['vendor_name'] = row.local_product.vendor_name
762 else: # use external
763 pass # TODO
764 if not data.get('product_id') and row.pending_product:
765 data['vendor_name'] = row.pending_product.vendor_name
767 if row.unit_price_reg:
768 data['unit_price_reg'] = float(row.unit_price_reg)
769 data['unit_price_reg_display'] = self.app.render_currency(row.unit_price_reg)
771 if row.unit_price_sale:
772 data['unit_price_sale'] = float(row.unit_price_sale)
773 data['unit_price_sale_display'] = self.app.render_currency(row.unit_price_sale)
774 if row.sale_ends:
775 sale_ends = row.sale_ends
776 data['sale_ends'] = str(row.sale_ends)
777 data['sale_ends_display'] = self.app.render_date(row.sale_ends)
779 if row.pending_product:
780 pending = row.pending_product
781 data['pending_product'] = {
782 'uuid': pending.uuid.hex,
783 'scancode': pending.scancode,
784 'brand_name': pending.brand_name,
785 'description': pending.description,
786 'size': pending.size,
787 'department_id': pending.department_id,
788 'department_name': pending.department_name,
789 'unit_price_reg': float(pending.unit_price_reg) if pending.unit_price_reg is not None else None,
790 'vendor_name': pending.vendor_name,
791 'vendor_item_code': pending.vendor_item_code,
792 'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None,
793 'case_size': float(pending.case_size) if pending.case_size is not None else None,
794 'notes': pending.notes,
795 'special_order': pending.special_order,
796 }
798 # display text for order qty/uom
799 data['order_qty_display'] = self.order_handler.get_order_qty_uom_text(
800 row.order_qty, row.order_uom, case_size=row.case_size, html=True)
802 return data
804 def get_instance_title(self, order):
805 """ """
806 return f"#{order.order_id} for {order.customer_name}"
808 def configure_form(self, f):
809 """ """
810 super().configure_form(f)
811 order = f.model_instance
813 # local_customer
814 if order.customer_id and not order.local_customer:
815 f.remove('local_customer')
816 else:
817 f.set_node('local_customer', LocalCustomerRef(self.request))
819 # pending_customer
820 if order.customer_id or order.local_customer:
821 f.remove('pending_customer')
822 else:
823 f.set_node('pending_customer', PendingCustomerRef(self.request))
825 # total_price
826 f.set_node('total_price', WuttaMoney(self.request))
828 # created_by
829 f.set_node('created_by', UserRef(self.request))
830 f.set_readonly('created_by')
832 def get_xref_buttons(self, order):
833 """ """
834 buttons = super().get_xref_buttons(order)
835 model = self.app.model
836 session = self.Session()
838 if self.request.has_perm('neworder_batches.view'):
839 batch = session.query(model.NewOrderBatch)\
840 .filter(model.NewOrderBatch.id == order.order_id)\
841 .first()
842 if batch:
843 url = self.request.route_url('neworder_batches.view', uuid=batch.uuid)
844 buttons.append(
845 self.make_button("View the Batch", primary=True, icon_left='eye', url=url))
847 return buttons
849 def get_row_grid_data(self, order):
850 """ """
851 model = self.app.model
852 session = self.Session()
853 return session.query(model.OrderItem)\
854 .filter(model.OrderItem.order == order)
856 def configure_row_grid(self, g):
857 """ """
858 super().configure_row_grid(g)
859 # enum = self.app.enum
861 # sequence
862 g.set_label('sequence', "Seq.", column_only=True)
863 g.set_link('sequence')
865 # product_scancode
866 g.set_link('product_scancode')
868 # product_brand
869 g.set_link('product_brand')
871 # product_description
872 g.set_link('product_description')
874 # product_size
875 g.set_link('product_size')
877 # TODO
878 # order_uom
879 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
881 # discount_percent
882 g.set_renderer('discount_percent', 'percent')
883 g.set_label('discount_percent', "Disc. %", column_only=True)
885 # total_price
886 g.set_renderer('total_price', g.render_currency)
888 # status_code
889 g.set_renderer('status_code', self.render_status_code)
891 # TODO: upstream should set this automatically
892 g.row_class = self.row_grid_row_class
894 def row_grid_row_class(self, item, data, i):
895 """ """
896 variant = self.order_handler.item_status_to_variant(item.status_code)
897 if variant:
898 return f'has-background-{variant}'
900 def render_status_code(self, item, key, value):
901 """ """
902 enum = self.app.enum
903 return enum.ORDER_ITEM_STATUS[value]
905 def get_row_action_url_view(self, item, i):
906 """ """
907 return self.request.route_url('order_items.view', uuid=item.uuid)
909 def configure_get_simple_settings(self):
910 """ """
911 settings = [
913 # batches
914 {'name': 'wutta.batch.neworder.handler.spec'},
916 # customers
917 {'name': 'sideshow.orders.use_local_customers',
918 # nb. this is really a bool but we present as string in config UI
919 #'type': bool,
920 'default': 'true'},
922 # products
923 {'name': 'sideshow.orders.allow_item_discounts',
924 'type': bool},
925 {'name': 'sideshow.orders.allow_item_discounts_if_on_sale',
926 'type': bool},
927 {'name': 'sideshow.orders.default_item_discount',
928 'type': float},
929 {'name': 'sideshow.orders.use_local_products',
930 # nb. this is really a bool but we present as string in config UI
931 #'type': bool,
932 'default': 'true'},
933 {'name': 'sideshow.orders.allow_unknown_products',
934 'type': bool,
935 'default': True},
936 ]
938 # required fields for new product entry
939 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
940 setting = {'name': f'sideshow.orders.unknown_product.fields.{field}.required',
941 'type': bool}
942 if field == 'description':
943 setting['default'] = True
944 settings.append(setting)
946 return settings
948 def configure_get_context(self, **kwargs):
949 """ """
950 context = super().configure_get_context(**kwargs)
952 context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS
954 handlers = self.app.get_batch_handler_specs('neworder')
955 handlers = [{'spec': spec} for spec in handlers]
956 context['batch_handlers'] = handlers
958 return context
960 @classmethod
961 def defaults(cls, config):
962 cls._order_defaults(config)
963 cls._defaults(config)
965 @classmethod
966 def _order_defaults(cls, config):
967 route_prefix = cls.get_route_prefix()
968 permission_prefix = cls.get_permission_prefix()
969 url_prefix = cls.get_url_prefix()
970 model_title = cls.get_model_title()
971 model_title_plural = cls.get_model_title_plural()
973 # fix perm group
974 config.add_wutta_permission_group(permission_prefix,
975 model_title_plural,
976 overwrite=False)
978 # extra perm required to create order with unknown/pending product
979 config.add_wutta_permission(permission_prefix,
980 f'{permission_prefix}.create_unknown_product',
981 f"Create new {model_title} for unknown/pending product")
983 # customer autocomplete
984 config.add_route(f'{route_prefix}.customer_autocomplete',
985 f'{url_prefix}/customer-autocomplete',
986 request_method='GET')
987 config.add_view(cls, attr='customer_autocomplete',
988 route_name=f'{route_prefix}.customer_autocomplete',
989 renderer='json',
990 permission=f'{permission_prefix}.list')
992 # product autocomplete
993 config.add_route(f'{route_prefix}.product_autocomplete',
994 f'{url_prefix}/product-autocomplete',
995 request_method='GET')
996 config.add_view(cls, attr='product_autocomplete',
997 route_name=f'{route_prefix}.product_autocomplete',
998 renderer='json',
999 permission=f'{permission_prefix}.list')
1002class OrderItemView(MasterView):
1003 """
1004 Master view for :class:`~sideshow.db.model.orders.OrderItem`;
1005 route prefix is ``order_items``.
1007 Notable URLs provided by this class:
1009 * ``/order-items/``
1010 * ``/order-items/XXX``
1012 This class serves both as a proper master view (for "all" order
1013 items) as well as a base class for other "workflow" master views,
1014 each of which auto-filters by order item status:
1016 * :class:`PlacementView`
1017 * :class:`ReceivingView`
1018 * :class:`ContactView`
1019 * :class:`DeliveryView`
1021 Note that this does not expose create, edit or delete. The user
1022 must perform various other workflow actions to modify the item.
1024 .. attribute:: order_handler
1026 Reference to the :term:`order handler` as returned by
1027 :meth:`get_order_handler()`.
1028 """
1029 model_class = OrderItem
1030 model_title = "Order Item (All)"
1031 model_title_plural = "Order Items (All)"
1032 route_prefix = 'order_items'
1033 url_prefix = '/order-items'
1034 creatable = False
1035 editable = False
1036 deletable = False
1038 labels = {
1039 'order_id': "Order ID",
1040 'product_id': "Product ID",
1041 'product_scancode': "Scancode",
1042 'product_brand': "Brand",
1043 'product_description': "Description",
1044 'product_size': "Size",
1045 'product_weighed': "Sold by Weight",
1046 'department_id': "Department ID",
1047 'order_uom': "Order UOM",
1048 'status_code': "Status",
1049 }
1051 grid_columns = [
1052 'order_id',
1053 'customer_name',
1054 # 'sequence',
1055 'product_scancode',
1056 'product_brand',
1057 'product_description',
1058 'product_size',
1059 'department_name',
1060 'special_order',
1061 'order_qty',
1062 'order_uom',
1063 'total_price',
1064 'status_code',
1065 ]
1067 sort_defaults = ('order_id', 'desc')
1069 form_fields = [
1070 'order',
1071 # 'customer_name',
1072 'sequence',
1073 'product_id',
1074 'local_product',
1075 'pending_product',
1076 'product_scancode',
1077 'product_brand',
1078 'product_description',
1079 'product_size',
1080 'product_weighed',
1081 'department_id',
1082 'department_name',
1083 'special_order',
1084 'case_size',
1085 'unit_cost',
1086 'unit_price_reg',
1087 'unit_price_sale',
1088 'sale_ends',
1089 'unit_price_quoted',
1090 'case_price_quoted',
1091 'order_qty',
1092 'order_uom',
1093 'discount_percent',
1094 'total_price',
1095 'status_code',
1096 'paid_amount',
1097 'payment_transaction_number',
1098 ]
1100 def __init__(self, request, context=None):
1101 super().__init__(request, context=context)
1102 self.order_handler = self.get_order_handler()
1104 def get_order_handler(self):
1105 """
1106 Returns the configured :term:`order handler`.
1108 You normally would not need to call this, and can use
1109 :attr:`order_handler` instead.
1111 :rtype: :class:`~sideshow.orders.OrderHandler`
1112 """
1113 if hasattr(self, 'order_handler'):
1114 return self.order_handler
1115 return OrderHandler(self.config)
1117 def get_fallback_templates(self, template):
1118 """ """
1119 templates = super().get_fallback_templates(template)
1120 templates.insert(0, f'/order-items/{template}.mako')
1121 return templates
1123 def get_query(self, session=None):
1124 """ """
1125 query = super().get_query(session=session)
1126 model = self.app.model
1127 return query.join(model.Order)
1129 def configure_grid(self, g):
1130 """ """
1131 super().configure_grid(g)
1132 model = self.app.model
1133 # enum = self.app.enum
1135 # order_id
1136 g.set_sorter('order_id', model.Order.order_id)
1137 g.set_renderer('order_id', self.render_order_id)
1138 g.set_link('order_id')
1140 # customer_name
1141 g.set_label('customer_name', "Customer", column_only=True)
1143 # # sequence
1144 # g.set_label('sequence', "Seq.", column_only=True)
1146 # product_scancode
1147 g.set_link('product_scancode')
1149 # product_brand
1150 g.set_link('product_brand')
1152 # product_description
1153 g.set_link('product_description')
1155 # product_size
1156 g.set_link('product_size')
1158 # order_uom
1159 # TODO
1160 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
1162 # total_price
1163 g.set_renderer('total_price', g.render_currency)
1165 # status_code
1166 g.set_renderer('status_code', self.render_status_code)
1168 def render_order_id(self, item, key, value):
1169 """ """
1170 return item.order.order_id
1172 def render_status_code(self, item, key, value):
1173 """ """
1174 enum = self.app.enum
1175 return enum.ORDER_ITEM_STATUS[value]
1177 def grid_row_class(self, item, data, i):
1178 """ """
1179 variant = self.order_handler.item_status_to_variant(item.status_code)
1180 if variant:
1181 return f'has-background-{variant}'
1183 def configure_form(self, f):
1184 """ """
1185 super().configure_form(f)
1186 enum = self.app.enum
1187 item = f.model_instance
1189 # order
1190 f.set_node('order', OrderRef(self.request))
1192 # local_product
1193 f.set_node('local_product', LocalProductRef(self.request))
1195 # pending_product
1196 if item.product_id or item.local_product:
1197 f.remove('pending_product')
1198 else:
1199 f.set_node('pending_product', PendingProductRef(self.request))
1201 # order_qty
1202 f.set_node('order_qty', WuttaQuantity(self.request))
1204 # order_uom
1205 f.set_node('order_uom', WuttaDictEnum(self.request, enum.ORDER_UOM))
1207 # case_size
1208 f.set_node('case_size', WuttaQuantity(self.request))
1210 # unit_cost
1211 f.set_node('unit_cost', WuttaMoney(self.request, scale=4))
1213 # unit_price_reg
1214 f.set_node('unit_price_reg', WuttaMoney(self.request))
1216 # unit_price_quoted
1217 f.set_node('unit_price_quoted', WuttaMoney(self.request))
1219 # case_price_quoted
1220 f.set_node('case_price_quoted', WuttaMoney(self.request))
1222 # total_price
1223 f.set_node('total_price', WuttaMoney(self.request))
1225 # status
1226 f.set_node('status_code', WuttaDictEnum(self.request, enum.ORDER_ITEM_STATUS))
1228 # paid_amount
1229 f.set_node('paid_amount', WuttaMoney(self.request))
1231 def get_template_context(self, context):
1232 """ """
1233 if self.viewing:
1234 model = self.app.model
1235 enum = self.app.enum
1236 route_prefix = self.get_route_prefix()
1237 item = context['instance']
1238 form = context['form']
1240 context['item'] = item
1241 context['order'] = item.order
1242 context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text(
1243 item.order_qty, item.order_uom, case_size=item.case_size, html=True)
1244 context['item_status_variant'] = self.order_handler.item_status_to_variant(item.status_code)
1246 grid = self.make_grid(key=f'{route_prefix}.view.events',
1247 model_class=model.OrderItemEvent,
1248 data=item.events,
1249 columns=[
1250 'occurred',
1251 'actor',
1252 'type_code',
1253 'note',
1254 ],
1255 labels={
1256 'occurred': "Date/Time",
1257 'actor': "User",
1258 'type_code': "Event Type",
1259 })
1260 grid.set_renderer('type_code', lambda e, k, v: enum.ORDER_ITEM_EVENT[v])
1261 grid.set_renderer('note', self.render_event_note)
1262 if self.request.has_perm('users.view'):
1263 grid.set_renderer('actor', lambda e, k, v: tags.link_to(
1264 e.actor, self.request.route_url('users.view', uuid=e.actor.uuid)))
1265 form.add_grid_vue_context(grid)
1266 context['events_grid'] = grid
1268 return context
1270 def render_event_note(self, event, key, value):
1271 """ """
1272 enum = self.app.enum
1273 if event.type_code == enum.ORDER_ITEM_EVENT_NOTE_ADDED:
1274 return HTML.tag('span', class_='has-background-info-light',
1275 style='padding: 0.25rem 0.5rem;',
1276 c=[value])
1277 return value
1279 def get_xref_buttons(self, item):
1280 """ """
1281 buttons = super().get_xref_buttons(item)
1283 if self.request.has_perm('orders.view'):
1284 url = self.request.route_url('orders.view', uuid=item.order_uuid)
1285 buttons.append(
1286 self.make_button("View the Order", url=url,
1287 primary=True, icon_left='eye'))
1289 return buttons
1291 def add_note(self):
1292 """
1293 View which adds a note to an order item. This is POST-only;
1294 will redirect back to the item view.
1295 """
1296 enum = self.app.enum
1297 item = self.get_instance()
1299 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, self.request.user,
1300 note=self.request.POST['note'])
1302 return self.redirect(self.get_action_url('view', item))
1304 def change_status(self):
1305 """
1306 View which changes status for an order item. This is
1307 POST-only; will redirect back to the item view.
1308 """
1309 model = self.app.model
1310 enum = self.app.enum
1311 main_item = self.get_instance()
1312 session = self.Session()
1313 redirect = self.redirect(self.get_action_url('view', main_item))
1315 extra_note = self.request.POST.get('note')
1317 # validate new status
1318 new_status_code = int(self.request.POST['new_status'])
1319 if new_status_code not in enum.ORDER_ITEM_STATUS:
1320 self.request.session.flash("Invalid status code", 'error')
1321 return redirect
1322 new_status_text = enum.ORDER_ITEM_STATUS[new_status_code]
1324 # locate all items to which new status will be applied
1325 items = [main_item]
1326 # uuids = self.request.POST.get('uuids')
1327 # if uuids:
1328 # for uuid in uuids.split(','):
1329 # item = Session.get(model.OrderItem, uuid)
1330 # if item:
1331 # items.append(item)
1333 # update item(s)
1334 for item in items:
1335 if item.status_code != new_status_code:
1337 # event: change status
1338 note = 'status changed from "{}" to "{}"'.format(
1339 enum.ORDER_ITEM_STATUS[item.status_code],
1340 new_status_text)
1341 item.add_event(enum.ORDER_ITEM_EVENT_STATUS_CHANGE,
1342 self.request.user, note=note)
1344 # event: add note
1345 if extra_note:
1346 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED,
1347 self.request.user, note=extra_note)
1349 # new status
1350 item.status_code = new_status_code
1352 self.request.session.flash(f"Status has been updated to: {new_status_text}")
1353 return redirect
1355 def get_order_items(self, uuids):
1356 """
1357 This method provides common logic to fetch a list of order
1358 items based on a list of UUID keys. It is used by various
1359 workflow action methods.
1361 Note that if no order items are found, this will set a flash
1362 warning message and raise a redirect back to the index page.
1364 :param uuids: List (or comma-delimited string) of UUID keys.
1366 :returns: List of :class:`~sideshow.db.model.orders.OrderItem`
1367 records.
1368 """
1369 model = self.app.model
1370 session = self.Session()
1372 if uuids is None:
1373 uuids = []
1374 elif isinstance(uuids, str):
1375 uuids = uuids.split(',')
1377 items = []
1378 for uuid in uuids:
1379 if isinstance(uuid, str):
1380 uuid = uuid.strip()
1381 if uuid:
1382 try:
1383 item = session.get(model.OrderItem, uuid)
1384 except sa.exc.StatementError:
1385 pass # nb. invalid UUID
1386 else:
1387 if item:
1388 items.append(item)
1390 if not items:
1391 self.request.session.flash("Must specify valid order item(s).", 'warning')
1392 raise self.redirect(self.get_index_url())
1394 return items
1396 @classmethod
1397 def defaults(cls, config):
1398 """ """
1399 cls._order_item_defaults(config)
1400 cls._defaults(config)
1402 @classmethod
1403 def _order_item_defaults(cls, config):
1404 """ """
1405 route_prefix = cls.get_route_prefix()
1406 permission_prefix = cls.get_permission_prefix()
1407 instance_url_prefix = cls.get_instance_url_prefix()
1408 model_title = cls.get_model_title()
1409 model_title_plural = cls.get_model_title_plural()
1411 # fix perm group
1412 config.add_wutta_permission_group(permission_prefix,
1413 model_title_plural,
1414 overwrite=False)
1416 # add note
1417 config.add_route(f'{route_prefix}.add_note',
1418 f'{instance_url_prefix}/add_note',
1419 request_method='POST')
1420 config.add_view(cls, attr='add_note',
1421 route_name=f'{route_prefix}.add_note',
1422 renderer='json',
1423 permission=f'{permission_prefix}.add_note')
1424 config.add_wutta_permission(permission_prefix,
1425 f'{permission_prefix}.add_note',
1426 f"Add note for {model_title}")
1428 # change status
1429 config.add_route(f'{route_prefix}.change_status',
1430 f'{instance_url_prefix}/change-status',
1431 request_method='POST')
1432 config.add_view(cls, attr='change_status',
1433 route_name=f'{route_prefix}.change_status',
1434 renderer='json',
1435 permission=f'{permission_prefix}.change_status')
1436 config.add_wutta_permission(permission_prefix,
1437 f'{permission_prefix}.change_status',
1438 f"Change status for {model_title}")
1441class PlacementView(OrderItemView):
1442 """
1443 Master view for the "placement" phase of
1444 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1445 ``placement``. This is a subclass of :class:`OrderItemView`.
1447 This class auto-filters so only order items with the following
1448 status codes are shown:
1450 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY`
1452 Notable URLs provided by this class:
1454 * ``/placement/``
1455 * ``/placement/XXX``
1456 """
1457 model_title = "Order Item (Placement)"
1458 model_title_plural = "Order Items (Placement)"
1459 route_prefix = 'order_items_placement'
1460 url_prefix = '/placement'
1462 def get_query(self, session=None):
1463 """ """
1464 query = super().get_query(session=session)
1465 model = self.app.model
1466 enum = self.app.enum
1467 return query.filter(model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_READY)
1469 def configure_grid(self, g):
1470 """ """
1471 super().configure_grid(g)
1473 # checkable
1474 if self.has_perm('process_placement'):
1475 g.checkable = True
1477 # tool button: Order Placed
1478 if self.has_perm('process_placement'):
1479 button = self.make_button("Order Placed", primary=True,
1480 icon_left='arrow-circle-right',
1481 **{'@click': "$emit('process-placement', checkedRows)",
1482 ':disabled': '!checkedRows.length'})
1483 g.add_tool(button, key='process_placement')
1485 def process_placement(self):
1486 """
1487 View to process the "placement" step for some order item(s).
1489 This requires a POST request with data:
1491 :param item_uuids: Comma-delimited list of
1492 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1494 :param vendor_name: Optional name of vendor.
1496 :param po_number: Optional PO number.
1498 :param note: Optional note text from the user.
1500 This invokes
1501 :meth:`~sideshow.orders.OrderHandler.process_placement()` on
1502 the :attr:`~OrderItemView.order_handler`, then redirects user
1503 back to the index page.
1504 """
1505 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1506 vendor_name = self.request.POST.get('vendor_name', '').strip() or None
1507 po_number = self.request.POST.get('po_number', '').strip() or None
1508 note = self.request.POST.get('note', '').strip() or None
1510 self.order_handler.process_placement(items, self.request.user,
1511 vendor_name=vendor_name,
1512 po_number=po_number,
1513 note=note)
1515 self.request.session.flash(f"{len(items)} Order Items were marked as placed")
1516 return self.redirect(self.get_index_url())
1518 @classmethod
1519 def defaults(cls, config):
1520 cls._order_item_defaults(config)
1521 cls._placement_defaults(config)
1522 cls._defaults(config)
1524 @classmethod
1525 def _placement_defaults(cls, config):
1526 route_prefix = cls.get_route_prefix()
1527 permission_prefix = cls.get_permission_prefix()
1528 url_prefix = cls.get_url_prefix()
1529 model_title_plural = cls.get_model_title_plural()
1531 # process placement
1532 config.add_wutta_permission(permission_prefix,
1533 f'{permission_prefix}.process_placement',
1534 f"Process placement for {model_title_plural}")
1535 config.add_route(f'{route_prefix}.process_placement',
1536 f'{url_prefix}/process-placement',
1537 request_method='POST')
1538 config.add_view(cls, attr='process_placement',
1539 route_name=f'{route_prefix}.process_placement',
1540 permission=f'{permission_prefix}.process_placement')
1543class ReceivingView(OrderItemView):
1544 """
1545 Master view for the "receiving" phase of
1546 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1547 ``receiving``. This is a subclass of :class:`OrderItemView`.
1549 This class auto-filters so only order items with the following
1550 status codes are shown:
1552 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_PLACED`
1554 Notable URLs provided by this class:
1556 * ``/receiving/``
1557 * ``/receiving/XXX``
1558 """
1559 model_title = "Order Item (Receiving)"
1560 model_title_plural = "Order Items (Receiving)"
1561 route_prefix = 'order_items_receiving'
1562 url_prefix = '/receiving'
1564 def get_query(self, session=None):
1565 """ """
1566 query = super().get_query(session=session)
1567 model = self.app.model
1568 enum = self.app.enum
1569 return query.filter(model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_PLACED)
1571 def configure_grid(self, g):
1572 """ """
1573 super().configure_grid(g)
1575 # checkable
1576 if self.has_any_perm('process_receiving', 'process_reorder'):
1577 g.checkable = True
1579 # tool button: Received
1580 if self.has_perm('process_receiving'):
1581 button = self.make_button("Received", primary=True,
1582 icon_left='arrow-circle-right',
1583 **{'@click': "$emit('process-receiving', checkedRows)",
1584 ':disabled': '!checkedRows.length'})
1585 g.add_tool(button, key='process_receiving')
1587 # tool button: Re-Order
1588 if self.has_perm('process_reorder'):
1589 button = self.make_button("Re-Order",
1590 icon_left='redo',
1591 **{'@click': "$emit('process-reorder', checkedRows)",
1592 ':disabled': '!checkedRows.length'})
1593 g.add_tool(button, key='process_reorder')
1595 def process_receiving(self):
1596 """
1597 View to process the "receiving" step for some order item(s).
1599 This requires a POST request with data:
1601 :param item_uuids: Comma-delimited list of
1602 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1604 :param vendor_name: Optional name of vendor.
1606 :param invoice_number: Optional invoice number.
1608 :param po_number: Optional PO number.
1610 :param note: Optional note text from the user.
1612 This invokes
1613 :meth:`~sideshow.orders.OrderHandler.process_receiving()` on
1614 the :attr:`~OrderItemView.order_handler`, then redirects user
1615 back to the index page.
1616 """
1617 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1618 vendor_name = self.request.POST.get('vendor_name', '').strip() or None
1619 invoice_number = self.request.POST.get('invoice_number', '').strip() or None
1620 po_number = self.request.POST.get('po_number', '').strip() or None
1621 note = self.request.POST.get('note', '').strip() or None
1623 self.order_handler.process_receiving(items, self.request.user,
1624 vendor_name=vendor_name,
1625 invoice_number=invoice_number,
1626 po_number=po_number,
1627 note=note)
1629 self.request.session.flash(f"{len(items)} Order Items were marked as received")
1630 return self.redirect(self.get_index_url())
1632 def process_reorder(self):
1633 """
1634 View to process the "reorder" step for some order item(s).
1636 This requires a POST request with data:
1638 :param item_uuids: Comma-delimited list of
1639 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1641 :param note: Optional note text from the user.
1643 This invokes
1644 :meth:`~sideshow.orders.OrderHandler.process_reorder()` on the
1645 :attr:`~OrderItemView.order_handler`, then redirects user back
1646 to the index page.
1647 """
1648 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1649 note = self.request.POST.get('note', '').strip() or None
1651 self.order_handler.process_reorder(items, self.request.user, note=note)
1653 self.request.session.flash(f"{len(items)} Order Items were marked as ready for placement")
1654 return self.redirect(self.get_index_url())
1656 @classmethod
1657 def defaults(cls, config):
1658 cls._order_item_defaults(config)
1659 cls._receiving_defaults(config)
1660 cls._defaults(config)
1662 @classmethod
1663 def _receiving_defaults(cls, config):
1664 route_prefix = cls.get_route_prefix()
1665 permission_prefix = cls.get_permission_prefix()
1666 url_prefix = cls.get_url_prefix()
1667 model_title_plural = cls.get_model_title_plural()
1669 # process receiving
1670 config.add_wutta_permission(permission_prefix,
1671 f'{permission_prefix}.process_receiving',
1672 f"Process receiving for {model_title_plural}")
1673 config.add_route(f'{route_prefix}.process_receiving',
1674 f'{url_prefix}/process-receiving',
1675 request_method='POST')
1676 config.add_view(cls, attr='process_receiving',
1677 route_name=f'{route_prefix}.process_receiving',
1678 permission=f'{permission_prefix}.process_receiving')
1680 # process reorder
1681 config.add_wutta_permission(permission_prefix,
1682 f'{permission_prefix}.process_reorder',
1683 f"Process re-order for {model_title_plural}")
1684 config.add_route(f'{route_prefix}.process_reorder',
1685 f'{url_prefix}/process-reorder',
1686 request_method='POST')
1687 config.add_view(cls, attr='process_reorder',
1688 route_name=f'{route_prefix}.process_reorder',
1689 permission=f'{permission_prefix}.process_reorder')
1692class ContactView(OrderItemView):
1693 """
1694 Master view for the "contact" phase of
1695 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1696 ``contact``. This is a subclass of :class:`OrderItemView`.
1698 This class auto-filters so only order items with the following
1699 status codes are shown:
1701 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED`
1702 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACT_FAILED`
1704 Notable URLs provided by this class:
1706 * ``/contact/``
1707 * ``/contact/XXX``
1708 """
1709 model_title = "Order Item (Contact)"
1710 model_title_plural = "Order Items (Contact)"
1711 route_prefix = 'order_items_contact'
1712 url_prefix = '/contact'
1714 def get_query(self, session=None):
1715 """ """
1716 query = super().get_query(session=session)
1717 model = self.app.model
1718 enum = self.app.enum
1719 return query.filter(model.OrderItem.status_code.in_((
1720 enum.ORDER_ITEM_STATUS_RECEIVED,
1721 enum.ORDER_ITEM_STATUS_CONTACT_FAILED)))
1723 def configure_grid(self, g):
1724 """ """
1725 super().configure_grid(g)
1727 # checkable
1728 if self.has_perm('process_contact'):
1729 g.checkable = True
1731 # tool button: Contact Success
1732 if self.has_perm('process_contact'):
1733 button = self.make_button("Contact Success", primary=True,
1734 icon_left='phone',
1735 **{'@click': "$emit('process-contact-success', checkedRows)",
1736 ':disabled': '!checkedRows.length'})
1737 g.add_tool(button, key='process_contact_success')
1739 # tool button: Contact Failure
1740 if self.has_perm('process_contact'):
1741 button = self.make_button("Contact Failure", variant='is-warning',
1742 icon_left='phone',
1743 **{'@click': "$emit('process-contact-failure', checkedRows)",
1744 ':disabled': '!checkedRows.length'})
1745 g.add_tool(button, key='process_contact_failure')
1747 def process_contact_success(self):
1748 """
1749 View to process the "contact success" step for some order
1750 item(s).
1752 This requires a POST request with data:
1754 :param item_uuids: Comma-delimited list of
1755 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1757 :param note: Optional note text from the user.
1759 This invokes
1760 :meth:`~sideshow.orders.OrderHandler.process_contact_success()`
1761 on the :attr:`~OrderItemView.order_handler`, then redirects
1762 user back to the index page.
1763 """
1764 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1765 note = self.request.POST.get('note', '').strip() or None
1767 self.order_handler.process_contact_success(items, self.request.user, note=note)
1769 self.request.session.flash(f"{len(items)} Order Items were marked as contacted")
1770 return self.redirect(self.get_index_url())
1772 def process_contact_failure(self):
1773 """
1774 View to process the "contact failure" step for some order
1775 item(s).
1777 This requires a POST request with data:
1779 :param item_uuids: Comma-delimited list of
1780 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1782 :param note: Optional note text from the user.
1784 This invokes
1785 :meth:`~sideshow.orders.OrderHandler.process_contact_failure()`
1786 on the :attr:`~OrderItemView.order_handler`, then redirects
1787 user back to the index page.
1788 """
1789 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1790 note = self.request.POST.get('note', '').strip() or None
1792 self.order_handler.process_contact_failure(items, self.request.user, note=note)
1794 self.request.session.flash(f"{len(items)} Order Items were marked as contact failed")
1795 return self.redirect(self.get_index_url())
1797 @classmethod
1798 def defaults(cls, config):
1799 cls._order_item_defaults(config)
1800 cls._contact_defaults(config)
1801 cls._defaults(config)
1803 @classmethod
1804 def _contact_defaults(cls, config):
1805 route_prefix = cls.get_route_prefix()
1806 permission_prefix = cls.get_permission_prefix()
1807 url_prefix = cls.get_url_prefix()
1808 model_title_plural = cls.get_model_title_plural()
1810 # common perm for processing contact success + failure
1811 config.add_wutta_permission(permission_prefix,
1812 f'{permission_prefix}.process_contact',
1813 f"Process contact success/failure for {model_title_plural}")
1815 # process contact success
1816 config.add_route(f'{route_prefix}.process_contact_success',
1817 f'{url_prefix}/process-contact-success',
1818 request_method='POST')
1819 config.add_view(cls, attr='process_contact_success',
1820 route_name=f'{route_prefix}.process_contact_success',
1821 permission=f'{permission_prefix}.process_contact')
1823 # process contact failure
1824 config.add_route(f'{route_prefix}.process_contact_failure',
1825 f'{url_prefix}/process-contact-failure',
1826 request_method='POST')
1827 config.add_view(cls, attr='process_contact_failure',
1828 route_name=f'{route_prefix}.process_contact_failure',
1829 permission=f'{permission_prefix}.process_contact')
1832class DeliveryView(OrderItemView):
1833 """
1834 Master view for the "delivery" phase of
1835 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1836 ``delivery``. This is a subclass of :class:`OrderItemView`.
1838 This class auto-filters so only order items with the following
1839 status codes are shown:
1841 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED`
1842 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACTED`
1844 Notable URLs provided by this class:
1846 * ``/delivery/``
1847 * ``/delivery/XXX``
1848 """
1849 model_title = "Order Item (Delivery)"
1850 model_title_plural = "Order Items (Delivery)"
1851 route_prefix = 'order_items_delivery'
1852 url_prefix = '/delivery'
1854 def get_query(self, session=None):
1855 """ """
1856 query = super().get_query(session=session)
1857 model = self.app.model
1858 enum = self.app.enum
1859 return query.filter(model.OrderItem.status_code.in_((
1860 enum.ORDER_ITEM_STATUS_RECEIVED,
1861 enum.ORDER_ITEM_STATUS_CONTACTED)))
1863 def configure_grid(self, g):
1864 """ """
1865 super().configure_grid(g)
1867 # checkable
1868 if self.has_any_perm('process_delivery', 'process_restock'):
1869 g.checkable = True
1871 # tool button: Delivered
1872 if self.has_perm('process_delivery'):
1873 button = self.make_button("Delivered", primary=True,
1874 icon_left='check',
1875 **{'@click': "$emit('process-delivery', checkedRows)",
1876 ':disabled': '!checkedRows.length'})
1877 g.add_tool(button, key='process_delivery')
1879 # tool button: Restocked
1880 if self.has_perm('process_restock'):
1881 button = self.make_button("Restocked",
1882 icon_left='redo',
1883 **{'@click': "$emit('process-restock', checkedRows)",
1884 ':disabled': '!checkedRows.length'})
1885 g.add_tool(button, key='process_restock')
1887 def process_delivery(self):
1888 """
1889 View to process the "delivery" step for some order item(s).
1891 This requires a POST request with data:
1893 :param item_uuids: Comma-delimited list of
1894 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1896 :param note: Optional note text from the user.
1898 This invokes
1899 :meth:`~sideshow.orders.OrderHandler.process_delivery()` on
1900 the :attr:`~OrderItemView.order_handler`, then redirects user
1901 back to the index page.
1902 """
1903 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1904 note = self.request.POST.get('note', '').strip() or None
1906 self.order_handler.process_delivery(items, self.request.user, note=note)
1908 self.request.session.flash(f"{len(items)} Order Items were marked as delivered")
1909 return self.redirect(self.get_index_url())
1911 def process_restock(self):
1912 """
1913 View to process the "restock" step for some order item(s).
1915 This requires a POST request with data:
1917 :param item_uuids: Comma-delimited list of
1918 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1920 :param note: Optional note text from the user.
1922 This invokes
1923 :meth:`~sideshow.orders.OrderHandler.process_restock()` on the
1924 :attr:`~OrderItemView.order_handler`, then redirects user back
1925 to the index page.
1926 """
1927 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1928 note = self.request.POST.get('note', '').strip() or None
1930 self.order_handler.process_restock(items, self.request.user, note=note)
1932 self.request.session.flash(f"{len(items)} Order Items were marked as restocked")
1933 return self.redirect(self.get_index_url())
1935 @classmethod
1936 def defaults(cls, config):
1937 cls._order_item_defaults(config)
1938 cls._delivery_defaults(config)
1939 cls._defaults(config)
1941 @classmethod
1942 def _delivery_defaults(cls, config):
1943 route_prefix = cls.get_route_prefix()
1944 permission_prefix = cls.get_permission_prefix()
1945 url_prefix = cls.get_url_prefix()
1946 model_title_plural = cls.get_model_title_plural()
1948 # process delivery
1949 config.add_wutta_permission(permission_prefix,
1950 f'{permission_prefix}.process_delivery',
1951 f"Process delivery for {model_title_plural}")
1952 config.add_route(f'{route_prefix}.process_delivery',
1953 f'{url_prefix}/process-delivery',
1954 request_method='POST')
1955 config.add_view(cls, attr='process_delivery',
1956 route_name=f'{route_prefix}.process_delivery',
1957 permission=f'{permission_prefix}.process_delivery')
1959 # process restock
1960 config.add_wutta_permission(permission_prefix,
1961 f'{permission_prefix}.process_restock',
1962 f"Process restock for {model_title_plural}")
1963 config.add_route(f'{route_prefix}.process_restock',
1964 f'{url_prefix}/process-restock',
1965 request_method='POST')
1966 config.add_view(cls, attr='process_restock',
1967 route_name=f'{route_prefix}.process_restock',
1968 permission=f'{permission_prefix}.process_restock')
1971def defaults(config, **kwargs):
1972 base = globals()
1974 OrderView = kwargs.get('OrderView', base['OrderView'])
1975 OrderView.defaults(config)
1977 OrderItemView = kwargs.get('OrderItemView', base['OrderItemView'])
1978 OrderItemView.defaults(config)
1980 PlacementView = kwargs.get('PlacementView', base['PlacementView'])
1981 PlacementView.defaults(config)
1983 ReceivingView = kwargs.get('ReceivingView', base['ReceivingView'])
1984 ReceivingView.defaults(config)
1986 ContactView = kwargs.get('ContactView', base['ContactView'])
1987 ContactView.defaults(config)
1989 DeliveryView = kwargs.get('DeliveryView', base['DeliveryView'])
1990 DeliveryView.defaults(config)
1993def includeme(config):
1994 defaults(config)