Coverage for .tox/coverage/lib/python3.11/site-packages/sideshow/web/views/orders.py: 100%
763 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-16 07:16 -0500
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-16 07:16 -0500
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 json
29import logging
30import re
32import colander
33import sqlalchemy as sa
34from sqlalchemy import orm
36from webhelpers2.html import tags, HTML
38from wuttaweb.views import MasterView
39from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum
40from wuttaweb.util import make_json_safe
42from sideshow.db.model import Order, OrderItem
43from sideshow.batch.neworder import NewOrderBatchHandler
44from sideshow.web.forms.schema import (OrderRef,
45 LocalCustomerRef, LocalProductRef,
46 PendingCustomerRef, PendingProductRef)
49log = logging.getLogger(__name__)
52class OrderView(MasterView):
53 """
54 Master view for :class:`~sideshow.db.model.orders.Order`; route
55 prefix is ``orders``.
57 Notable URLs provided by this class:
59 * ``/orders/``
60 * ``/orders/new``
61 * ``/orders/XXX``
62 * ``/orders/XXX/delete``
64 Note that the "edit" view is not exposed here; user must perform
65 various other workflow actions to modify the order.
67 .. attribute:: order_handler
69 Reference to the :term:`order handler` as returned by
70 :meth:`~sideshow.app.SideshowAppProvider.get_order_handler()`.
71 This gets set in the constructor.
73 .. attribute:: batch_handler
75 Reference to the :term:`new order batch` handler. This gets
76 set in the constructor.
77 """
78 model_class = Order
79 editable = False
80 configurable = True
82 labels = {
83 'order_id': "Order ID",
84 'store_id': "Store ID",
85 'customer_id': "Customer ID",
86 }
88 grid_columns = [
89 'order_id',
90 'store_id',
91 'customer_id',
92 'customer_name',
93 'total_price',
94 'created',
95 'created_by',
96 ]
98 sort_defaults = ('order_id', 'desc')
100 form_fields = [
101 'order_id',
102 'store_id',
103 'customer_id',
104 'local_customer',
105 'pending_customer',
106 'customer_name',
107 'phone_number',
108 'email_address',
109 'total_price',
110 'created',
111 'created_by',
112 ]
114 has_rows = True
115 row_model_class = OrderItem
116 rows_title = "Order Items"
117 rows_sort_defaults = 'sequence'
118 rows_viewable = True
120 row_labels = {
121 'product_scancode': "Scancode",
122 'product_brand': "Brand",
123 'product_description': "Description",
124 'product_size': "Size",
125 'department_name': "Department",
126 'order_uom': "Order UOM",
127 'status_code': "Status",
128 }
130 row_grid_columns = [
131 'sequence',
132 'product_scancode',
133 'product_brand',
134 'product_description',
135 'product_size',
136 'department_name',
137 'special_order',
138 'order_qty',
139 'order_uom',
140 'discount_percent',
141 'total_price',
142 'status_code',
143 ]
145 PENDING_PRODUCT_ENTRY_FIELDS = [
146 'scancode',
147 'brand_name',
148 'description',
149 'size',
150 'department_id',
151 'department_name',
152 'vendor_name',
153 'vendor_item_code',
154 'case_size',
155 'unit_cost',
156 'unit_price_reg',
157 ]
159 def __init__(self, request, context=None):
160 super().__init__(request, context=context)
161 self.order_handler = self.app.get_order_handler()
162 self.batch_handler = self.app.get_batch_handler('neworder')
164 def configure_grid(self, g):
165 """ """
166 super().configure_grid(g)
168 # store_id
169 if not self.order_handler.expose_store_id():
170 g.remove('store_id')
172 # order_id
173 g.set_link('order_id')
175 # customer_id
176 g.set_link('customer_id')
178 # customer_name
179 g.set_link('customer_name')
181 # total_price
182 g.set_renderer('total_price', g.render_currency)
184 def create(self):
185 """
186 Instead of the typical "create" view, this displays a "wizard"
187 of sorts.
189 Under the hood a
190 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` is
191 automatically created for the user when they first visit this
192 page. They can select a customer, add items etc.
194 When user is finished assembling the order (i.e. populating
195 the batch), they submit it. This of course executes the
196 batch, which in turn creates a true
197 :class:`~sideshow.db.model.orders.Order`, and user is
198 redirected to the "view order" page.
200 See also these methods which may be called from this one,
201 based on user actions:
203 * :meth:`start_over()`
204 * :meth:`cancel_order()`
205 * :meth:`set_store()`
206 * :meth:`assign_customer()`
207 * :meth:`unassign_customer()`
208 * :meth:`set_pending_customer()`
209 * :meth:`get_product_info()`
210 * :meth:`add_item()`
211 * :meth:`update_item()`
212 * :meth:`delete_item()`
213 * :meth:`submit_order()`
214 """
215 model = self.app.model
216 enum = self.app.enum
217 session = self.Session()
218 batch = self.get_current_batch()
219 self.creating = True
221 context = self.get_context_customer(batch)
223 if self.request.method == 'POST':
225 # first we check for traditional form post
226 action = self.request.POST.get('action')
227 post_actions = [
228 'start_over',
229 'cancel_order',
230 ]
231 if action in post_actions:
232 return getattr(self, action)(batch)
234 # okay then, we'll assume newer JSON-style post params
235 data = dict(self.request.json_body)
236 action = data.pop('action')
237 json_actions = [
238 'set_store',
239 'assign_customer',
240 'unassign_customer',
241 # 'update_phone_number',
242 # 'update_email_address',
243 'set_pending_customer',
244 # 'get_customer_info',
245 # # 'set_customer_data',
246 'get_product_info',
247 'get_past_products',
248 'add_item',
249 'update_item',
250 'delete_item',
251 'submit_order',
252 ]
253 if action in json_actions:
254 try:
255 result = getattr(self, action)(batch, data)
256 except Exception as error:
257 log.warning("error calling json action for order", exc_info=True)
258 result = {'error': self.app.render_error(error)}
259 return self.json_response(result)
261 return self.json_response({'error': "unknown form action"})
263 context.update({
264 'batch': batch,
265 'normalized_batch': self.normalize_batch(batch),
266 'order_items': [self.normalize_row(row)
267 for row in batch.rows],
268 'default_uom_choices': self.batch_handler.get_default_uom_choices(),
269 'default_uom': None, # TODO?
270 'expose_store_id': self.order_handler.expose_store_id(),
271 'allow_item_discounts': self.batch_handler.allow_item_discounts(),
272 'allow_unknown_products': (self.batch_handler.allow_unknown_products()
273 and self.has_perm('create_unknown_product')),
274 'pending_product_required_fields': self.get_pending_product_required_fields(),
275 'allow_past_item_reorder': True, # TODO: make configurable?
276 })
278 if context['expose_store_id']:
279 stores = session.query(model.Store)\
280 .filter(model.Store.archived == False)\
281 .order_by(model.Store.store_id)\
282 .all()
283 context['stores'] = [{'store_id': store.store_id, 'display': store.get_display()}
284 for store in stores]
286 # set default so things just work
287 if not batch.store_id:
288 batch.store_id = self.batch_handler.get_default_store_id()
290 if context['allow_item_discounts']:
291 context['allow_item_discounts_if_on_sale'] = self.batch_handler\
292 .allow_item_discounts_if_on_sale()
293 # nb. render quantity so that '10.0' => '10'
294 context['default_item_discount'] = self.app.render_quantity(
295 self.batch_handler.get_default_item_discount())
296 context['dept_item_discounts'] = dict([(d['department_id'], d['default_item_discount'])
297 for d in self.get_dept_item_discounts()])
299 return self.render_to_response('create', context)
301 def get_current_batch(self):
302 """
303 Returns the current batch for the current user.
305 This looks for a new order batch which was created by the
306 user, but not yet executed. If none is found, a new batch is
307 created.
309 :returns:
310 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
311 instance
312 """
313 model = self.app.model
314 session = self.Session()
316 user = self.request.user
317 if not user:
318 raise self.forbidden()
320 try:
321 # there should be at most *one* new batch per user
322 batch = session.query(model.NewOrderBatch)\
323 .filter(model.NewOrderBatch.created_by == user)\
324 .filter(model.NewOrderBatch.executed == None)\
325 .one()
327 except orm.exc.NoResultFound:
328 # no batch yet for this user, so make one
329 batch = self.batch_handler.make_batch(session, created_by=user)
330 session.add(batch)
331 session.flush()
333 return batch
335 def customer_autocomplete(self):
336 """
337 AJAX view for customer autocomplete, when entering new order.
339 This invokes one of the following on the
340 :attr:`batch_handler`:
342 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_customers_external()`
343 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_customers_local()`
345 :returns: List of search results; each should be a dict with
346 ``value`` and ``label`` keys.
347 """
348 session = self.Session()
349 term = self.request.GET.get('term', '').strip()
350 if not term:
351 return []
353 handler = self.batch_handler
354 if handler.use_local_customers():
355 return handler.autocomplete_customers_local(session, term, user=self.request.user)
356 else:
357 return handler.autocomplete_customers_external(session, term, user=self.request.user)
359 def product_autocomplete(self):
360 """
361 AJAX view for product autocomplete, when entering new order.
363 This invokes one of the following on the
364 :attr:`batch_handler`:
366 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_products_external()`
367 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_products_local()`
369 :returns: List of search results; each should be a dict with
370 ``value`` and ``label`` keys.
371 """
372 session = self.Session()
373 term = self.request.GET.get('term', '').strip()
374 if not term:
375 return []
377 handler = self.batch_handler
378 if handler.use_local_products():
379 return handler.autocomplete_products_local(session, term, user=self.request.user)
380 else:
381 return handler.autocomplete_products_external(session, term, user=self.request.user)
383 def get_pending_product_required_fields(self):
384 """ """
385 required = []
386 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
387 require = self.config.get_bool(
388 f'sideshow.orders.unknown_product.fields.{field}.required')
389 if require is None and field == 'description':
390 require = True
391 if require:
392 required.append(field)
393 return required
395 def get_dept_item_discounts(self):
396 """
397 Returns the list of per-department default item discount settings.
399 Each entry in the list will look like::
401 {
402 'department_id': '42',
403 'department_name': 'Grocery',
404 'default_item_discount': 10,
405 }
407 :returns: List of department settings as shown above.
408 """
409 model = self.app.model
410 session = self.Session()
411 pattern = re.compile(r'^sideshow\.orders\.departments\.([^.]+)\.default_item_discount$')
413 dept_item_discounts = []
414 settings = session.query(model.Setting)\
415 .filter(model.Setting.name.like('sideshow.orders.departments.%.default_item_discount'))\
416 .all()
417 for setting in settings:
418 match = pattern.match(setting.name)
419 if not match:
420 log.warning("invalid setting name: %s", setting.name)
421 continue
422 deptid = match.group(1)
423 name = self.app.get_setting(session, f'sideshow.orders.departments.{deptid}.name')
424 dept_item_discounts.append({
425 'department_id': deptid,
426 'department_name': name,
427 'default_item_discount': setting.value,
428 })
429 dept_item_discounts.sort(key=lambda d: d['department_name'])
430 return dept_item_discounts
432 def start_over(self, batch):
433 """
434 This will delete the user's current batch, then redirect user
435 back to "Create Order" page, which in turn will auto-create a
436 new batch for them.
438 This is a "batch action" method which may be called from
439 :meth:`create()`. See also:
441 * :meth:`cancel_order()`
442 * :meth:`submit_order()`
443 """
444 # drop current batch
445 self.batch_handler.do_delete(batch, self.request.user)
446 self.Session.flush()
448 # send back to "create order" which makes new batch
449 route_prefix = self.get_route_prefix()
450 url = self.request.route_url(f'{route_prefix}.create')
451 return self.redirect(url)
453 def cancel_order(self, batch):
454 """
455 This will delete the user's current batch, then redirect user
456 back to "List Orders" page.
458 This is a "batch action" method which may be called from
459 :meth:`create()`. See also:
461 * :meth:`start_over()`
462 * :meth:`submit_order()`
463 """
464 self.batch_handler.do_delete(batch, self.request.user)
465 self.Session.flush()
467 # set flash msg just to be more obvious
468 self.request.session.flash("New order has been deleted.")
470 # send user back to orders list, w/ no new batch generated
471 url = self.get_index_url()
472 return self.redirect(url)
474 def set_store(self, batch, data):
475 """
476 Assign the
477 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`
478 for a batch.
480 This is a "batch action" method which may be called from
481 :meth:`create()`.
482 """
483 store_id = data.get('store_id')
484 if not store_id:
485 return {'error': "Must provide store_id"}
487 batch.store_id = store_id
488 return self.get_context_customer(batch)
490 def get_context_customer(self, batch):
491 """ """
492 context = {
493 'store_id': batch.store_id,
494 'customer_is_known': True,
495 'customer_id': None,
496 'customer_name': batch.customer_name,
497 'phone_number': batch.phone_number,
498 'email_address': batch.email_address,
499 }
501 # customer_id
502 use_local = self.batch_handler.use_local_customers()
503 if use_local:
504 local = batch.local_customer
505 if local:
506 context['customer_id'] = local.uuid.hex
507 else: # use external
508 context['customer_id'] = batch.customer_id
510 # pending customer
511 pending = batch.pending_customer
512 if pending:
513 context.update({
514 'new_customer_first_name': pending.first_name,
515 'new_customer_last_name': pending.last_name,
516 'new_customer_full_name': pending.full_name,
517 'new_customer_phone': pending.phone_number,
518 'new_customer_email': pending.email_address,
519 })
521 # declare customer "not known" only if pending is in use
522 if (pending
523 and not batch.customer_id and not batch.local_customer
524 and batch.customer_name):
525 context['customer_is_known'] = False
527 return context
529 def assign_customer(self, batch, data):
530 """
531 Assign the true customer account for a batch.
533 This calls
534 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
535 for the heavy lifting.
537 This is a "batch action" method which may be called from
538 :meth:`create()`. See also:
540 * :meth:`unassign_customer()`
541 * :meth:`set_pending_customer()`
542 """
543 customer_id = data.get('customer_id')
544 if not customer_id:
545 return {'error': "Must provide customer_id"}
547 self.batch_handler.set_customer(batch, customer_id)
548 return self.get_context_customer(batch)
550 def unassign_customer(self, batch, data):
551 """
552 Clear the customer info for a batch.
554 This calls
555 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
556 for the heavy lifting.
558 This is a "batch action" method which may be called from
559 :meth:`create()`. See also:
561 * :meth:`assign_customer()`
562 * :meth:`set_pending_customer()`
563 """
564 self.batch_handler.set_customer(batch, None)
565 return self.get_context_customer(batch)
567 def set_pending_customer(self, batch, data):
568 """
569 This will set/update the batch pending customer info.
571 This calls
572 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
573 for the heavy lifting.
575 This is a "batch action" method which may be called from
576 :meth:`create()`. See also:
578 * :meth:`assign_customer()`
579 * :meth:`unassign_customer()`
580 """
581 self.batch_handler.set_customer(batch, data, user=self.request.user)
582 return self.get_context_customer(batch)
584 def get_product_info(self, batch, data):
585 """
586 Fetch data for a specific product.
588 Depending on config, this calls one of the following to get
589 its primary data:
591 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_local()`
592 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()`
594 It then may supplement the data with additional fields.
596 This is a "batch action" method which may be called from
597 :meth:`create()`.
599 :returns: Dict of product info.
600 """
601 product_id = data.get('product_id')
602 if not product_id:
603 return {'error': "Must specify a product ID"}
605 session = self.Session()
606 use_local = self.batch_handler.use_local_products()
607 if use_local:
608 data = self.batch_handler.get_product_info_local(session, product_id)
609 else:
610 data = self.batch_handler.get_product_info_external(session, product_id)
612 if 'error' in data:
613 return data
615 if 'unit_price_reg' in data and 'unit_price_reg_display' not in data:
616 data['unit_price_reg_display'] = self.app.render_currency(data['unit_price_reg'])
618 if 'unit_price_reg' in data and 'unit_price_quoted' not in data:
619 data['unit_price_quoted'] = data['unit_price_reg']
621 if 'unit_price_quoted' in data and 'unit_price_quoted_display' not in data:
622 data['unit_price_quoted_display'] = self.app.render_currency(data['unit_price_quoted'])
624 if 'case_price_quoted' not in data:
625 if data.get('unit_price_quoted') is not None and data.get('case_size') is not None:
626 data['case_price_quoted'] = data['unit_price_quoted'] * data['case_size']
628 if 'case_price_quoted' in data and 'case_price_quoted_display' not in data:
629 data['case_price_quoted_display'] = self.app.render_currency(data['case_price_quoted'])
631 decimal_fields = [
632 'case_size',
633 'unit_price_reg',
634 'unit_price_quoted',
635 'case_price_quoted',
636 'default_item_discount',
637 ]
639 for field in decimal_fields:
640 if field in list(data):
641 value = data[field]
642 if isinstance(value, decimal.Decimal):
643 data[field] = float(value)
645 return data
647 def get_past_products(self, batch, data):
648 """
649 Fetch past products for convenient re-ordering.
651 This essentially calls
652 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_past_products()`
653 on the :attr:`batch_handler` and returns the result.
655 This is a "batch action" method which may be called from
656 :meth:`create()`.
658 :returns: List of product info dicts.
659 """
660 past_products = self.batch_handler.get_past_products(batch)
661 return make_json_safe(past_products)
663 def add_item(self, batch, data):
664 """
665 This adds a row to the user's current new order batch.
667 This is a "batch action" method which may be called from
668 :meth:`create()`. See also:
670 * :meth:`update_item()`
671 * :meth:`delete_item()`
672 """
673 kw = {'user': self.request.user}
674 if 'discount_percent' in data and self.batch_handler.allow_item_discounts():
675 kw['discount_percent'] = data['discount_percent']
676 row = self.batch_handler.add_item(batch, data['product_info'],
677 data['order_qty'], data['order_uom'], **kw)
679 return {'batch': self.normalize_batch(batch),
680 'row': self.normalize_row(row)}
682 def update_item(self, batch, data):
683 """
684 This updates a row in the user's current new order batch.
686 This is a "batch action" method which may be called from
687 :meth:`create()`. See also:
689 * :meth:`add_item()`
690 * :meth:`delete_item()`
691 """
692 model = self.app.model
693 session = self.Session()
695 uuid = data.get('uuid')
696 if not uuid:
697 return {'error': "Must specify row UUID"}
699 row = session.get(model.NewOrderBatchRow, uuid)
700 if not row:
701 return {'error': "Row not found"}
703 if row.batch is not batch:
704 return {'error': "Row is for wrong batch"}
706 kw = {'user': self.request.user}
707 if 'discount_percent' in data and self.batch_handler.allow_item_discounts():
708 kw['discount_percent'] = data['discount_percent']
709 self.batch_handler.update_item(row, data['product_info'],
710 data['order_qty'], data['order_uom'], **kw)
712 return {'batch': self.normalize_batch(batch),
713 'row': self.normalize_row(row)}
715 def delete_item(self, batch, data):
716 """
717 This deletes a row from the user's current new order batch.
719 This is a "batch action" method which may be called from
720 :meth:`create()`. See also:
722 * :meth:`add_item()`
723 * :meth:`update_item()`
724 """
725 model = self.app.model
726 session = self.app.get_session(batch)
728 uuid = data.get('uuid')
729 if not uuid:
730 return {'error': "Must specify a row UUID"}
732 row = session.get(model.NewOrderBatchRow, uuid)
733 if not row:
734 return {'error': "Row not found"}
736 if row.batch is not batch:
737 return {'error': "Row is for wrong batch"}
739 self.batch_handler.do_remove_row(row)
740 return {'batch': self.normalize_batch(batch)}
742 def submit_order(self, batch, data):
743 """
744 This submits the user's current new order batch, hence
745 executing the batch and creating the true order.
747 This is a "batch action" method which may be called from
748 :meth:`create()`. See also:
750 * :meth:`start_over()`
751 * :meth:`cancel_order()`
752 """
753 user = self.request.user
754 reason = self.batch_handler.why_not_execute(batch, user=user)
755 if reason:
756 return {'error': reason}
758 try:
759 order = self.batch_handler.do_execute(batch, user)
760 except Exception as error:
761 log.warning("failed to execute new order batch: %s", batch,
762 exc_info=True)
763 return {'error': self.app.render_error(error)}
765 return {
766 'next_url': self.get_action_url('view', order),
767 }
769 def normalize_batch(self, batch):
770 """ """
771 return {
772 'uuid': batch.uuid.hex,
773 'total_price': str(batch.total_price or 0),
774 'total_price_display': self.app.render_currency(batch.total_price),
775 'status_code': batch.status_code,
776 'status_text': batch.status_text,
777 }
779 def normalize_row(self, row):
780 """ """
781 data = {
782 'uuid': row.uuid.hex,
783 'sequence': row.sequence,
784 'product_id': None,
785 'product_scancode': row.product_scancode,
786 'product_brand': row.product_brand,
787 'product_description': row.product_description,
788 'product_size': row.product_size,
789 'product_full_description': self.app.make_full_name(row.product_brand,
790 row.product_description,
791 row.product_size),
792 'product_weighed': row.product_weighed,
793 'department_id': row.department_id,
794 'department_name': row.department_name,
795 'special_order': row.special_order,
796 'vendor_name': row.vendor_name,
797 'vendor_item_code': row.vendor_item_code,
798 'case_size': float(row.case_size) if row.case_size is not None else None,
799 'order_qty': float(row.order_qty),
800 'order_uom': row.order_uom,
801 'discount_percent': self.app.render_quantity(row.discount_percent),
802 'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None,
803 'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted),
804 'case_price_quoted': float(row.case_price_quoted) if row.case_price_quoted is not None else None,
805 'case_price_quoted_display': self.app.render_currency(row.case_price_quoted),
806 'total_price': float(row.total_price) if row.total_price is not None else None,
807 'total_price_display': self.app.render_currency(row.total_price),
808 'status_code': row.status_code,
809 'status_text': row.status_text,
810 }
812 use_local = self.batch_handler.use_local_products()
814 # product_id
815 if use_local:
816 if row.local_product:
817 data['product_id'] = row.local_product.uuid.hex
818 else:
819 data['product_id'] = row.product_id
821 # vendor_name
822 if use_local:
823 if row.local_product:
824 data['vendor_name'] = row.local_product.vendor_name
825 else: # use external
826 pass # TODO
827 if not data.get('product_id') and row.pending_product:
828 data['vendor_name'] = row.pending_product.vendor_name
830 if row.unit_price_reg:
831 data['unit_price_reg'] = float(row.unit_price_reg)
832 data['unit_price_reg_display'] = self.app.render_currency(row.unit_price_reg)
834 if row.unit_price_sale:
835 data['unit_price_sale'] = float(row.unit_price_sale)
836 data['unit_price_sale_display'] = self.app.render_currency(row.unit_price_sale)
837 if row.sale_ends:
838 sale_ends = row.sale_ends
839 data['sale_ends'] = str(row.sale_ends)
840 data['sale_ends_display'] = self.app.render_date(row.sale_ends)
842 if row.pending_product:
843 pending = row.pending_product
844 data['pending_product'] = {
845 'uuid': pending.uuid.hex,
846 'scancode': pending.scancode,
847 'brand_name': pending.brand_name,
848 'description': pending.description,
849 'size': pending.size,
850 'department_id': pending.department_id,
851 'department_name': pending.department_name,
852 'unit_price_reg': float(pending.unit_price_reg) if pending.unit_price_reg is not None else None,
853 'vendor_name': pending.vendor_name,
854 'vendor_item_code': pending.vendor_item_code,
855 'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None,
856 'case_size': float(pending.case_size) if pending.case_size is not None else None,
857 'notes': pending.notes,
858 'special_order': pending.special_order,
859 }
861 # display text for order qty/uom
862 data['order_qty_display'] = self.order_handler.get_order_qty_uom_text(
863 row.order_qty, row.order_uom, case_size=row.case_size, html=True)
865 return data
867 def get_instance_title(self, order):
868 """ """
869 return f"#{order.order_id} for {order.customer_name}"
871 def configure_form(self, f):
872 """ """
873 super().configure_form(f)
874 order = f.model_instance
876 # store_id
877 if not self.order_handler.expose_store_id():
878 f.remove('store_id')
880 # local_customer
881 if order.customer_id and not order.local_customer:
882 f.remove('local_customer')
883 else:
884 f.set_node('local_customer', LocalCustomerRef(self.request))
886 # pending_customer
887 if order.customer_id or order.local_customer:
888 f.remove('pending_customer')
889 else:
890 f.set_node('pending_customer', PendingCustomerRef(self.request))
892 # total_price
893 f.set_node('total_price', WuttaMoney(self.request))
895 # created_by
896 f.set_node('created_by', UserRef(self.request))
897 f.set_readonly('created_by')
899 def get_xref_buttons(self, order):
900 """ """
901 buttons = super().get_xref_buttons(order)
902 model = self.app.model
903 session = self.Session()
905 if self.request.has_perm('neworder_batches.view'):
906 batch = session.query(model.NewOrderBatch)\
907 .filter(model.NewOrderBatch.id == order.order_id)\
908 .first()
909 if batch:
910 url = self.request.route_url('neworder_batches.view', uuid=batch.uuid)
911 buttons.append(
912 self.make_button("View the Batch", primary=True, icon_left='eye', url=url))
914 return buttons
916 def get_row_grid_data(self, order):
917 """ """
918 model = self.app.model
919 session = self.Session()
920 return session.query(model.OrderItem)\
921 .filter(model.OrderItem.order == order)
923 def configure_row_grid(self, g):
924 """ """
925 super().configure_row_grid(g)
926 # enum = self.app.enum
928 # sequence
929 g.set_label('sequence', "Seq.", column_only=True)
930 g.set_link('sequence')
932 # product_scancode
933 g.set_link('product_scancode')
935 # product_brand
936 g.set_link('product_brand')
938 # product_description
939 g.set_link('product_description')
941 # product_size
942 g.set_link('product_size')
944 # TODO
945 # order_uom
946 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
948 # discount_percent
949 g.set_renderer('discount_percent', 'percent')
950 g.set_label('discount_percent', "Disc. %", column_only=True)
952 # total_price
953 g.set_renderer('total_price', g.render_currency)
955 # status_code
956 g.set_renderer('status_code', self.render_status_code)
958 # TODO: upstream should set this automatically
959 g.row_class = self.row_grid_row_class
961 def row_grid_row_class(self, item, data, i):
962 """ """
963 variant = self.order_handler.item_status_to_variant(item.status_code)
964 if variant:
965 return f'has-background-{variant}'
967 def render_status_code(self, item, key, value):
968 """ """
969 enum = self.app.enum
970 return enum.ORDER_ITEM_STATUS[value]
972 def get_row_action_url_view(self, item, i):
973 """ """
974 return self.request.route_url('order_items.view', uuid=item.uuid)
976 def configure_get_simple_settings(self):
977 """ """
978 settings = [
980 # stores
981 {'name': 'sideshow.orders.expose_store_id',
982 'type': bool},
983 {'name': 'sideshow.orders.default_store_id'},
985 # customers
986 {'name': 'sideshow.orders.use_local_customers',
987 # nb. this is really a bool but we present as string in config UI
988 #'type': bool,
989 'default': 'true'},
991 # products
992 {'name': 'sideshow.orders.use_local_products',
993 # nb. this is really a bool but we present as string in config UI
994 #'type': bool,
995 'default': 'true'},
996 {'name': 'sideshow.orders.allow_unknown_products',
997 'type': bool,
998 'default': True},
1000 # pricing
1001 {'name': 'sideshow.orders.allow_item_discounts',
1002 'type': bool},
1003 {'name': 'sideshow.orders.allow_item_discounts_if_on_sale',
1004 'type': bool},
1005 {'name': 'sideshow.orders.default_item_discount',
1006 'type': float},
1008 # batches
1009 {'name': 'wutta.batch.neworder.handler.spec'},
1010 ]
1012 # required fields for new product entry
1013 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
1014 setting = {'name': f'sideshow.orders.unknown_product.fields.{field}.required',
1015 'type': bool}
1016 if field == 'description':
1017 setting['default'] = True
1018 settings.append(setting)
1020 return settings
1022 def configure_get_context(self, **kwargs):
1023 """ """
1024 context = super().configure_get_context(**kwargs)
1026 context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS
1028 handlers = self.app.get_batch_handler_specs('neworder')
1029 handlers = [{'spec': spec} for spec in handlers]
1030 context['batch_handlers'] = handlers
1032 context['dept_item_discounts'] = self.get_dept_item_discounts()
1034 return context
1036 def configure_gather_settings(self, data, simple_settings=None):
1037 """ """
1038 settings = super().configure_gather_settings(data, simple_settings=simple_settings)
1040 for dept in json.loads(data['dept_item_discounts']):
1041 deptid = dept['department_id']
1042 settings.append({'name': f'sideshow.orders.departments.{deptid}.name',
1043 'value': dept['department_name']})
1044 settings.append({'name': f'sideshow.orders.departments.{deptid}.default_item_discount',
1045 'value': dept['default_item_discount']})
1047 return settings
1049 def configure_remove_settings(self, **kwargs):
1050 """ """
1051 model = self.app.model
1052 session = self.Session()
1054 super().configure_remove_settings(**kwargs)
1056 to_delete = session.query(model.Setting)\
1057 .filter(sa.or_(
1058 model.Setting.name.like('sideshow.orders.departments.%.name'),
1059 model.Setting.name.like('sideshow.orders.departments.%.default_item_discount')))\
1060 .all()
1061 for setting in to_delete:
1062 self.app.delete_setting(session, setting.name)
1065 @classmethod
1066 def defaults(cls, config):
1067 cls._order_defaults(config)
1068 cls._defaults(config)
1070 @classmethod
1071 def _order_defaults(cls, config):
1072 route_prefix = cls.get_route_prefix()
1073 permission_prefix = cls.get_permission_prefix()
1074 url_prefix = cls.get_url_prefix()
1075 model_title = cls.get_model_title()
1076 model_title_plural = cls.get_model_title_plural()
1078 # fix perm group
1079 config.add_wutta_permission_group(permission_prefix,
1080 model_title_plural,
1081 overwrite=False)
1083 # extra perm required to create order with unknown/pending product
1084 config.add_wutta_permission(permission_prefix,
1085 f'{permission_prefix}.create_unknown_product',
1086 f"Create new {model_title} for unknown/pending product")
1088 # customer autocomplete
1089 config.add_route(f'{route_prefix}.customer_autocomplete',
1090 f'{url_prefix}/customer-autocomplete',
1091 request_method='GET')
1092 config.add_view(cls, attr='customer_autocomplete',
1093 route_name=f'{route_prefix}.customer_autocomplete',
1094 renderer='json',
1095 permission=f'{permission_prefix}.list')
1097 # product autocomplete
1098 config.add_route(f'{route_prefix}.product_autocomplete',
1099 f'{url_prefix}/product-autocomplete',
1100 request_method='GET')
1101 config.add_view(cls, attr='product_autocomplete',
1102 route_name=f'{route_prefix}.product_autocomplete',
1103 renderer='json',
1104 permission=f'{permission_prefix}.list')
1107class OrderItemView(MasterView):
1108 """
1109 Master view for :class:`~sideshow.db.model.orders.OrderItem`;
1110 route prefix is ``order_items``.
1112 Notable URLs provided by this class:
1114 * ``/order-items/``
1115 * ``/order-items/XXX``
1117 This class serves both as a proper master view (for "all" order
1118 items) as well as a base class for other "workflow" master views,
1119 each of which auto-filters by order item status:
1121 * :class:`PlacementView`
1122 * :class:`ReceivingView`
1123 * :class:`ContactView`
1124 * :class:`DeliveryView`
1126 Note that this does not expose create, edit or delete. The user
1127 must perform various other workflow actions to modify the item.
1129 .. attribute:: order_handler
1131 Reference to the :term:`order handler` as returned by
1132 :meth:`get_order_handler()`.
1133 """
1134 model_class = OrderItem
1135 model_title = "Order Item (All)"
1136 model_title_plural = "Order Items (All)"
1137 route_prefix = 'order_items'
1138 url_prefix = '/order-items'
1139 creatable = False
1140 editable = False
1141 deletable = False
1143 labels = {
1144 'order_id': "Order ID",
1145 'store_id': "Store ID",
1146 'product_id': "Product ID",
1147 'product_scancode': "Scancode",
1148 'product_brand': "Brand",
1149 'product_description': "Description",
1150 'product_size': "Size",
1151 'product_weighed': "Sold by Weight",
1152 'department_id': "Department ID",
1153 'order_uom': "Order UOM",
1154 'status_code': "Status",
1155 }
1157 grid_columns = [
1158 'order_id',
1159 'store_id',
1160 'customer_name',
1161 # 'sequence',
1162 'product_scancode',
1163 'product_brand',
1164 'product_description',
1165 'product_size',
1166 'department_name',
1167 'special_order',
1168 'order_qty',
1169 'order_uom',
1170 'total_price',
1171 'status_code',
1172 ]
1174 sort_defaults = ('order_id', 'desc')
1176 form_fields = [
1177 'order',
1178 # 'customer_name',
1179 'sequence',
1180 'product_id',
1181 'local_product',
1182 'pending_product',
1183 'product_scancode',
1184 'product_brand',
1185 'product_description',
1186 'product_size',
1187 'product_weighed',
1188 'department_id',
1189 'department_name',
1190 'special_order',
1191 'case_size',
1192 'unit_cost',
1193 'unit_price_reg',
1194 'unit_price_sale',
1195 'sale_ends',
1196 'unit_price_quoted',
1197 'case_price_quoted',
1198 'order_qty',
1199 'order_uom',
1200 'discount_percent',
1201 'total_price',
1202 'status_code',
1203 'paid_amount',
1204 'payment_transaction_number',
1205 ]
1207 def __init__(self, request, context=None):
1208 super().__init__(request, context=context)
1209 self.order_handler = self.app.get_order_handler()
1211 def get_fallback_templates(self, template):
1212 """ """
1213 templates = super().get_fallback_templates(template)
1214 templates.insert(0, f'/order-items/{template}.mako')
1215 return templates
1217 def get_query(self, session=None):
1218 """ """
1219 query = super().get_query(session=session)
1220 model = self.app.model
1221 return query.join(model.Order)
1223 def configure_grid(self, g):
1224 """ """
1225 super().configure_grid(g)
1226 model = self.app.model
1227 # enum = self.app.enum
1229 # store_id
1230 if not self.order_handler.expose_store_id():
1231 g.remove('store_id')
1233 # order_id
1234 g.set_sorter('order_id', model.Order.order_id)
1235 g.set_renderer('order_id', self.render_order_attr)
1236 g.set_link('order_id')
1238 # store_id
1239 g.set_sorter('store_id', model.Order.store_id)
1240 g.set_renderer('store_id', self.render_order_attr)
1242 # customer_name
1243 g.set_label('customer_name', "Customer", column_only=True)
1244 g.set_renderer('customer_name', self.render_order_attr)
1245 g.set_sorter('customer_name', model.Order.customer_name)
1246 g.set_filter('customer_name', model.Order.customer_name)
1248 # # sequence
1249 # g.set_label('sequence', "Seq.", column_only=True)
1251 # product_scancode
1252 g.set_link('product_scancode')
1254 # product_brand
1255 g.set_link('product_brand')
1257 # product_description
1258 g.set_link('product_description')
1260 # product_size
1261 g.set_link('product_size')
1263 # order_uom
1264 # TODO
1265 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
1267 # total_price
1268 g.set_renderer('total_price', g.render_currency)
1270 # status_code
1271 g.set_renderer('status_code', self.render_status_code)
1273 def render_order_attr(self, item, key, value):
1274 """ """
1275 order = item.order
1276 return getattr(order, key)
1278 def render_status_code(self, item, key, value):
1279 """ """
1280 enum = self.app.enum
1281 return enum.ORDER_ITEM_STATUS[value]
1283 def grid_row_class(self, item, data, i):
1284 """ """
1285 variant = self.order_handler.item_status_to_variant(item.status_code)
1286 if variant:
1287 return f'has-background-{variant}'
1289 def configure_form(self, f):
1290 """ """
1291 super().configure_form(f)
1292 enum = self.app.enum
1293 item = f.model_instance
1295 # order
1296 f.set_node('order', OrderRef(self.request))
1298 # local_product
1299 f.set_node('local_product', LocalProductRef(self.request))
1301 # pending_product
1302 if item.product_id or item.local_product:
1303 f.remove('pending_product')
1304 else:
1305 f.set_node('pending_product', PendingProductRef(self.request))
1307 # order_qty
1308 f.set_node('order_qty', WuttaQuantity(self.request))
1310 # order_uom
1311 f.set_node('order_uom', WuttaDictEnum(self.request, enum.ORDER_UOM))
1313 # case_size
1314 f.set_node('case_size', WuttaQuantity(self.request))
1316 # unit_cost
1317 f.set_node('unit_cost', WuttaMoney(self.request, scale=4))
1319 # unit_price_reg
1320 f.set_node('unit_price_reg', WuttaMoney(self.request))
1322 # unit_price_quoted
1323 f.set_node('unit_price_quoted', WuttaMoney(self.request))
1325 # case_price_quoted
1326 f.set_node('case_price_quoted', WuttaMoney(self.request))
1328 # total_price
1329 f.set_node('total_price', WuttaMoney(self.request))
1331 # status
1332 f.set_node('status_code', WuttaDictEnum(self.request, enum.ORDER_ITEM_STATUS))
1334 # paid_amount
1335 f.set_node('paid_amount', WuttaMoney(self.request))
1337 def get_template_context(self, context):
1338 """ """
1339 if self.viewing:
1340 model = self.app.model
1341 enum = self.app.enum
1342 route_prefix = self.get_route_prefix()
1343 item = context['instance']
1344 form = context['form']
1346 context['expose_store_id'] = self.order_handler.expose_store_id()
1348 context['item'] = item
1349 context['order'] = item.order
1350 context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text(
1351 item.order_qty, item.order_uom, case_size=item.case_size, html=True)
1352 context['item_status_variant'] = self.order_handler.item_status_to_variant(item.status_code)
1354 grid = self.make_grid(key=f'{route_prefix}.view.events',
1355 model_class=model.OrderItemEvent,
1356 data=item.events,
1357 columns=[
1358 'occurred',
1359 'actor',
1360 'type_code',
1361 'note',
1362 ],
1363 labels={
1364 'occurred': "Date/Time",
1365 'actor': "User",
1366 'type_code': "Event Type",
1367 })
1368 grid.set_renderer('type_code', lambda e, k, v: enum.ORDER_ITEM_EVENT[v])
1369 grid.set_renderer('note', self.render_event_note)
1370 if self.request.has_perm('users.view'):
1371 grid.set_renderer('actor', lambda e, k, v: tags.link_to(
1372 e.actor, self.request.route_url('users.view', uuid=e.actor.uuid)))
1373 form.add_grid_vue_context(grid)
1374 context['events_grid'] = grid
1376 return context
1378 def render_event_note(self, event, key, value):
1379 """ """
1380 enum = self.app.enum
1381 if event.type_code == enum.ORDER_ITEM_EVENT_NOTE_ADDED:
1382 return HTML.tag('span', class_='has-background-info-light',
1383 style='padding: 0.25rem 0.5rem;',
1384 c=[value])
1385 return value
1387 def get_xref_buttons(self, item):
1388 """ """
1389 buttons = super().get_xref_buttons(item)
1391 if self.request.has_perm('orders.view'):
1392 url = self.request.route_url('orders.view', uuid=item.order_uuid)
1393 buttons.append(
1394 self.make_button("View the Order", url=url,
1395 primary=True, icon_left='eye'))
1397 return buttons
1399 def add_note(self):
1400 """
1401 View which adds a note to an order item. This is POST-only;
1402 will redirect back to the item view.
1403 """
1404 enum = self.app.enum
1405 item = self.get_instance()
1407 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, self.request.user,
1408 note=self.request.POST['note'])
1410 return self.redirect(self.get_action_url('view', item))
1412 def change_status(self):
1413 """
1414 View which changes status for an order item. This is
1415 POST-only; will redirect back to the item view.
1416 """
1417 model = self.app.model
1418 enum = self.app.enum
1419 main_item = self.get_instance()
1420 session = self.Session()
1421 redirect = self.redirect(self.get_action_url('view', main_item))
1423 extra_note = self.request.POST.get('note')
1425 # validate new status
1426 new_status_code = int(self.request.POST['new_status'])
1427 if new_status_code not in enum.ORDER_ITEM_STATUS:
1428 self.request.session.flash("Invalid status code", 'error')
1429 return redirect
1430 new_status_text = enum.ORDER_ITEM_STATUS[new_status_code]
1432 # locate all items to which new status will be applied
1433 items = [main_item]
1434 # uuids = self.request.POST.get('uuids')
1435 # if uuids:
1436 # for uuid in uuids.split(','):
1437 # item = Session.get(model.OrderItem, uuid)
1438 # if item:
1439 # items.append(item)
1441 # update item(s)
1442 for item in items:
1443 if item.status_code != new_status_code:
1445 # event: change status
1446 note = 'status changed from "{}" to "{}"'.format(
1447 enum.ORDER_ITEM_STATUS[item.status_code],
1448 new_status_text)
1449 item.add_event(enum.ORDER_ITEM_EVENT_STATUS_CHANGE,
1450 self.request.user, note=note)
1452 # event: add note
1453 if extra_note:
1454 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED,
1455 self.request.user, note=extra_note)
1457 # new status
1458 item.status_code = new_status_code
1460 self.request.session.flash(f"Status has been updated to: {new_status_text}")
1461 return redirect
1463 def get_order_items(self, uuids):
1464 """
1465 This method provides common logic to fetch a list of order
1466 items based on a list of UUID keys. It is used by various
1467 workflow action methods.
1469 Note that if no order items are found, this will set a flash
1470 warning message and raise a redirect back to the index page.
1472 :param uuids: List (or comma-delimited string) of UUID keys.
1474 :returns: List of :class:`~sideshow.db.model.orders.OrderItem`
1475 records.
1476 """
1477 model = self.app.model
1478 session = self.Session()
1480 if uuids is None:
1481 uuids = []
1482 elif isinstance(uuids, str):
1483 uuids = uuids.split(',')
1485 items = []
1486 for uuid in uuids:
1487 if isinstance(uuid, str):
1488 uuid = uuid.strip()
1489 if uuid:
1490 try:
1491 item = session.get(model.OrderItem, uuid)
1492 except sa.exc.StatementError:
1493 pass # nb. invalid UUID
1494 else:
1495 if item:
1496 items.append(item)
1498 if not items:
1499 self.request.session.flash("Must specify valid order item(s).", 'warning')
1500 raise self.redirect(self.get_index_url())
1502 return items
1504 @classmethod
1505 def defaults(cls, config):
1506 """ """
1507 cls._order_item_defaults(config)
1508 cls._defaults(config)
1510 @classmethod
1511 def _order_item_defaults(cls, config):
1512 """ """
1513 route_prefix = cls.get_route_prefix()
1514 permission_prefix = cls.get_permission_prefix()
1515 instance_url_prefix = cls.get_instance_url_prefix()
1516 model_title = cls.get_model_title()
1517 model_title_plural = cls.get_model_title_plural()
1519 # fix perm group
1520 config.add_wutta_permission_group(permission_prefix,
1521 model_title_plural,
1522 overwrite=False)
1524 # add note
1525 config.add_route(f'{route_prefix}.add_note',
1526 f'{instance_url_prefix}/add_note',
1527 request_method='POST')
1528 config.add_view(cls, attr='add_note',
1529 route_name=f'{route_prefix}.add_note',
1530 renderer='json',
1531 permission=f'{permission_prefix}.add_note')
1532 config.add_wutta_permission(permission_prefix,
1533 f'{permission_prefix}.add_note',
1534 f"Add note for {model_title}")
1536 # change status
1537 config.add_route(f'{route_prefix}.change_status',
1538 f'{instance_url_prefix}/change-status',
1539 request_method='POST')
1540 config.add_view(cls, attr='change_status',
1541 route_name=f'{route_prefix}.change_status',
1542 renderer='json',
1543 permission=f'{permission_prefix}.change_status')
1544 config.add_wutta_permission(permission_prefix,
1545 f'{permission_prefix}.change_status',
1546 f"Change status for {model_title}")
1549class PlacementView(OrderItemView):
1550 """
1551 Master view for the "placement" phase of
1552 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1553 ``placement``. This is a subclass of :class:`OrderItemView`.
1555 This class auto-filters so only order items with the following
1556 status codes are shown:
1558 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY`
1560 Notable URLs provided by this class:
1562 * ``/placement/``
1563 * ``/placement/XXX``
1564 """
1565 model_title = "Order Item (Placement)"
1566 model_title_plural = "Order Items (Placement)"
1567 route_prefix = 'order_items_placement'
1568 url_prefix = '/placement'
1570 grid_columns = [
1571 'order_id',
1572 'store_id',
1573 'customer_name',
1574 'product_brand',
1575 'product_description',
1576 'product_size',
1577 'department_name',
1578 'special_order',
1579 'vendor_name',
1580 'vendor_item_code',
1581 'order_qty',
1582 'order_uom',
1583 'total_price',
1584 ]
1586 filter_defaults = {
1587 'vendor_name': {'active': True},
1588 }
1590 def get_query(self, session=None):
1591 """ """
1592 query = super().get_query(session=session)
1593 model = self.app.model
1594 enum = self.app.enum
1595 return query.filter(model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_READY)
1597 def configure_grid(self, g):
1598 """ """
1599 super().configure_grid(g)
1601 # checkable
1602 if self.has_perm('process_placement'):
1603 g.checkable = True
1605 # tool button: Order Placed
1606 if self.has_perm('process_placement'):
1607 button = self.make_button("Order Placed", primary=True,
1608 icon_left='arrow-circle-right',
1609 **{'@click': "$emit('process-placement', checkedRows)",
1610 ':disabled': '!checkedRows.length'})
1611 g.add_tool(button, key='process_placement')
1613 def process_placement(self):
1614 """
1615 View to process the "placement" step for some order item(s).
1617 This requires a POST request with data:
1619 :param item_uuids: Comma-delimited list of
1620 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1622 :param vendor_name: Optional name of vendor.
1624 :param po_number: Optional PO number.
1626 :param note: Optional note text from the user.
1628 This invokes
1629 :meth:`~sideshow.orders.OrderHandler.process_placement()` on
1630 the :attr:`~OrderItemView.order_handler`, then redirects user
1631 back to the index page.
1632 """
1633 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1634 vendor_name = self.request.POST.get('vendor_name', '').strip() or None
1635 po_number = self.request.POST.get('po_number', '').strip() or None
1636 note = self.request.POST.get('note', '').strip() or None
1638 self.order_handler.process_placement(items, self.request.user,
1639 vendor_name=vendor_name,
1640 po_number=po_number,
1641 note=note)
1643 self.request.session.flash(f"{len(items)} Order Items were marked as placed")
1644 return self.redirect(self.get_index_url())
1646 @classmethod
1647 def defaults(cls, config):
1648 cls._order_item_defaults(config)
1649 cls._placement_defaults(config)
1650 cls._defaults(config)
1652 @classmethod
1653 def _placement_defaults(cls, config):
1654 route_prefix = cls.get_route_prefix()
1655 permission_prefix = cls.get_permission_prefix()
1656 url_prefix = cls.get_url_prefix()
1657 model_title_plural = cls.get_model_title_plural()
1659 # process placement
1660 config.add_wutta_permission(permission_prefix,
1661 f'{permission_prefix}.process_placement',
1662 f"Process placement for {model_title_plural}")
1663 config.add_route(f'{route_prefix}.process_placement',
1664 f'{url_prefix}/process-placement',
1665 request_method='POST')
1666 config.add_view(cls, attr='process_placement',
1667 route_name=f'{route_prefix}.process_placement',
1668 permission=f'{permission_prefix}.process_placement')
1671class ReceivingView(OrderItemView):
1672 """
1673 Master view for the "receiving" phase of
1674 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1675 ``receiving``. This is a subclass of :class:`OrderItemView`.
1677 This class auto-filters so only order items with the following
1678 status codes are shown:
1680 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_PLACED`
1682 Notable URLs provided by this class:
1684 * ``/receiving/``
1685 * ``/receiving/XXX``
1686 """
1687 model_title = "Order Item (Receiving)"
1688 model_title_plural = "Order Items (Receiving)"
1689 route_prefix = 'order_items_receiving'
1690 url_prefix = '/receiving'
1692 grid_columns = [
1693 'order_id',
1694 'store_id',
1695 'customer_name',
1696 'product_brand',
1697 'product_description',
1698 'product_size',
1699 'department_name',
1700 'special_order',
1701 'vendor_name',
1702 'vendor_item_code',
1703 'order_qty',
1704 'order_uom',
1705 'total_price',
1706 ]
1708 filter_defaults = {
1709 'vendor_name': {'active': True},
1710 }
1712 def get_query(self, session=None):
1713 """ """
1714 query = super().get_query(session=session)
1715 model = self.app.model
1716 enum = self.app.enum
1717 return query.filter(model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_PLACED)
1719 def configure_grid(self, g):
1720 """ """
1721 super().configure_grid(g)
1723 # checkable
1724 if self.has_any_perm('process_receiving', 'process_reorder'):
1725 g.checkable = True
1727 # tool button: Received
1728 if self.has_perm('process_receiving'):
1729 button = self.make_button("Received", primary=True,
1730 icon_left='arrow-circle-right',
1731 **{'@click': "$emit('process-receiving', checkedRows)",
1732 ':disabled': '!checkedRows.length'})
1733 g.add_tool(button, key='process_receiving')
1735 # tool button: Re-Order
1736 if self.has_perm('process_reorder'):
1737 button = self.make_button("Re-Order",
1738 icon_left='redo',
1739 **{'@click': "$emit('process-reorder', checkedRows)",
1740 ':disabled': '!checkedRows.length'})
1741 g.add_tool(button, key='process_reorder')
1743 def process_receiving(self):
1744 """
1745 View to process the "receiving" step for some order item(s).
1747 This requires a POST request with data:
1749 :param item_uuids: Comma-delimited list of
1750 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1752 :param vendor_name: Optional name of vendor.
1754 :param invoice_number: Optional invoice number.
1756 :param po_number: Optional PO number.
1758 :param note: Optional note text from the user.
1760 This invokes
1761 :meth:`~sideshow.orders.OrderHandler.process_receiving()` on
1762 the :attr:`~OrderItemView.order_handler`, then redirects user
1763 back to the index page.
1764 """
1765 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1766 vendor_name = self.request.POST.get('vendor_name', '').strip() or None
1767 invoice_number = self.request.POST.get('invoice_number', '').strip() or None
1768 po_number = self.request.POST.get('po_number', '').strip() or None
1769 note = self.request.POST.get('note', '').strip() or None
1771 self.order_handler.process_receiving(items, self.request.user,
1772 vendor_name=vendor_name,
1773 invoice_number=invoice_number,
1774 po_number=po_number,
1775 note=note)
1777 self.request.session.flash(f"{len(items)} Order Items were marked as received")
1778 return self.redirect(self.get_index_url())
1780 def process_reorder(self):
1781 """
1782 View to process the "reorder" step for some order item(s).
1784 This requires a POST request with data:
1786 :param item_uuids: Comma-delimited list of
1787 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1789 :param note: Optional note text from the user.
1791 This invokes
1792 :meth:`~sideshow.orders.OrderHandler.process_reorder()` on the
1793 :attr:`~OrderItemView.order_handler`, then redirects user back
1794 to the index page.
1795 """
1796 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1797 note = self.request.POST.get('note', '').strip() or None
1799 self.order_handler.process_reorder(items, self.request.user, note=note)
1801 self.request.session.flash(f"{len(items)} Order Items were marked as ready for placement")
1802 return self.redirect(self.get_index_url())
1804 @classmethod
1805 def defaults(cls, config):
1806 cls._order_item_defaults(config)
1807 cls._receiving_defaults(config)
1808 cls._defaults(config)
1810 @classmethod
1811 def _receiving_defaults(cls, config):
1812 route_prefix = cls.get_route_prefix()
1813 permission_prefix = cls.get_permission_prefix()
1814 url_prefix = cls.get_url_prefix()
1815 model_title_plural = cls.get_model_title_plural()
1817 # process receiving
1818 config.add_wutta_permission(permission_prefix,
1819 f'{permission_prefix}.process_receiving',
1820 f"Process receiving for {model_title_plural}")
1821 config.add_route(f'{route_prefix}.process_receiving',
1822 f'{url_prefix}/process-receiving',
1823 request_method='POST')
1824 config.add_view(cls, attr='process_receiving',
1825 route_name=f'{route_prefix}.process_receiving',
1826 permission=f'{permission_prefix}.process_receiving')
1828 # process reorder
1829 config.add_wutta_permission(permission_prefix,
1830 f'{permission_prefix}.process_reorder',
1831 f"Process re-order for {model_title_plural}")
1832 config.add_route(f'{route_prefix}.process_reorder',
1833 f'{url_prefix}/process-reorder',
1834 request_method='POST')
1835 config.add_view(cls, attr='process_reorder',
1836 route_name=f'{route_prefix}.process_reorder',
1837 permission=f'{permission_prefix}.process_reorder')
1840class ContactView(OrderItemView):
1841 """
1842 Master view for the "contact" phase of
1843 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1844 ``contact``. This is a subclass of :class:`OrderItemView`.
1846 This class auto-filters so only order items with the following
1847 status codes are shown:
1849 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED`
1850 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACT_FAILED`
1852 Notable URLs provided by this class:
1854 * ``/contact/``
1855 * ``/contact/XXX``
1856 """
1857 model_title = "Order Item (Contact)"
1858 model_title_plural = "Order Items (Contact)"
1859 route_prefix = 'order_items_contact'
1860 url_prefix = '/contact'
1862 def get_query(self, session=None):
1863 """ """
1864 query = super().get_query(session=session)
1865 model = self.app.model
1866 enum = self.app.enum
1867 return query.filter(model.OrderItem.status_code.in_((
1868 enum.ORDER_ITEM_STATUS_RECEIVED,
1869 enum.ORDER_ITEM_STATUS_CONTACT_FAILED)))
1871 def configure_grid(self, g):
1872 """ """
1873 super().configure_grid(g)
1875 # checkable
1876 if self.has_perm('process_contact'):
1877 g.checkable = True
1879 # tool button: Contact Success
1880 if self.has_perm('process_contact'):
1881 button = self.make_button("Contact Success", primary=True,
1882 icon_left='phone',
1883 **{'@click': "$emit('process-contact-success', checkedRows)",
1884 ':disabled': '!checkedRows.length'})
1885 g.add_tool(button, key='process_contact_success')
1887 # tool button: Contact Failure
1888 if self.has_perm('process_contact'):
1889 button = self.make_button("Contact Failure", variant='is-warning',
1890 icon_left='phone',
1891 **{'@click': "$emit('process-contact-failure', checkedRows)",
1892 ':disabled': '!checkedRows.length'})
1893 g.add_tool(button, key='process_contact_failure')
1895 def process_contact_success(self):
1896 """
1897 View to process the "contact success" step for some order
1898 item(s).
1900 This requires a POST request with data:
1902 :param item_uuids: Comma-delimited list of
1903 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1905 :param note: Optional note text from the user.
1907 This invokes
1908 :meth:`~sideshow.orders.OrderHandler.process_contact_success()`
1909 on the :attr:`~OrderItemView.order_handler`, then redirects
1910 user back to the index page.
1911 """
1912 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1913 note = self.request.POST.get('note', '').strip() or None
1915 self.order_handler.process_contact_success(items, self.request.user, note=note)
1917 self.request.session.flash(f"{len(items)} Order Items were marked as contacted")
1918 return self.redirect(self.get_index_url())
1920 def process_contact_failure(self):
1921 """
1922 View to process the "contact failure" step for some order
1923 item(s).
1925 This requires a POST request with data:
1927 :param item_uuids: Comma-delimited list of
1928 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1930 :param note: Optional note text from the user.
1932 This invokes
1933 :meth:`~sideshow.orders.OrderHandler.process_contact_failure()`
1934 on the :attr:`~OrderItemView.order_handler`, then redirects
1935 user back to the index page.
1936 """
1937 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1938 note = self.request.POST.get('note', '').strip() or None
1940 self.order_handler.process_contact_failure(items, self.request.user, note=note)
1942 self.request.session.flash(f"{len(items)} Order Items were marked as contact failed")
1943 return self.redirect(self.get_index_url())
1945 @classmethod
1946 def defaults(cls, config):
1947 cls._order_item_defaults(config)
1948 cls._contact_defaults(config)
1949 cls._defaults(config)
1951 @classmethod
1952 def _contact_defaults(cls, config):
1953 route_prefix = cls.get_route_prefix()
1954 permission_prefix = cls.get_permission_prefix()
1955 url_prefix = cls.get_url_prefix()
1956 model_title_plural = cls.get_model_title_plural()
1958 # common perm for processing contact success + failure
1959 config.add_wutta_permission(permission_prefix,
1960 f'{permission_prefix}.process_contact',
1961 f"Process contact success/failure for {model_title_plural}")
1963 # process contact success
1964 config.add_route(f'{route_prefix}.process_contact_success',
1965 f'{url_prefix}/process-contact-success',
1966 request_method='POST')
1967 config.add_view(cls, attr='process_contact_success',
1968 route_name=f'{route_prefix}.process_contact_success',
1969 permission=f'{permission_prefix}.process_contact')
1971 # process contact failure
1972 config.add_route(f'{route_prefix}.process_contact_failure',
1973 f'{url_prefix}/process-contact-failure',
1974 request_method='POST')
1975 config.add_view(cls, attr='process_contact_failure',
1976 route_name=f'{route_prefix}.process_contact_failure',
1977 permission=f'{permission_prefix}.process_contact')
1980class DeliveryView(OrderItemView):
1981 """
1982 Master view for the "delivery" phase of
1983 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1984 ``delivery``. This is a subclass of :class:`OrderItemView`.
1986 This class auto-filters so only order items with the following
1987 status codes are shown:
1989 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED`
1990 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACTED`
1992 Notable URLs provided by this class:
1994 * ``/delivery/``
1995 * ``/delivery/XXX``
1996 """
1997 model_title = "Order Item (Delivery)"
1998 model_title_plural = "Order Items (Delivery)"
1999 route_prefix = 'order_items_delivery'
2000 url_prefix = '/delivery'
2002 def get_query(self, session=None):
2003 """ """
2004 query = super().get_query(session=session)
2005 model = self.app.model
2006 enum = self.app.enum
2007 return query.filter(model.OrderItem.status_code.in_((
2008 enum.ORDER_ITEM_STATUS_RECEIVED,
2009 enum.ORDER_ITEM_STATUS_CONTACTED)))
2011 def configure_grid(self, g):
2012 """ """
2013 super().configure_grid(g)
2015 # checkable
2016 if self.has_any_perm('process_delivery', 'process_restock'):
2017 g.checkable = True
2019 # tool button: Delivered
2020 if self.has_perm('process_delivery'):
2021 button = self.make_button("Delivered", primary=True,
2022 icon_left='check',
2023 **{'@click': "$emit('process-delivery', checkedRows)",
2024 ':disabled': '!checkedRows.length'})
2025 g.add_tool(button, key='process_delivery')
2027 # tool button: Restocked
2028 if self.has_perm('process_restock'):
2029 button = self.make_button("Restocked",
2030 icon_left='redo',
2031 **{'@click': "$emit('process-restock', checkedRows)",
2032 ':disabled': '!checkedRows.length'})
2033 g.add_tool(button, key='process_restock')
2035 def process_delivery(self):
2036 """
2037 View to process the "delivery" step for some order item(s).
2039 This requires a POST request with data:
2041 :param item_uuids: Comma-delimited list of
2042 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
2044 :param note: Optional note text from the user.
2046 This invokes
2047 :meth:`~sideshow.orders.OrderHandler.process_delivery()` on
2048 the :attr:`~OrderItemView.order_handler`, then redirects user
2049 back to the index page.
2050 """
2051 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
2052 note = self.request.POST.get('note', '').strip() or None
2054 self.order_handler.process_delivery(items, self.request.user, note=note)
2056 self.request.session.flash(f"{len(items)} Order Items were marked as delivered")
2057 return self.redirect(self.get_index_url())
2059 def process_restock(self):
2060 """
2061 View to process the "restock" step for some order item(s).
2063 This requires a POST request with data:
2065 :param item_uuids: Comma-delimited list of
2066 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
2068 :param note: Optional note text from the user.
2070 This invokes
2071 :meth:`~sideshow.orders.OrderHandler.process_restock()` on the
2072 :attr:`~OrderItemView.order_handler`, then redirects user back
2073 to the index page.
2074 """
2075 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
2076 note = self.request.POST.get('note', '').strip() or None
2078 self.order_handler.process_restock(items, self.request.user, note=note)
2080 self.request.session.flash(f"{len(items)} Order Items were marked as restocked")
2081 return self.redirect(self.get_index_url())
2083 @classmethod
2084 def defaults(cls, config):
2085 cls._order_item_defaults(config)
2086 cls._delivery_defaults(config)
2087 cls._defaults(config)
2089 @classmethod
2090 def _delivery_defaults(cls, config):
2091 route_prefix = cls.get_route_prefix()
2092 permission_prefix = cls.get_permission_prefix()
2093 url_prefix = cls.get_url_prefix()
2094 model_title_plural = cls.get_model_title_plural()
2096 # process delivery
2097 config.add_wutta_permission(permission_prefix,
2098 f'{permission_prefix}.process_delivery',
2099 f"Process delivery for {model_title_plural}")
2100 config.add_route(f'{route_prefix}.process_delivery',
2101 f'{url_prefix}/process-delivery',
2102 request_method='POST')
2103 config.add_view(cls, attr='process_delivery',
2104 route_name=f'{route_prefix}.process_delivery',
2105 permission=f'{permission_prefix}.process_delivery')
2107 # process restock
2108 config.add_wutta_permission(permission_prefix,
2109 f'{permission_prefix}.process_restock',
2110 f"Process restock for {model_title_plural}")
2111 config.add_route(f'{route_prefix}.process_restock',
2112 f'{url_prefix}/process-restock',
2113 request_method='POST')
2114 config.add_view(cls, attr='process_restock',
2115 route_name=f'{route_prefix}.process_restock',
2116 permission=f'{permission_prefix}.process_restock')
2119def defaults(config, **kwargs):
2120 base = globals()
2122 OrderView = kwargs.get('OrderView', base['OrderView'])
2123 OrderView.defaults(config)
2125 OrderItemView = kwargs.get('OrderItemView', base['OrderItemView'])
2126 OrderItemView.defaults(config)
2128 PlacementView = kwargs.get('PlacementView', base['PlacementView'])
2129 PlacementView.defaults(config)
2131 ReceivingView = kwargs.get('ReceivingView', base['ReceivingView'])
2132 ReceivingView.defaults(config)
2134 ContactView = kwargs.get('ContactView', base['ContactView'])
2135 ContactView.defaults(config)
2137 DeliveryView = kwargs.get('DeliveryView', base['DeliveryView'])
2138 DeliveryView.defaults(config)
2141def includeme(config):
2142 defaults(config)