Coverage for .tox/coverage/lib/python3.11/site-packages/sideshow/web/views/orders.py: 100%
400 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-13 18:58 -0600
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-13 18:58 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# Sideshow -- Case/Special Order Tracker
5# Copyright © 2024 Lance Edgar
6#
7# This file is part of Sideshow.
8#
9# Sideshow is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# Sideshow is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17# General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Views for Orders
25"""
27import decimal
28import logging
30import colander
31from sqlalchemy import orm
33from wuttaweb.views import MasterView
34from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum
36from sideshow.db.model import Order, OrderItem
37from sideshow.batch.neworder import NewOrderBatchHandler
38from sideshow.web.forms.schema import (OrderRef,
39 LocalCustomerRef, LocalProductRef,
40 PendingCustomerRef, PendingProductRef)
43log = logging.getLogger(__name__)
46class OrderView(MasterView):
47 """
48 Master view for :class:`~sideshow.db.model.orders.Order`; route
49 prefix is ``orders``.
51 Notable URLs provided by this class:
53 * ``/orders/``
54 * ``/orders/new``
55 * ``/orders/XXX``
56 * ``/orders/XXX/delete``
58 Note that the "edit" view is not exposed here; user must perform
59 various other workflow actions to modify the order.
61 .. attribute:: batch_handler
63 Reference to the new order batch handler, as returned by
64 :meth:`get_batch_handler()`. This gets set in the constructor.
65 """
66 model_class = Order
67 editable = False
68 configurable = True
70 labels = {
71 'order_id': "Order ID",
72 'store_id': "Store ID",
73 'customer_id': "Customer ID",
74 }
76 grid_columns = [
77 'order_id',
78 'store_id',
79 'customer_id',
80 'customer_name',
81 'total_price',
82 'created',
83 'created_by',
84 ]
86 sort_defaults = ('order_id', 'desc')
88 form_fields = [
89 'order_id',
90 'store_id',
91 'customer_id',
92 'local_customer',
93 'pending_customer',
94 'customer_name',
95 'phone_number',
96 'email_address',
97 'total_price',
98 'created',
99 'created_by',
100 ]
102 has_rows = True
103 row_model_class = OrderItem
104 rows_title = "Order Items"
105 rows_sort_defaults = 'sequence'
106 rows_viewable = True
108 row_labels = {
109 'product_scancode': "Scancode",
110 'product_brand': "Brand",
111 'product_description': "Description",
112 'product_size': "Size",
113 'department_name': "Department",
114 'order_uom': "Order UOM",
115 'status_code': "Status",
116 }
118 row_grid_columns = [
119 'sequence',
120 'product_scancode',
121 'product_brand',
122 'product_description',
123 'product_size',
124 'department_name',
125 'special_order',
126 'order_qty',
127 'order_uom',
128 'total_price',
129 'status_code',
130 ]
132 PENDING_PRODUCT_ENTRY_FIELDS = [
133 'scancode',
134 'brand_name',
135 'description',
136 'size',
137 'department_name',
138 'vendor_name',
139 'vendor_item_code',
140 'case_size',
141 'unit_cost',
142 'unit_price_reg',
143 ]
145 def configure_grid(self, g):
146 """ """
147 super().configure_grid(g)
149 # order_id
150 g.set_link('order_id')
152 # customer_id
153 g.set_link('customer_id')
155 # customer_name
156 g.set_link('customer_name')
158 # total_price
159 g.set_renderer('total_price', g.render_currency)
161 def get_batch_handler(self):
162 """
163 Returns the configured :term:`handler` for :term:`new order
164 batches <new order batch>`.
166 You normally would not need to call this, and can use
167 :attr:`batch_handler` instead.
169 :returns:
170 :class:`~sideshow.batch.neworder.NewOrderBatchHandler`
171 instance.
172 """
173 if hasattr(self, 'batch_handler'):
174 return self.batch_handler
175 return self.app.get_batch_handler('neworder')
177 def create(self):
178 """
179 Instead of the typical "create" view, this displays a "wizard"
180 of sorts.
182 Under the hood a
183 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` is
184 automatically created for the user when they first visit this
185 page. They can select a customer, add items etc.
187 When user is finished assembling the order (i.e. populating
188 the batch), they submit it. This of course executes the
189 batch, which in turn creates a true
190 :class:`~sideshow.db.model.orders.Order`, and user is
191 redirected to the "view order" page.
193 See also these methods which may be called from this one,
194 based on user actions:
196 * :meth:`start_over()`
197 * :meth:`cancel_order()`
198 * :meth:`assign_customer()`
199 * :meth:`unassign_customer()`
200 * :meth:`set_pending_customer()`
201 * :meth:`get_product_info()`
202 * :meth:`add_item()`
203 * :meth:`update_item()`
204 * :meth:`delete_item()`
205 * :meth:`submit_order()`
206 """
207 enum = self.app.enum
208 self.creating = True
209 self.batch_handler = self.get_batch_handler()
210 batch = self.get_current_batch()
212 context = self.get_context_customer(batch)
214 if self.request.method == 'POST':
216 # first we check for traditional form post
217 action = self.request.POST.get('action')
218 post_actions = [
219 'start_over',
220 'cancel_order',
221 ]
222 if action in post_actions:
223 return getattr(self, action)(batch)
225 # okay then, we'll assume newer JSON-style post params
226 data = dict(self.request.json_body)
227 action = data.pop('action')
228 json_actions = [
229 'assign_customer',
230 'unassign_customer',
231 # 'update_phone_number',
232 # 'update_email_address',
233 'set_pending_customer',
234 # 'get_customer_info',
235 # # 'set_customer_data',
236 'get_product_info',
237 # 'get_past_items',
238 'add_item',
239 'update_item',
240 'delete_item',
241 'submit_order',
242 ]
243 if action in json_actions:
244 try:
245 result = getattr(self, action)(batch, data)
246 except Exception as error:
247 log.warning("error calling json action for order", exc_info=True)
248 result = {'error': self.app.render_error(error)}
249 return self.json_response(result)
251 return self.json_response({'error': "unknown form action"})
253 context.update({
254 'batch': batch,
255 'normalized_batch': self.normalize_batch(batch),
256 'order_items': [self.normalize_row(row)
257 for row in batch.rows],
258 'default_uom_choices': self.get_default_uom_choices(),
259 'default_uom': None, # TODO?
260 'allow_unknown_products': (self.batch_handler.allow_unknown_products()
261 and self.has_perm('create_unknown_product')),
262 'pending_product_required_fields': self.get_pending_product_required_fields(),
263 })
264 return self.render_to_response('create', context)
266 def get_current_batch(self):
267 """
268 Returns the current batch for the current user.
270 This looks for a new order batch which was created by the
271 user, but not yet executed. If none is found, a new batch is
272 created.
274 :returns:
275 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
276 instance
277 """
278 model = self.app.model
279 session = self.Session()
281 user = self.request.user
282 if not user:
283 raise self.forbidden()
285 try:
286 # there should be at most *one* new batch per user
287 batch = session.query(model.NewOrderBatch)\
288 .filter(model.NewOrderBatch.created_by == user)\
289 .filter(model.NewOrderBatch.executed == None)\
290 .one()
292 except orm.exc.NoResultFound:
293 # no batch yet for this user, so make one
294 batch = self.batch_handler.make_batch(session, created_by=user)
295 session.add(batch)
296 session.flush()
298 return batch
300 def customer_autocomplete(self):
301 """
302 AJAX view for customer autocomplete, when entering new order.
304 This invokes one of the following on the
305 :attr:`batch_handler`:
307 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_customers_external()`
308 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_customers_local()`
310 :returns: List of search results; each should be a dict with
311 ``value`` and ``label`` keys.
312 """
313 session = self.Session()
314 term = self.request.GET.get('term', '').strip()
315 if not term:
316 return []
318 handler = self.get_batch_handler()
319 if handler.use_local_customers():
320 return handler.autocomplete_customers_local(session, term, user=self.request.user)
321 else:
322 return handler.autocomplete_customers_external(session, term, user=self.request.user)
324 def product_autocomplete(self):
325 """
326 AJAX view for product autocomplete, when entering new order.
328 This invokes one of the following on the
329 :attr:`batch_handler`:
331 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_products_external()`
332 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_products_local()`
334 :returns: List of search results; each should be a dict with
335 ``value`` and ``label`` keys.
336 """
337 session = self.Session()
338 term = self.request.GET.get('term', '').strip()
339 if not term:
340 return []
342 handler = self.get_batch_handler()
343 if handler.use_local_products():
344 return handler.autocomplete_products_local(session, term, user=self.request.user)
345 else:
346 return handler.autocomplete_products_external(session, term, user=self.request.user)
348 def get_pending_product_required_fields(self):
349 """ """
350 required = []
351 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
352 require = self.config.get_bool(
353 f'sideshow.orders.unknown_product.fields.{field}.required')
354 if require is None and field == 'description':
355 require = True
356 if require:
357 required.append(field)
358 return required
360 def start_over(self, batch):
361 """
362 This will delete the user's current batch, then redirect user
363 back to "Create Order" page, which in turn will auto-create a
364 new batch for them.
366 This is a "batch action" method which may be called from
367 :meth:`create()`. See also:
369 * :meth:`cancel_order()`
370 * :meth:`submit_order()`
371 """
372 # drop current batch
373 self.batch_handler.do_delete(batch, self.request.user)
374 self.Session.flush()
376 # send back to "create order" which makes new batch
377 route_prefix = self.get_route_prefix()
378 url = self.request.route_url(f'{route_prefix}.create')
379 return self.redirect(url)
381 def cancel_order(self, batch):
382 """
383 This will delete the user's current batch, then redirect user
384 back to "List Orders" page.
386 This is a "batch action" method which may be called from
387 :meth:`create()`. See also:
389 * :meth:`start_over()`
390 * :meth:`submit_order()`
391 """
392 self.batch_handler.do_delete(batch, self.request.user)
393 self.Session.flush()
395 # set flash msg just to be more obvious
396 self.request.session.flash("New order has been deleted.")
398 # send user back to orders list, w/ no new batch generated
399 url = self.get_index_url()
400 return self.redirect(url)
402 def get_context_customer(self, batch):
403 """ """
404 context = {
405 'customer_is_known': True,
406 'customer_id': None,
407 'customer_name': batch.customer_name,
408 'phone_number': batch.phone_number,
409 'email_address': batch.email_address,
410 }
412 # customer_id
413 use_local = self.batch_handler.use_local_customers()
414 if use_local:
415 local = batch.local_customer
416 if local:
417 context['customer_id'] = local.uuid.hex
418 else: # use external
419 context['customer_id'] = batch.customer_id
421 # pending customer
422 pending = batch.pending_customer
423 if pending:
424 context.update({
425 'new_customer_first_name': pending.first_name,
426 'new_customer_last_name': pending.last_name,
427 'new_customer_full_name': pending.full_name,
428 'new_customer_phone': pending.phone_number,
429 'new_customer_email': pending.email_address,
430 })
432 # declare customer "not known" only if pending is in use
433 if (pending
434 and not batch.customer_id and not batch.local_customer
435 and batch.customer_name):
436 context['customer_is_known'] = False
438 return context
440 def assign_customer(self, batch, data):
441 """
442 Assign the true customer account for a batch.
444 This calls
445 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
446 for the heavy lifting.
448 This is a "batch action" method which may be called from
449 :meth:`create()`. See also:
451 * :meth:`unassign_customer()`
452 * :meth:`set_pending_customer()`
453 """
454 customer_id = data.get('customer_id')
455 if not customer_id:
456 return {'error': "Must provide customer_id"}
458 self.batch_handler.set_customer(batch, customer_id)
459 return self.get_context_customer(batch)
461 def unassign_customer(self, batch, data):
462 """
463 Clear the customer info for a batch.
465 This calls
466 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
467 for the heavy lifting.
469 This is a "batch action" method which may be called from
470 :meth:`create()`. See also:
472 * :meth:`assign_customer()`
473 * :meth:`set_pending_customer()`
474 """
475 self.batch_handler.set_customer(batch, None)
476 return self.get_context_customer(batch)
478 def set_pending_customer(self, batch, data):
479 """
480 This will set/update the batch pending customer info.
482 This calls
483 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
484 for the heavy lifting.
486 This is a "batch action" method which may be called from
487 :meth:`create()`. See also:
489 * :meth:`assign_customer()`
490 * :meth:`unassign_customer()`
491 """
492 self.batch_handler.set_customer(batch, data, user=self.request.user)
493 return self.get_context_customer(batch)
495 def get_product_info(self, batch, data):
496 """
497 Fetch data for a specific product. (Nothing is modified.)
499 Depending on config, this will fetch a :term:`local product`
500 or :term:`external product` to get the data.
502 This should invoke a configured handler for the query
503 behavior, but that is not yet implemented. For now it uses
504 built-in logic only, which queries the
505 :class:`~sideshow.db.model.products.LocalProduct` table.
507 This is a "batch action" method which may be called from
508 :meth:`create()`.
509 """
510 product_id = data.get('product_id')
511 if not product_id:
512 return {'error': "Must specify a product ID"}
514 session = self.Session()
515 use_local = self.batch_handler.use_local_products()
516 if use_local:
517 data = self.batch_handler.get_product_info_local(session, product_id)
518 else:
519 data = self.batch_handler.get_product_info_external(session, product_id)
521 if 'error' in data:
522 return data
524 if 'unit_price_reg' in data and 'unit_price_reg_display' not in data:
525 data['unit_price_reg_display'] = self.app.render_currency(data['unit_price_reg'])
527 if 'unit_price_reg' in data and 'unit_price_quoted' not in data:
528 data['unit_price_quoted'] = data['unit_price_reg']
530 if 'unit_price_quoted' in data and 'unit_price_quoted_display' not in data:
531 data['unit_price_quoted_display'] = self.app.render_currency(data['unit_price_quoted'])
533 if 'case_price_quoted' not in data:
534 if data.get('unit_price_quoted') is not None and data.get('case_size') is not None:
535 data['case_price_quoted'] = data['unit_price_quoted'] * data['case_size']
537 if 'case_price_quoted' in data and 'case_price_quoted_display' not in data:
538 data['case_price_quoted_display'] = self.app.render_currency(data['case_price_quoted'])
540 decimal_fields = [
541 'case_size',
542 'unit_price_reg',
543 'unit_price_quoted',
544 'case_price_quoted',
545 ]
547 for field in decimal_fields:
548 if field in list(data):
549 value = data[field]
550 if isinstance(value, decimal.Decimal):
551 data[field] = float(value)
553 return data
555 def add_item(self, batch, data):
556 """
557 This adds a row to the user's current new order batch.
559 This is a "batch action" method which may be called from
560 :meth:`create()`. See also:
562 * :meth:`update_item()`
563 * :meth:`delete_item()`
564 """
565 row = self.batch_handler.add_item(batch, data['product_info'],
566 data['order_qty'], data['order_uom'])
568 return {'batch': self.normalize_batch(batch),
569 'row': self.normalize_row(row)}
571 def update_item(self, batch, data):
572 """
573 This updates a row in the user's current new order batch.
575 This is a "batch action" method which may be called from
576 :meth:`create()`. See also:
578 * :meth:`add_item()`
579 * :meth:`delete_item()`
580 """
581 model = self.app.model
582 session = self.Session()
584 uuid = data.get('uuid')
585 if not uuid:
586 return {'error': "Must specify row UUID"}
588 row = session.get(model.NewOrderBatchRow, uuid)
589 if not row:
590 return {'error': "Row not found"}
592 if row.batch is not batch:
593 return {'error': "Row is for wrong batch"}
595 self.batch_handler.update_item(row, data['product_info'],
596 data['order_qty'], data['order_uom'])
598 return {'batch': self.normalize_batch(batch),
599 'row': self.normalize_row(row)}
601 def delete_item(self, batch, data):
602 """
603 This deletes a row from the user's current new order batch.
605 This is a "batch action" method which may be called from
606 :meth:`create()`. See also:
608 * :meth:`add_item()`
609 * :meth:`update_item()`
610 """
611 model = self.app.model
612 session = self.app.get_session(batch)
614 uuid = data.get('uuid')
615 if not uuid:
616 return {'error': "Must specify a row UUID"}
618 row = session.get(model.NewOrderBatchRow, uuid)
619 if not row:
620 return {'error': "Row not found"}
622 if row.batch is not batch:
623 return {'error': "Row is for wrong batch"}
625 self.batch_handler.do_remove_row(row)
626 return {'batch': self.normalize_batch(batch)}
628 def submit_order(self, batch, data):
629 """
630 This submits the user's current new order batch, hence
631 executing the batch and creating the true order.
633 This is a "batch action" method which may be called from
634 :meth:`create()`. See also:
636 * :meth:`start_over()`
637 * :meth:`cancel_order()`
638 """
639 user = self.request.user
640 reason = self.batch_handler.why_not_execute(batch, user=user)
641 if reason:
642 return {'error': reason}
644 try:
645 order = self.batch_handler.do_execute(batch, user)
646 except Exception as error:
647 log.warning("failed to execute new order batch: %s", batch,
648 exc_info=True)
649 return {'error': self.app.render_error(error)}
651 return {
652 'next_url': self.get_action_url('view', order),
653 }
655 def normalize_batch(self, batch):
656 """ """
657 return {
658 'uuid': batch.uuid.hex,
659 'total_price': str(batch.total_price or 0),
660 'total_price_display': self.app.render_currency(batch.total_price),
661 'status_code': batch.status_code,
662 'status_text': batch.status_text,
663 }
665 def get_default_uom_choices(self):
666 """ """
667 enum = self.app.enum
668 return [{'key': key, 'value': val}
669 for key, val in enum.ORDER_UOM.items()]
671 def normalize_row(self, row):
672 """ """
673 enum = self.app.enum
675 data = {
676 'uuid': row.uuid.hex,
677 'sequence': row.sequence,
678 'product_id': None,
679 'product_scancode': row.product_scancode,
680 'product_brand': row.product_brand,
681 'product_description': row.product_description,
682 'product_size': row.product_size,
683 'product_full_description': self.app.make_full_name(row.product_brand,
684 row.product_description,
685 row.product_size),
686 'product_weighed': row.product_weighed,
687 'department_display': row.department_name,
688 'special_order': row.special_order,
689 'case_size': float(row.case_size) if row.case_size is not None else None,
690 'order_qty': float(row.order_qty),
691 'order_uom': row.order_uom,
692 'order_uom_choices': self.get_default_uom_choices(),
693 'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None,
694 'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted),
695 'case_price_quoted': float(row.case_price_quoted) if row.case_price_quoted is not None else None,
696 'case_price_quoted_display': self.app.render_currency(row.case_price_quoted),
697 'total_price': float(row.total_price) if row.total_price is not None else None,
698 'total_price_display': self.app.render_currency(row.total_price),
699 'status_code': row.status_code,
700 'status_text': row.status_text,
701 }
703 use_local = self.batch_handler.use_local_products()
705 # product_id
706 if use_local:
707 if row.local_product:
708 data['product_id'] = row.local_product.uuid.hex
709 else:
710 data['product_id'] = row.product_id
712 # vendor_name
713 if use_local:
714 if row.local_product:
715 data['vendor_name'] = row.local_product.vendor_name
716 else: # use external
717 pass # TODO
718 if not data.get('product_id') and row.pending_product:
719 data['vendor_name'] = row.pending_product.vendor_name
721 if row.unit_price_reg:
722 data['unit_price_reg'] = float(row.unit_price_reg)
723 data['unit_price_reg_display'] = self.app.render_currency(row.unit_price_reg)
725 if row.unit_price_sale:
726 data['unit_price_sale'] = float(row.unit_price_sale)
727 data['unit_price_sale_display'] = self.app.render_currency(row.unit_price_sale)
728 if row.sale_ends:
729 sale_ends = row.sale_ends
730 data['sale_ends'] = str(row.sale_ends)
731 data['sale_ends_display'] = self.app.render_date(row.sale_ends)
733 if row.pending_product:
734 pending = row.pending_product
735 data['pending_product'] = {
736 'uuid': pending.uuid.hex,
737 'scancode': pending.scancode,
738 'brand_name': pending.brand_name,
739 'description': pending.description,
740 'size': pending.size,
741 'department_id': pending.department_id,
742 'department_name': pending.department_name,
743 'unit_price_reg': float(pending.unit_price_reg) if pending.unit_price_reg is not None else None,
744 'vendor_name': pending.vendor_name,
745 'vendor_item_code': pending.vendor_item_code,
746 'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None,
747 'case_size': float(pending.case_size) if pending.case_size is not None else None,
748 'notes': pending.notes,
749 'special_order': pending.special_order,
750 }
752 # display text for order qty/uom
753 if row.order_uom == enum.ORDER_UOM_CASE:
754 order_qty = self.app.render_quantity(row.order_qty)
755 if row.case_size is None:
756 case_qty = unit_qty = '??'
757 else:
758 case_qty = self.app.render_quantity(row.case_size)
759 unit_qty = self.app.render_quantity(row.order_qty * row.case_size)
760 CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE]
761 EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
762 data['order_qty_display'] = (f"{order_qty} {CS} "
763 f"(× {case_qty} = {unit_qty} {EA})")
764 else:
765 unit_qty = self.app.render_quantity(row.order_qty)
766 EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
767 data['order_qty_display'] = f"{unit_qty} {EA}"
769 return data
771 def get_instance_title(self, order):
772 """ """
773 return f"#{order.order_id} for {order.customer_name}"
775 def configure_form(self, f):
776 """ """
777 super().configure_form(f)
778 order = f.model_instance
780 # local_customer
781 if order.customer_id and not order.local_customer:
782 f.remove('local_customer')
783 else:
784 f.set_node('local_customer', LocalCustomerRef(self.request))
786 # pending_customer
787 if order.customer_id or order.local_customer:
788 f.remove('pending_customer')
789 else:
790 f.set_node('pending_customer', PendingCustomerRef(self.request))
792 # total_price
793 f.set_node('total_price', WuttaMoney(self.request))
795 # created_by
796 f.set_node('created_by', UserRef(self.request))
797 f.set_readonly('created_by')
799 def get_xref_buttons(self, order):
800 """ """
801 buttons = super().get_xref_buttons(order)
802 model = self.app.model
803 session = self.Session()
805 if self.request.has_perm('neworder_batches.view'):
806 batch = session.query(model.NewOrderBatch)\
807 .filter(model.NewOrderBatch.id == order.order_id)\
808 .first()
809 if batch:
810 url = self.request.route_url('neworder_batches.view', uuid=batch.uuid)
811 buttons.append(
812 self.make_button("View the Batch", primary=True, icon_left='eye', url=url))
814 return buttons
816 def get_row_grid_data(self, order):
817 """ """
818 model = self.app.model
819 session = self.Session()
820 return session.query(model.OrderItem)\
821 .filter(model.OrderItem.order == order)
823 def configure_row_grid(self, g):
824 """ """
825 super().configure_row_grid(g)
826 enum = self.app.enum
828 # sequence
829 g.set_label('sequence', "Seq.", column_only=True)
830 g.set_link('sequence')
832 # product_scancode
833 g.set_link('product_scancode')
835 # product_brand
836 g.set_link('product_brand')
838 # product_description
839 g.set_link('product_description')
841 # product_size
842 g.set_link('product_size')
844 # TODO
845 # order_uom
846 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
848 # total_price
849 g.set_renderer('total_price', g.render_currency)
851 # status_code
852 g.set_renderer('status_code', self.render_status_code)
854 def render_status_code(self, item, key, value):
855 """ """
856 enum = self.app.enum
857 return enum.ORDER_ITEM_STATUS[value]
859 def get_row_action_url_view(self, item, i):
860 """ """
861 return self.request.route_url('order_items.view', uuid=item.uuid)
863 def configure_get_simple_settings(self):
864 """ """
865 settings = [
867 # batches
868 {'name': 'wutta.batch.neworder.handler.spec'},
870 # customers
871 {'name': 'sideshow.orders.use_local_customers',
872 # nb. this is really a bool but we present as string in config UI
873 #'type': bool,
874 'default': 'true'},
876 # products
877 {'name': 'sideshow.orders.use_local_products',
878 # nb. this is really a bool but we present as string in config UI
879 #'type': bool,
880 'default': 'true'},
881 {'name': 'sideshow.orders.allow_unknown_products',
882 'type': bool,
883 'default': True},
884 ]
886 # required fields for new product entry
887 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
888 setting = {'name': f'sideshow.orders.unknown_product.fields.{field}.required',
889 'type': bool}
890 if field == 'description':
891 setting['default'] = True
892 settings.append(setting)
894 return settings
896 def configure_get_context(self, **kwargs):
897 """ """
898 context = super().configure_get_context(**kwargs)
900 context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS
902 handlers = self.app.get_batch_handler_specs('neworder')
903 handlers = [{'spec': spec} for spec in handlers]
904 context['batch_handlers'] = handlers
906 return context
908 @classmethod
909 def defaults(cls, config):
910 cls._order_defaults(config)
911 cls._defaults(config)
913 @classmethod
914 def _order_defaults(cls, config):
915 route_prefix = cls.get_route_prefix()
916 permission_prefix = cls.get_permission_prefix()
917 url_prefix = cls.get_url_prefix()
918 model_title = cls.get_model_title()
919 model_title_plural = cls.get_model_title_plural()
921 # fix perm group
922 config.add_wutta_permission_group(permission_prefix,
923 model_title_plural,
924 overwrite=False)
926 # extra perm required to create order with unknown/pending product
927 config.add_wutta_permission(permission_prefix,
928 f'{permission_prefix}.create_unknown_product',
929 f"Create new {model_title} for unknown/pending product")
931 # customer autocomplete
932 config.add_route(f'{route_prefix}.customer_autocomplete',
933 f'{url_prefix}/customer-autocomplete',
934 request_method='GET')
935 config.add_view(cls, attr='customer_autocomplete',
936 route_name=f'{route_prefix}.customer_autocomplete',
937 renderer='json',
938 permission=f'{permission_prefix}.list')
940 # product autocomplete
941 config.add_route(f'{route_prefix}.product_autocomplete',
942 f'{url_prefix}/product-autocomplete',
943 request_method='GET')
944 config.add_view(cls, attr='product_autocomplete',
945 route_name=f'{route_prefix}.product_autocomplete',
946 renderer='json',
947 permission=f'{permission_prefix}.list')
950class OrderItemView(MasterView):
951 """
952 Master view for :class:`~sideshow.db.model.orders.OrderItem`;
953 route prefix is ``order_items``.
955 Notable URLs provided by this class:
957 * ``/order-items/``
958 * ``/order-items/XXX``
960 Note that this does not expose create, edit or delete. The user
961 must perform various other workflow actions to modify the item.
962 """
963 model_class = OrderItem
964 model_title = "Order Item"
965 route_prefix = 'order_items'
966 url_prefix = '/order-items'
967 creatable = False
968 editable = False
969 deletable = False
971 labels = {
972 'order_id': "Order ID",
973 'product_id': "Product ID",
974 'product_scancode': "Scancode",
975 'product_brand': "Brand",
976 'product_description': "Description",
977 'product_size': "Size",
978 'product_weighed': "Sold by Weight",
979 'department_id': "Department ID",
980 'order_uom': "Order UOM",
981 'status_code': "Status",
982 }
984 grid_columns = [
985 'order_id',
986 'customer_name',
987 # 'sequence',
988 'product_scancode',
989 'product_brand',
990 'product_description',
991 'product_size',
992 'department_name',
993 'special_order',
994 'order_qty',
995 'order_uom',
996 'total_price',
997 'status_code',
998 ]
1000 sort_defaults = ('order_id', 'desc')
1002 form_fields = [
1003 'order',
1004 # 'customer_name',
1005 'sequence',
1006 'product_id',
1007 'local_product',
1008 'pending_product',
1009 'product_scancode',
1010 'product_brand',
1011 'product_description',
1012 'product_size',
1013 'product_weighed',
1014 'department_id',
1015 'department_name',
1016 'special_order',
1017 'case_size',
1018 'unit_cost',
1019 'unit_price_reg',
1020 'unit_price_sale',
1021 'sale_ends',
1022 'unit_price_quoted',
1023 'case_price_quoted',
1024 'order_qty',
1025 'order_uom',
1026 'discount_percent',
1027 'total_price',
1028 'status_code',
1029 'paid_amount',
1030 'payment_transaction_number',
1031 ]
1033 def get_query(self, session=None):
1034 """ """
1035 query = super().get_query(session=session)
1036 model = self.app.model
1037 return query.join(model.Order)
1039 def configure_grid(self, g):
1040 """ """
1041 super().configure_grid(g)
1042 model = self.app.model
1043 # enum = self.app.enum
1045 # order_id
1046 g.set_sorter('order_id', model.Order.order_id)
1047 g.set_renderer('order_id', self.render_order_id)
1048 g.set_link('order_id')
1050 # customer_name
1051 g.set_label('customer_name', "Customer", column_only=True)
1053 # # sequence
1054 # g.set_label('sequence', "Seq.", column_only=True)
1056 # product_scancode
1057 g.set_link('product_scancode')
1059 # product_brand
1060 g.set_link('product_brand')
1062 # product_description
1063 g.set_link('product_description')
1065 # product_size
1066 g.set_link('product_size')
1068 # order_uom
1069 # TODO
1070 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
1072 # total_price
1073 g.set_renderer('total_price', g.render_currency)
1075 # status_code
1076 g.set_renderer('status_code', self.render_status_code)
1078 def render_order_id(self, item, key, value):
1079 """ """
1080 return item.order.order_id
1082 def render_status_code(self, item, key, value):
1083 """ """
1084 enum = self.app.enum
1085 return enum.ORDER_ITEM_STATUS[value]
1087 def get_instance_title(self, item):
1088 """ """
1089 enum = self.app.enum
1090 title = str(item)
1091 status = enum.ORDER_ITEM_STATUS[item.status_code]
1092 return f"({status}) {title}"
1094 def configure_form(self, f):
1095 """ """
1096 super().configure_form(f)
1097 enum = self.app.enum
1098 item = f.model_instance
1100 # order
1101 f.set_node('order', OrderRef(self.request))
1103 # local_product
1104 f.set_node('local_product', LocalProductRef(self.request))
1106 # pending_product
1107 if item.product_id or item.local_product:
1108 f.remove('pending_product')
1109 else:
1110 f.set_node('pending_product', PendingProductRef(self.request))
1112 # order_qty
1113 f.set_node('order_qty', WuttaQuantity(self.request))
1115 # order_uom
1116 f.set_node('order_uom', WuttaDictEnum(self.request, enum.ORDER_UOM))
1118 # case_size
1119 f.set_node('case_size', WuttaQuantity(self.request))
1121 # unit_cost
1122 f.set_node('unit_cost', WuttaMoney(self.request, scale=4))
1124 # unit_price_reg
1125 f.set_node('unit_price_reg', WuttaMoney(self.request))
1127 # unit_price_quoted
1128 f.set_node('unit_price_quoted', WuttaMoney(self.request))
1130 # case_price_quoted
1131 f.set_node('case_price_quoted', WuttaMoney(self.request))
1133 # total_price
1134 f.set_node('total_price', WuttaMoney(self.request))
1136 # status
1137 f.set_node('status_code', WuttaDictEnum(self.request, enum.ORDER_ITEM_STATUS))
1139 # paid_amount
1140 f.set_node('paid_amount', WuttaMoney(self.request))
1142 def get_xref_buttons(self, item):
1143 """ """
1144 buttons = super().get_xref_buttons(item)
1146 if self.request.has_perm('orders.view'):
1147 url = self.request.route_url('orders.view', uuid=item.order_uuid)
1148 buttons.append(
1149 self.make_button("View the Order", url=url,
1150 primary=True, icon_left='eye'))
1152 return buttons
1155def defaults(config, **kwargs):
1156 base = globals()
1158 OrderView = kwargs.get('OrderView', base['OrderView'])
1159 OrderView.defaults(config)
1161 OrderItemView = kwargs.get('OrderItemView', base['OrderItemView'])
1162 OrderItemView.defaults(config)
1165def includeme(config):
1166 defaults(config)