Coverage for src/sideshow/web/views/orders.py: 0%
763 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-20 09:03 -0600
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-20 09:03 -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 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 'case_size': float(row.case_size) if row.case_size is not None else None,
797 'order_qty': float(row.order_qty),
798 'order_uom': row.order_uom,
799 'discount_percent': self.app.render_quantity(row.discount_percent),
800 'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None,
801 'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted),
802 'case_price_quoted': float(row.case_price_quoted) if row.case_price_quoted is not None else None,
803 'case_price_quoted_display': self.app.render_currency(row.case_price_quoted),
804 'total_price': float(row.total_price) if row.total_price is not None else None,
805 'total_price_display': self.app.render_currency(row.total_price),
806 'status_code': row.status_code,
807 'status_text': row.status_text,
808 }
810 use_local = self.batch_handler.use_local_products()
812 # product_id
813 if use_local:
814 if row.local_product:
815 data['product_id'] = row.local_product.uuid.hex
816 else:
817 data['product_id'] = row.product_id
819 # vendor_name
820 if use_local:
821 if row.local_product:
822 data['vendor_name'] = row.local_product.vendor_name
823 else: # use external
824 pass # TODO
825 if not data.get('product_id') and row.pending_product:
826 data['vendor_name'] = row.pending_product.vendor_name
828 if row.unit_price_reg:
829 data['unit_price_reg'] = float(row.unit_price_reg)
830 data['unit_price_reg_display'] = self.app.render_currency(row.unit_price_reg)
832 if row.unit_price_sale:
833 data['unit_price_sale'] = float(row.unit_price_sale)
834 data['unit_price_sale_display'] = self.app.render_currency(row.unit_price_sale)
835 if row.sale_ends:
836 sale_ends = row.sale_ends
837 data['sale_ends'] = str(row.sale_ends)
838 data['sale_ends_display'] = self.app.render_date(row.sale_ends)
840 if row.pending_product:
841 pending = row.pending_product
842 data['pending_product'] = {
843 'uuid': pending.uuid.hex,
844 'scancode': pending.scancode,
845 'brand_name': pending.brand_name,
846 'description': pending.description,
847 'size': pending.size,
848 'department_id': pending.department_id,
849 'department_name': pending.department_name,
850 'unit_price_reg': float(pending.unit_price_reg) if pending.unit_price_reg is not None else None,
851 'vendor_name': pending.vendor_name,
852 'vendor_item_code': pending.vendor_item_code,
853 'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None,
854 'case_size': float(pending.case_size) if pending.case_size is not None else None,
855 'notes': pending.notes,
856 'special_order': pending.special_order,
857 }
859 # display text for order qty/uom
860 data['order_qty_display'] = self.order_handler.get_order_qty_uom_text(
861 row.order_qty, row.order_uom, case_size=row.case_size, html=True)
863 return data
865 def get_instance_title(self, order):
866 """ """
867 return f"#{order.order_id} for {order.customer_name}"
869 def configure_form(self, f):
870 """ """
871 super().configure_form(f)
872 order = f.model_instance
874 # store_id
875 if not self.order_handler.expose_store_id():
876 f.remove('store_id')
878 # local_customer
879 if order.customer_id and not order.local_customer:
880 f.remove('local_customer')
881 else:
882 f.set_node('local_customer', LocalCustomerRef(self.request))
884 # pending_customer
885 if order.customer_id or order.local_customer:
886 f.remove('pending_customer')
887 else:
888 f.set_node('pending_customer', PendingCustomerRef(self.request))
890 # total_price
891 f.set_node('total_price', WuttaMoney(self.request))
893 # created_by
894 f.set_node('created_by', UserRef(self.request))
895 f.set_readonly('created_by')
897 def get_xref_buttons(self, order):
898 """ """
899 buttons = super().get_xref_buttons(order)
900 model = self.app.model
901 session = self.Session()
903 if self.request.has_perm('neworder_batches.view'):
904 batch = session.query(model.NewOrderBatch)\
905 .filter(model.NewOrderBatch.id == order.order_id)\
906 .first()
907 if batch:
908 url = self.request.route_url('neworder_batches.view', uuid=batch.uuid)
909 buttons.append(
910 self.make_button("View the Batch", primary=True, icon_left='eye', url=url))
912 return buttons
914 def get_row_grid_data(self, order):
915 """ """
916 model = self.app.model
917 session = self.Session()
918 return session.query(model.OrderItem)\
919 .filter(model.OrderItem.order == order)
921 def configure_row_grid(self, g):
922 """ """
923 super().configure_row_grid(g)
924 # enum = self.app.enum
926 # sequence
927 g.set_label('sequence', "Seq.", column_only=True)
928 g.set_link('sequence')
930 # product_scancode
931 g.set_link('product_scancode')
933 # product_brand
934 g.set_link('product_brand')
936 # product_description
937 g.set_link('product_description')
939 # product_size
940 g.set_link('product_size')
942 # TODO
943 # order_uom
944 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
946 # discount_percent
947 g.set_renderer('discount_percent', 'percent')
948 g.set_label('discount_percent', "Disc. %", column_only=True)
950 # total_price
951 g.set_renderer('total_price', g.render_currency)
953 # status_code
954 g.set_renderer('status_code', self.render_status_code)
956 # TODO: upstream should set this automatically
957 g.row_class = self.row_grid_row_class
959 def row_grid_row_class(self, item, data, i):
960 """ """
961 variant = self.order_handler.item_status_to_variant(item.status_code)
962 if variant:
963 return f'has-background-{variant}'
965 def render_status_code(self, item, key, value):
966 """ """
967 enum = self.app.enum
968 return enum.ORDER_ITEM_STATUS[value]
970 def get_row_action_url_view(self, item, i):
971 """ """
972 return self.request.route_url('order_items.view', uuid=item.uuid)
974 def configure_get_simple_settings(self):
975 """ """
976 settings = [
978 # stores
979 {'name': 'sideshow.orders.expose_store_id',
980 'type': bool},
981 {'name': 'sideshow.orders.default_store_id'},
983 # customers
984 {'name': 'sideshow.orders.use_local_customers',
985 # nb. this is really a bool but we present as string in config UI
986 #'type': bool,
987 'default': 'true'},
989 # products
990 {'name': 'sideshow.orders.use_local_products',
991 # nb. this is really a bool but we present as string in config UI
992 #'type': bool,
993 'default': 'true'},
994 {'name': 'sideshow.orders.allow_unknown_products',
995 'type': bool,
996 'default': True},
998 # pricing
999 {'name': 'sideshow.orders.allow_item_discounts',
1000 'type': bool},
1001 {'name': 'sideshow.orders.allow_item_discounts_if_on_sale',
1002 'type': bool},
1003 {'name': 'sideshow.orders.default_item_discount',
1004 'type': float},
1006 # batches
1007 {'name': 'wutta.batch.neworder.handler.spec'},
1008 ]
1010 # required fields for new product entry
1011 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
1012 setting = {'name': f'sideshow.orders.unknown_product.fields.{field}.required',
1013 'type': bool}
1014 if field == 'description':
1015 setting['default'] = True
1016 settings.append(setting)
1018 return settings
1020 def configure_get_context(self, **kwargs):
1021 """ """
1022 context = super().configure_get_context(**kwargs)
1024 context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS
1026 handlers = self.app.get_batch_handler_specs('neworder')
1027 handlers = [{'spec': spec} for spec in handlers]
1028 context['batch_handlers'] = handlers
1030 context['dept_item_discounts'] = self.get_dept_item_discounts()
1032 return context
1034 def configure_gather_settings(self, data, simple_settings=None):
1035 """ """
1036 settings = super().configure_gather_settings(data, simple_settings=simple_settings)
1038 for dept in json.loads(data['dept_item_discounts']):
1039 deptid = dept['department_id']
1040 settings.append({'name': f'sideshow.orders.departments.{deptid}.name',
1041 'value': dept['department_name']})
1042 settings.append({'name': f'sideshow.orders.departments.{deptid}.default_item_discount',
1043 'value': dept['default_item_discount']})
1045 return settings
1047 def configure_remove_settings(self, **kwargs):
1048 """ """
1049 model = self.app.model
1050 session = self.Session()
1052 super().configure_remove_settings(**kwargs)
1054 to_delete = session.query(model.Setting)\
1055 .filter(sa.or_(
1056 model.Setting.name.like('sideshow.orders.departments.%.name'),
1057 model.Setting.name.like('sideshow.orders.departments.%.default_item_discount')))\
1058 .all()
1059 for setting in to_delete:
1060 self.app.delete_setting(session, setting.name)
1063 @classmethod
1064 def defaults(cls, config):
1065 cls._order_defaults(config)
1066 cls._defaults(config)
1068 @classmethod
1069 def _order_defaults(cls, config):
1070 route_prefix = cls.get_route_prefix()
1071 permission_prefix = cls.get_permission_prefix()
1072 url_prefix = cls.get_url_prefix()
1073 model_title = cls.get_model_title()
1074 model_title_plural = cls.get_model_title_plural()
1076 # fix perm group
1077 config.add_wutta_permission_group(permission_prefix,
1078 model_title_plural,
1079 overwrite=False)
1081 # extra perm required to create order with unknown/pending product
1082 config.add_wutta_permission(permission_prefix,
1083 f'{permission_prefix}.create_unknown_product',
1084 f"Create new {model_title} for unknown/pending product")
1086 # customer autocomplete
1087 config.add_route(f'{route_prefix}.customer_autocomplete',
1088 f'{url_prefix}/customer-autocomplete',
1089 request_method='GET')
1090 config.add_view(cls, attr='customer_autocomplete',
1091 route_name=f'{route_prefix}.customer_autocomplete',
1092 renderer='json',
1093 permission=f'{permission_prefix}.list')
1095 # product autocomplete
1096 config.add_route(f'{route_prefix}.product_autocomplete',
1097 f'{url_prefix}/product-autocomplete',
1098 request_method='GET')
1099 config.add_view(cls, attr='product_autocomplete',
1100 route_name=f'{route_prefix}.product_autocomplete',
1101 renderer='json',
1102 permission=f'{permission_prefix}.list')
1105class OrderItemView(MasterView):
1106 """
1107 Master view for :class:`~sideshow.db.model.orders.OrderItem`;
1108 route prefix is ``order_items``.
1110 Notable URLs provided by this class:
1112 * ``/order-items/``
1113 * ``/order-items/XXX``
1115 This class serves both as a proper master view (for "all" order
1116 items) as well as a base class for other "workflow" master views,
1117 each of which auto-filters by order item status:
1119 * :class:`PlacementView`
1120 * :class:`ReceivingView`
1121 * :class:`ContactView`
1122 * :class:`DeliveryView`
1124 Note that this does not expose create, edit or delete. The user
1125 must perform various other workflow actions to modify the item.
1127 .. attribute:: order_handler
1129 Reference to the :term:`order handler` as returned by
1130 :meth:`get_order_handler()`.
1131 """
1132 model_class = OrderItem
1133 model_title = "Order Item (All)"
1134 model_title_plural = "Order Items (All)"
1135 route_prefix = 'order_items'
1136 url_prefix = '/order-items'
1137 creatable = False
1138 editable = False
1139 deletable = False
1141 labels = {
1142 'order_id': "Order ID",
1143 'store_id': "Store ID",
1144 'product_id': "Product ID",
1145 'product_scancode': "Scancode",
1146 'product_brand': "Brand",
1147 'product_description': "Description",
1148 'product_size': "Size",
1149 'product_weighed': "Sold by Weight",
1150 'department_id': "Department ID",
1151 'order_uom': "Order UOM",
1152 'status_code': "Status",
1153 }
1155 grid_columns = [
1156 'order_id',
1157 'store_id',
1158 'customer_name',
1159 # 'sequence',
1160 'product_scancode',
1161 'product_brand',
1162 'product_description',
1163 'product_size',
1164 'department_name',
1165 'special_order',
1166 'order_qty',
1167 'order_uom',
1168 'total_price',
1169 'status_code',
1170 ]
1172 sort_defaults = ('order_id', 'desc')
1174 form_fields = [
1175 'order',
1176 # 'customer_name',
1177 'sequence',
1178 'product_id',
1179 'local_product',
1180 'pending_product',
1181 'product_scancode',
1182 'product_brand',
1183 'product_description',
1184 'product_size',
1185 'product_weighed',
1186 'department_id',
1187 'department_name',
1188 'special_order',
1189 'case_size',
1190 'unit_cost',
1191 'unit_price_reg',
1192 'unit_price_sale',
1193 'sale_ends',
1194 'unit_price_quoted',
1195 'case_price_quoted',
1196 'order_qty',
1197 'order_uom',
1198 'discount_percent',
1199 'total_price',
1200 'status_code',
1201 'paid_amount',
1202 'payment_transaction_number',
1203 ]
1205 def __init__(self, request, context=None):
1206 super().__init__(request, context=context)
1207 self.order_handler = self.app.get_order_handler()
1209 def get_fallback_templates(self, template):
1210 """ """
1211 templates = super().get_fallback_templates(template)
1212 templates.insert(0, f'/order-items/{template}.mako')
1213 return templates
1215 def get_query(self, session=None):
1216 """ """
1217 query = super().get_query(session=session)
1218 model = self.app.model
1219 return query.join(model.Order)
1221 def configure_grid(self, g):
1222 """ """
1223 super().configure_grid(g)
1224 model = self.app.model
1225 # enum = self.app.enum
1227 # store_id
1228 if not self.order_handler.expose_store_id():
1229 g.remove('store_id')
1231 # order_id
1232 g.set_sorter('order_id', model.Order.order_id)
1233 g.set_renderer('order_id', self.render_order_attr)
1234 g.set_link('order_id')
1236 # store_id
1237 g.set_sorter('store_id', model.Order.store_id)
1238 g.set_renderer('store_id', self.render_order_attr)
1240 # customer_name
1241 g.set_label('customer_name', "Customer", column_only=True)
1242 g.set_renderer('customer_name', self.render_order_attr)
1243 g.set_sorter('customer_name', model.Order.customer_name)
1244 g.set_filter('customer_name', model.Order.customer_name)
1246 # # sequence
1247 # g.set_label('sequence', "Seq.", column_only=True)
1249 # product_scancode
1250 g.set_link('product_scancode')
1252 # product_brand
1253 g.set_link('product_brand')
1255 # product_description
1256 g.set_link('product_description')
1258 # product_size
1259 g.set_link('product_size')
1261 # order_uom
1262 # TODO
1263 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
1265 # total_price
1266 g.set_renderer('total_price', g.render_currency)
1268 # status_code
1269 g.set_renderer('status_code', self.render_status_code)
1271 def render_order_attr(self, item, key, value):
1272 """ """
1273 order = item.order
1274 return getattr(order, key)
1276 def render_status_code(self, item, key, value):
1277 """ """
1278 enum = self.app.enum
1279 return enum.ORDER_ITEM_STATUS[value]
1281 def grid_row_class(self, item, data, i):
1282 """ """
1283 variant = self.order_handler.item_status_to_variant(item.status_code)
1284 if variant:
1285 return f'has-background-{variant}'
1287 def configure_form(self, f):
1288 """ """
1289 super().configure_form(f)
1290 enum = self.app.enum
1291 item = f.model_instance
1293 # order
1294 f.set_node('order', OrderRef(self.request))
1296 # local_product
1297 f.set_node('local_product', LocalProductRef(self.request))
1299 # pending_product
1300 if item.product_id or item.local_product:
1301 f.remove('pending_product')
1302 else:
1303 f.set_node('pending_product', PendingProductRef(self.request))
1305 # order_qty
1306 f.set_node('order_qty', WuttaQuantity(self.request))
1308 # order_uom
1309 f.set_node('order_uom', WuttaDictEnum(self.request, enum.ORDER_UOM))
1311 # case_size
1312 f.set_node('case_size', WuttaQuantity(self.request))
1314 # unit_cost
1315 f.set_node('unit_cost', WuttaMoney(self.request, scale=4))
1317 # unit_price_reg
1318 f.set_node('unit_price_reg', WuttaMoney(self.request))
1320 # unit_price_quoted
1321 f.set_node('unit_price_quoted', WuttaMoney(self.request))
1323 # case_price_quoted
1324 f.set_node('case_price_quoted', WuttaMoney(self.request))
1326 # total_price
1327 f.set_node('total_price', WuttaMoney(self.request))
1329 # status
1330 f.set_node('status_code', WuttaDictEnum(self.request, enum.ORDER_ITEM_STATUS))
1332 # paid_amount
1333 f.set_node('paid_amount', WuttaMoney(self.request))
1335 def get_template_context(self, context):
1336 """ """
1337 if self.viewing:
1338 model = self.app.model
1339 enum = self.app.enum
1340 route_prefix = self.get_route_prefix()
1341 item = context['instance']
1342 form = context['form']
1344 context['expose_store_id'] = self.order_handler.expose_store_id()
1346 context['item'] = item
1347 context['order'] = item.order
1348 context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text(
1349 item.order_qty, item.order_uom, case_size=item.case_size, html=True)
1350 context['item_status_variant'] = self.order_handler.item_status_to_variant(item.status_code)
1352 grid = self.make_grid(key=f'{route_prefix}.view.events',
1353 model_class=model.OrderItemEvent,
1354 data=item.events,
1355 columns=[
1356 'occurred',
1357 'actor',
1358 'type_code',
1359 'note',
1360 ],
1361 labels={
1362 'occurred': "Date/Time",
1363 'actor': "User",
1364 'type_code': "Event Type",
1365 })
1366 grid.set_renderer('type_code', lambda e, k, v: enum.ORDER_ITEM_EVENT[v])
1367 grid.set_renderer('note', self.render_event_note)
1368 if self.request.has_perm('users.view'):
1369 grid.set_renderer('actor', lambda e, k, v: tags.link_to(
1370 e.actor, self.request.route_url('users.view', uuid=e.actor.uuid)))
1371 form.add_grid_vue_context(grid)
1372 context['events_grid'] = grid
1374 return context
1376 def render_event_note(self, event, key, value):
1377 """ """
1378 enum = self.app.enum
1379 if event.type_code == enum.ORDER_ITEM_EVENT_NOTE_ADDED:
1380 return HTML.tag('span', class_='has-background-info-light',
1381 style='padding: 0.25rem 0.5rem;',
1382 c=[value])
1383 return value
1385 def get_xref_buttons(self, item):
1386 """ """
1387 buttons = super().get_xref_buttons(item)
1389 if self.request.has_perm('orders.view'):
1390 url = self.request.route_url('orders.view', uuid=item.order_uuid)
1391 buttons.append(
1392 self.make_button("View the Order", url=url,
1393 primary=True, icon_left='eye'))
1395 return buttons
1397 def add_note(self):
1398 """
1399 View which adds a note to an order item. This is POST-only;
1400 will redirect back to the item view.
1401 """
1402 enum = self.app.enum
1403 item = self.get_instance()
1405 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, self.request.user,
1406 note=self.request.POST['note'])
1408 return self.redirect(self.get_action_url('view', item))
1410 def change_status(self):
1411 """
1412 View which changes status for an order item. This is
1413 POST-only; will redirect back to the item view.
1414 """
1415 model = self.app.model
1416 enum = self.app.enum
1417 main_item = self.get_instance()
1418 session = self.Session()
1419 redirect = self.redirect(self.get_action_url('view', main_item))
1421 extra_note = self.request.POST.get('note')
1423 # validate new status
1424 new_status_code = int(self.request.POST['new_status'])
1425 if new_status_code not in enum.ORDER_ITEM_STATUS:
1426 self.request.session.flash("Invalid status code", 'error')
1427 return redirect
1428 new_status_text = enum.ORDER_ITEM_STATUS[new_status_code]
1430 # locate all items to which new status will be applied
1431 items = [main_item]
1432 # uuids = self.request.POST.get('uuids')
1433 # if uuids:
1434 # for uuid in uuids.split(','):
1435 # item = Session.get(model.OrderItem, uuid)
1436 # if item:
1437 # items.append(item)
1439 # update item(s)
1440 for item in items:
1441 if item.status_code != new_status_code:
1443 # event: change status
1444 note = 'status changed from "{}" to "{}"'.format(
1445 enum.ORDER_ITEM_STATUS[item.status_code],
1446 new_status_text)
1447 item.add_event(enum.ORDER_ITEM_EVENT_STATUS_CHANGE,
1448 self.request.user, note=note)
1450 # event: add note
1451 if extra_note:
1452 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED,
1453 self.request.user, note=extra_note)
1455 # new status
1456 item.status_code = new_status_code
1458 self.request.session.flash(f"Status has been updated to: {new_status_text}")
1459 return redirect
1461 def get_order_items(self, uuids):
1462 """
1463 This method provides common logic to fetch a list of order
1464 items based on a list of UUID keys. It is used by various
1465 workflow action methods.
1467 Note that if no order items are found, this will set a flash
1468 warning message and raise a redirect back to the index page.
1470 :param uuids: List (or comma-delimited string) of UUID keys.
1472 :returns: List of :class:`~sideshow.db.model.orders.OrderItem`
1473 records.
1474 """
1475 model = self.app.model
1476 session = self.Session()
1478 if uuids is None:
1479 uuids = []
1480 elif isinstance(uuids, str):
1481 uuids = uuids.split(',')
1483 items = []
1484 for uuid in uuids:
1485 if isinstance(uuid, str):
1486 uuid = uuid.strip()
1487 if uuid:
1488 try:
1489 item = session.get(model.OrderItem, uuid)
1490 except sa.exc.StatementError:
1491 pass # nb. invalid UUID
1492 else:
1493 if item:
1494 items.append(item)
1496 if not items:
1497 self.request.session.flash("Must specify valid order item(s).", 'warning')
1498 raise self.redirect(self.get_index_url())
1500 return items
1502 @classmethod
1503 def defaults(cls, config):
1504 """ """
1505 cls._order_item_defaults(config)
1506 cls._defaults(config)
1508 @classmethod
1509 def _order_item_defaults(cls, config):
1510 """ """
1511 route_prefix = cls.get_route_prefix()
1512 permission_prefix = cls.get_permission_prefix()
1513 instance_url_prefix = cls.get_instance_url_prefix()
1514 model_title = cls.get_model_title()
1515 model_title_plural = cls.get_model_title_plural()
1517 # fix perm group
1518 config.add_wutta_permission_group(permission_prefix,
1519 model_title_plural,
1520 overwrite=False)
1522 # add note
1523 config.add_route(f'{route_prefix}.add_note',
1524 f'{instance_url_prefix}/add_note',
1525 request_method='POST')
1526 config.add_view(cls, attr='add_note',
1527 route_name=f'{route_prefix}.add_note',
1528 renderer='json',
1529 permission=f'{permission_prefix}.add_note')
1530 config.add_wutta_permission(permission_prefix,
1531 f'{permission_prefix}.add_note',
1532 f"Add note for {model_title}")
1534 # change status
1535 config.add_route(f'{route_prefix}.change_status',
1536 f'{instance_url_prefix}/change-status',
1537 request_method='POST')
1538 config.add_view(cls, attr='change_status',
1539 route_name=f'{route_prefix}.change_status',
1540 renderer='json',
1541 permission=f'{permission_prefix}.change_status')
1542 config.add_wutta_permission(permission_prefix,
1543 f'{permission_prefix}.change_status',
1544 f"Change status for {model_title}")
1547class PlacementView(OrderItemView):
1548 """
1549 Master view for the "placement" phase of
1550 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1551 ``placement``. This is a subclass of :class:`OrderItemView`.
1553 This class auto-filters so only order items with the following
1554 status codes are shown:
1556 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY`
1558 Notable URLs provided by this class:
1560 * ``/placement/``
1561 * ``/placement/XXX``
1562 """
1563 model_title = "Order Item (Placement)"
1564 model_title_plural = "Order Items (Placement)"
1565 route_prefix = 'order_items_placement'
1566 url_prefix = '/placement'
1568 grid_columns = [
1569 'order_id',
1570 'store_id',
1571 'customer_name',
1572 'product_brand',
1573 'product_description',
1574 'product_size',
1575 'department_name',
1576 'special_order',
1577 'vendor_name',
1578 'vendor_item_code',
1579 'order_qty',
1580 'order_uom',
1581 'total_price',
1582 ]
1584 filter_defaults = {
1585 'vendor_name': {'active': True},
1586 }
1588 def get_query(self, session=None):
1589 """ """
1590 query = super().get_query(session=session)
1591 model = self.app.model
1592 enum = self.app.enum
1593 return query.filter(model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_READY)
1595 def configure_grid(self, g):
1596 """ """
1597 super().configure_grid(g)
1599 # checkable
1600 if self.has_perm('process_placement'):
1601 g.checkable = True
1603 # tool button: Order Placed
1604 if self.has_perm('process_placement'):
1605 button = self.make_button("Order Placed", primary=True,
1606 icon_left='arrow-circle-right',
1607 **{'@click': "$emit('process-placement', checkedRows)",
1608 ':disabled': '!checkedRows.length'})
1609 g.add_tool(button, key='process_placement')
1611 def process_placement(self):
1612 """
1613 View to process the "placement" step for some order item(s).
1615 This requires a POST request with data:
1617 :param item_uuids: Comma-delimited list of
1618 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1620 :param vendor_name: Optional name of vendor.
1622 :param po_number: Optional PO number.
1624 :param note: Optional note text from the user.
1626 This invokes
1627 :meth:`~sideshow.orders.OrderHandler.process_placement()` on
1628 the :attr:`~OrderItemView.order_handler`, then redirects user
1629 back to the index page.
1630 """
1631 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1632 vendor_name = self.request.POST.get('vendor_name', '').strip() or None
1633 po_number = self.request.POST.get('po_number', '').strip() or None
1634 note = self.request.POST.get('note', '').strip() or None
1636 self.order_handler.process_placement(items, self.request.user,
1637 vendor_name=vendor_name,
1638 po_number=po_number,
1639 note=note)
1641 self.request.session.flash(f"{len(items)} Order Items were marked as placed")
1642 return self.redirect(self.get_index_url())
1644 @classmethod
1645 def defaults(cls, config):
1646 cls._order_item_defaults(config)
1647 cls._placement_defaults(config)
1648 cls._defaults(config)
1650 @classmethod
1651 def _placement_defaults(cls, config):
1652 route_prefix = cls.get_route_prefix()
1653 permission_prefix = cls.get_permission_prefix()
1654 url_prefix = cls.get_url_prefix()
1655 model_title_plural = cls.get_model_title_plural()
1657 # process placement
1658 config.add_wutta_permission(permission_prefix,
1659 f'{permission_prefix}.process_placement',
1660 f"Process placement for {model_title_plural}")
1661 config.add_route(f'{route_prefix}.process_placement',
1662 f'{url_prefix}/process-placement',
1663 request_method='POST')
1664 config.add_view(cls, attr='process_placement',
1665 route_name=f'{route_prefix}.process_placement',
1666 permission=f'{permission_prefix}.process_placement')
1669class ReceivingView(OrderItemView):
1670 """
1671 Master view for the "receiving" phase of
1672 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1673 ``receiving``. This is a subclass of :class:`OrderItemView`.
1675 This class auto-filters so only order items with the following
1676 status codes are shown:
1678 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_PLACED`
1680 Notable URLs provided by this class:
1682 * ``/receiving/``
1683 * ``/receiving/XXX``
1684 """
1685 model_title = "Order Item (Receiving)"
1686 model_title_plural = "Order Items (Receiving)"
1687 route_prefix = 'order_items_receiving'
1688 url_prefix = '/receiving'
1690 grid_columns = [
1691 'order_id',
1692 'store_id',
1693 'customer_name',
1694 'product_brand',
1695 'product_description',
1696 'product_size',
1697 'department_name',
1698 'special_order',
1699 'vendor_name',
1700 'vendor_item_code',
1701 'order_qty',
1702 'order_uom',
1703 'total_price',
1704 ]
1706 filter_defaults = {
1707 'vendor_name': {'active': True},
1708 }
1710 def get_query(self, session=None):
1711 """ """
1712 query = super().get_query(session=session)
1713 model = self.app.model
1714 enum = self.app.enum
1715 return query.filter(model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_PLACED)
1717 def configure_grid(self, g):
1718 """ """
1719 super().configure_grid(g)
1721 # checkable
1722 if self.has_any_perm('process_receiving', 'process_reorder'):
1723 g.checkable = True
1725 # tool button: Received
1726 if self.has_perm('process_receiving'):
1727 button = self.make_button("Received", primary=True,
1728 icon_left='arrow-circle-right',
1729 **{'@click': "$emit('process-receiving', checkedRows)",
1730 ':disabled': '!checkedRows.length'})
1731 g.add_tool(button, key='process_receiving')
1733 # tool button: Re-Order
1734 if self.has_perm('process_reorder'):
1735 button = self.make_button("Re-Order",
1736 icon_left='redo',
1737 **{'@click': "$emit('process-reorder', checkedRows)",
1738 ':disabled': '!checkedRows.length'})
1739 g.add_tool(button, key='process_reorder')
1741 def process_receiving(self):
1742 """
1743 View to process the "receiving" step for some order item(s).
1745 This requires a POST request with data:
1747 :param item_uuids: Comma-delimited list of
1748 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1750 :param vendor_name: Optional name of vendor.
1752 :param invoice_number: Optional invoice number.
1754 :param po_number: Optional PO number.
1756 :param note: Optional note text from the user.
1758 This invokes
1759 :meth:`~sideshow.orders.OrderHandler.process_receiving()` on
1760 the :attr:`~OrderItemView.order_handler`, then redirects user
1761 back to the index page.
1762 """
1763 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1764 vendor_name = self.request.POST.get('vendor_name', '').strip() or None
1765 invoice_number = self.request.POST.get('invoice_number', '').strip() or None
1766 po_number = self.request.POST.get('po_number', '').strip() or None
1767 note = self.request.POST.get('note', '').strip() or None
1769 self.order_handler.process_receiving(items, self.request.user,
1770 vendor_name=vendor_name,
1771 invoice_number=invoice_number,
1772 po_number=po_number,
1773 note=note)
1775 self.request.session.flash(f"{len(items)} Order Items were marked as received")
1776 return self.redirect(self.get_index_url())
1778 def process_reorder(self):
1779 """
1780 View to process the "reorder" step for some order item(s).
1782 This requires a POST request with data:
1784 :param item_uuids: Comma-delimited list of
1785 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1787 :param note: Optional note text from the user.
1789 This invokes
1790 :meth:`~sideshow.orders.OrderHandler.process_reorder()` on the
1791 :attr:`~OrderItemView.order_handler`, then redirects user back
1792 to the index page.
1793 """
1794 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1795 note = self.request.POST.get('note', '').strip() or None
1797 self.order_handler.process_reorder(items, self.request.user, note=note)
1799 self.request.session.flash(f"{len(items)} Order Items were marked as ready for placement")
1800 return self.redirect(self.get_index_url())
1802 @classmethod
1803 def defaults(cls, config):
1804 cls._order_item_defaults(config)
1805 cls._receiving_defaults(config)
1806 cls._defaults(config)
1808 @classmethod
1809 def _receiving_defaults(cls, config):
1810 route_prefix = cls.get_route_prefix()
1811 permission_prefix = cls.get_permission_prefix()
1812 url_prefix = cls.get_url_prefix()
1813 model_title_plural = cls.get_model_title_plural()
1815 # process receiving
1816 config.add_wutta_permission(permission_prefix,
1817 f'{permission_prefix}.process_receiving',
1818 f"Process receiving for {model_title_plural}")
1819 config.add_route(f'{route_prefix}.process_receiving',
1820 f'{url_prefix}/process-receiving',
1821 request_method='POST')
1822 config.add_view(cls, attr='process_receiving',
1823 route_name=f'{route_prefix}.process_receiving',
1824 permission=f'{permission_prefix}.process_receiving')
1826 # process reorder
1827 config.add_wutta_permission(permission_prefix,
1828 f'{permission_prefix}.process_reorder',
1829 f"Process re-order for {model_title_plural}")
1830 config.add_route(f'{route_prefix}.process_reorder',
1831 f'{url_prefix}/process-reorder',
1832 request_method='POST')
1833 config.add_view(cls, attr='process_reorder',
1834 route_name=f'{route_prefix}.process_reorder',
1835 permission=f'{permission_prefix}.process_reorder')
1838class ContactView(OrderItemView):
1839 """
1840 Master view for the "contact" phase of
1841 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1842 ``contact``. This is a subclass of :class:`OrderItemView`.
1844 This class auto-filters so only order items with the following
1845 status codes are shown:
1847 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED`
1848 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACT_FAILED`
1850 Notable URLs provided by this class:
1852 * ``/contact/``
1853 * ``/contact/XXX``
1854 """
1855 model_title = "Order Item (Contact)"
1856 model_title_plural = "Order Items (Contact)"
1857 route_prefix = 'order_items_contact'
1858 url_prefix = '/contact'
1860 def get_query(self, session=None):
1861 """ """
1862 query = super().get_query(session=session)
1863 model = self.app.model
1864 enum = self.app.enum
1865 return query.filter(model.OrderItem.status_code.in_((
1866 enum.ORDER_ITEM_STATUS_RECEIVED,
1867 enum.ORDER_ITEM_STATUS_CONTACT_FAILED)))
1869 def configure_grid(self, g):
1870 """ """
1871 super().configure_grid(g)
1873 # checkable
1874 if self.has_perm('process_contact'):
1875 g.checkable = True
1877 # tool button: Contact Success
1878 if self.has_perm('process_contact'):
1879 button = self.make_button("Contact Success", primary=True,
1880 icon_left='phone',
1881 **{'@click': "$emit('process-contact-success', checkedRows)",
1882 ':disabled': '!checkedRows.length'})
1883 g.add_tool(button, key='process_contact_success')
1885 # tool button: Contact Failure
1886 if self.has_perm('process_contact'):
1887 button = self.make_button("Contact Failure", variant='is-warning',
1888 icon_left='phone',
1889 **{'@click': "$emit('process-contact-failure', checkedRows)",
1890 ':disabled': '!checkedRows.length'})
1891 g.add_tool(button, key='process_contact_failure')
1893 def process_contact_success(self):
1894 """
1895 View to process the "contact success" step for some order
1896 item(s).
1898 This requires a POST request with data:
1900 :param item_uuids: Comma-delimited list of
1901 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1903 :param note: Optional note text from the user.
1905 This invokes
1906 :meth:`~sideshow.orders.OrderHandler.process_contact_success()`
1907 on the :attr:`~OrderItemView.order_handler`, then redirects
1908 user back to the index page.
1909 """
1910 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1911 note = self.request.POST.get('note', '').strip() or None
1913 self.order_handler.process_contact_success(items, self.request.user, note=note)
1915 self.request.session.flash(f"{len(items)} Order Items were marked as contacted")
1916 return self.redirect(self.get_index_url())
1918 def process_contact_failure(self):
1919 """
1920 View to process the "contact failure" step for some order
1921 item(s).
1923 This requires a POST request with data:
1925 :param item_uuids: Comma-delimited list of
1926 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1928 :param note: Optional note text from the user.
1930 This invokes
1931 :meth:`~sideshow.orders.OrderHandler.process_contact_failure()`
1932 on the :attr:`~OrderItemView.order_handler`, then redirects
1933 user back to the index page.
1934 """
1935 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
1936 note = self.request.POST.get('note', '').strip() or None
1938 self.order_handler.process_contact_failure(items, self.request.user, note=note)
1940 self.request.session.flash(f"{len(items)} Order Items were marked as contact failed")
1941 return self.redirect(self.get_index_url())
1943 @classmethod
1944 def defaults(cls, config):
1945 cls._order_item_defaults(config)
1946 cls._contact_defaults(config)
1947 cls._defaults(config)
1949 @classmethod
1950 def _contact_defaults(cls, config):
1951 route_prefix = cls.get_route_prefix()
1952 permission_prefix = cls.get_permission_prefix()
1953 url_prefix = cls.get_url_prefix()
1954 model_title_plural = cls.get_model_title_plural()
1956 # common perm for processing contact success + failure
1957 config.add_wutta_permission(permission_prefix,
1958 f'{permission_prefix}.process_contact',
1959 f"Process contact success/failure for {model_title_plural}")
1961 # process contact success
1962 config.add_route(f'{route_prefix}.process_contact_success',
1963 f'{url_prefix}/process-contact-success',
1964 request_method='POST')
1965 config.add_view(cls, attr='process_contact_success',
1966 route_name=f'{route_prefix}.process_contact_success',
1967 permission=f'{permission_prefix}.process_contact')
1969 # process contact failure
1970 config.add_route(f'{route_prefix}.process_contact_failure',
1971 f'{url_prefix}/process-contact-failure',
1972 request_method='POST')
1973 config.add_view(cls, attr='process_contact_failure',
1974 route_name=f'{route_prefix}.process_contact_failure',
1975 permission=f'{permission_prefix}.process_contact')
1978class DeliveryView(OrderItemView):
1979 """
1980 Master view for the "delivery" phase of
1981 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1982 ``delivery``. This is a subclass of :class:`OrderItemView`.
1984 This class auto-filters so only order items with the following
1985 status codes are shown:
1987 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED`
1988 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACTED`
1990 Notable URLs provided by this class:
1992 * ``/delivery/``
1993 * ``/delivery/XXX``
1994 """
1995 model_title = "Order Item (Delivery)"
1996 model_title_plural = "Order Items (Delivery)"
1997 route_prefix = 'order_items_delivery'
1998 url_prefix = '/delivery'
2000 def get_query(self, session=None):
2001 """ """
2002 query = super().get_query(session=session)
2003 model = self.app.model
2004 enum = self.app.enum
2005 return query.filter(model.OrderItem.status_code.in_((
2006 enum.ORDER_ITEM_STATUS_RECEIVED,
2007 enum.ORDER_ITEM_STATUS_CONTACTED)))
2009 def configure_grid(self, g):
2010 """ """
2011 super().configure_grid(g)
2013 # checkable
2014 if self.has_any_perm('process_delivery', 'process_restock'):
2015 g.checkable = True
2017 # tool button: Delivered
2018 if self.has_perm('process_delivery'):
2019 button = self.make_button("Delivered", primary=True,
2020 icon_left='check',
2021 **{'@click': "$emit('process-delivery', checkedRows)",
2022 ':disabled': '!checkedRows.length'})
2023 g.add_tool(button, key='process_delivery')
2025 # tool button: Restocked
2026 if self.has_perm('process_restock'):
2027 button = self.make_button("Restocked",
2028 icon_left='redo',
2029 **{'@click': "$emit('process-restock', checkedRows)",
2030 ':disabled': '!checkedRows.length'})
2031 g.add_tool(button, key='process_restock')
2033 def process_delivery(self):
2034 """
2035 View to process the "delivery" step for some order item(s).
2037 This requires a POST request with data:
2039 :param item_uuids: Comma-delimited list of
2040 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
2042 :param note: Optional note text from the user.
2044 This invokes
2045 :meth:`~sideshow.orders.OrderHandler.process_delivery()` on
2046 the :attr:`~OrderItemView.order_handler`, then redirects user
2047 back to the index page.
2048 """
2049 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
2050 note = self.request.POST.get('note', '').strip() or None
2052 self.order_handler.process_delivery(items, self.request.user, note=note)
2054 self.request.session.flash(f"{len(items)} Order Items were marked as delivered")
2055 return self.redirect(self.get_index_url())
2057 def process_restock(self):
2058 """
2059 View to process the "restock" step for some order item(s).
2061 This requires a POST request with data:
2063 :param item_uuids: Comma-delimited list of
2064 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
2066 :param note: Optional note text from the user.
2068 This invokes
2069 :meth:`~sideshow.orders.OrderHandler.process_restock()` on the
2070 :attr:`~OrderItemView.order_handler`, then redirects user back
2071 to the index page.
2072 """
2073 items = self.get_order_items(self.request.POST.get('item_uuids', ''))
2074 note = self.request.POST.get('note', '').strip() or None
2076 self.order_handler.process_restock(items, self.request.user, note=note)
2078 self.request.session.flash(f"{len(items)} Order Items were marked as restocked")
2079 return self.redirect(self.get_index_url())
2081 @classmethod
2082 def defaults(cls, config):
2083 cls._order_item_defaults(config)
2084 cls._delivery_defaults(config)
2085 cls._defaults(config)
2087 @classmethod
2088 def _delivery_defaults(cls, config):
2089 route_prefix = cls.get_route_prefix()
2090 permission_prefix = cls.get_permission_prefix()
2091 url_prefix = cls.get_url_prefix()
2092 model_title_plural = cls.get_model_title_plural()
2094 # process delivery
2095 config.add_wutta_permission(permission_prefix,
2096 f'{permission_prefix}.process_delivery',
2097 f"Process delivery for {model_title_plural}")
2098 config.add_route(f'{route_prefix}.process_delivery',
2099 f'{url_prefix}/process-delivery',
2100 request_method='POST')
2101 config.add_view(cls, attr='process_delivery',
2102 route_name=f'{route_prefix}.process_delivery',
2103 permission=f'{permission_prefix}.process_delivery')
2105 # process restock
2106 config.add_wutta_permission(permission_prefix,
2107 f'{permission_prefix}.process_restock',
2108 f"Process restock for {model_title_plural}")
2109 config.add_route(f'{route_prefix}.process_restock',
2110 f'{url_prefix}/process-restock',
2111 request_method='POST')
2112 config.add_view(cls, attr='process_restock',
2113 route_name=f'{route_prefix}.process_restock',
2114 permission=f'{permission_prefix}.process_restock')
2117def defaults(config, **kwargs):
2118 base = globals()
2120 OrderView = kwargs.get('OrderView', base['OrderView'])
2121 OrderView.defaults(config)
2123 OrderItemView = kwargs.get('OrderItemView', base['OrderItemView'])
2124 OrderItemView.defaults(config)
2126 PlacementView = kwargs.get('PlacementView', base['PlacementView'])
2127 PlacementView.defaults(config)
2129 ReceivingView = kwargs.get('ReceivingView', base['ReceivingView'])
2130 ReceivingView.defaults(config)
2132 ContactView = kwargs.get('ContactView', base['ContactView'])
2133 ContactView.defaults(config)
2135 DeliveryView = kwargs.get('DeliveryView', base['DeliveryView'])
2136 DeliveryView.defaults(config)
2139def includeme(config):
2140 defaults(config)