Coverage for src/sideshow/web/views/orders.py: 100%
398 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-13 13:01 -0600
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-13 13:01 -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; just 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 f.set_node('local_customer', LocalCustomerRef(self.request))
783 # pending_customer
784 if order.customer_id or order.local_customer:
785 f.remove('pending_customer')
786 else:
787 f.set_node('pending_customer', PendingCustomerRef(self.request))
789 # total_price
790 f.set_node('total_price', WuttaMoney(self.request))
792 # created_by
793 f.set_node('created_by', UserRef(self.request))
794 f.set_readonly('created_by')
796 def get_xref_buttons(self, order):
797 """ """
798 buttons = super().get_xref_buttons(order)
799 model = self.app.model
800 session = self.Session()
802 if self.request.has_perm('neworder_batches.view'):
803 batch = session.query(model.NewOrderBatch)\
804 .filter(model.NewOrderBatch.id == order.order_id)\
805 .first()
806 if batch:
807 url = self.request.route_url('neworder_batches.view', uuid=batch.uuid)
808 buttons.append(
809 self.make_button("View the Batch", primary=True, icon_left='eye', url=url))
811 return buttons
813 def get_row_grid_data(self, order):
814 """ """
815 model = self.app.model
816 session = self.Session()
817 return session.query(model.OrderItem)\
818 .filter(model.OrderItem.order == order)
820 def configure_row_grid(self, g):
821 """ """
822 super().configure_row_grid(g)
823 enum = self.app.enum
825 # sequence
826 g.set_label('sequence', "Seq.", column_only=True)
827 g.set_link('sequence')
829 # product_scancode
830 g.set_link('product_scancode')
832 # product_brand
833 g.set_link('product_brand')
835 # product_description
836 g.set_link('product_description')
838 # product_size
839 g.set_link('product_size')
841 # TODO
842 # order_uom
843 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
845 # total_price
846 g.set_renderer('total_price', g.render_currency)
848 # status_code
849 g.set_renderer('status_code', self.render_status_code)
851 def render_status_code(self, item, key, value):
852 """ """
853 enum = self.app.enum
854 return enum.ORDER_ITEM_STATUS[value]
856 def get_row_action_url_view(self, item, i):
857 """ """
858 return self.request.route_url('order_items.view', uuid=item.uuid)
860 def configure_get_simple_settings(self):
861 """ """
862 settings = [
864 # batches
865 {'name': 'wutta.batch.neworder.handler.spec'},
867 # customers
868 {'name': 'sideshow.orders.use_local_customers',
869 # nb. this is really a bool but we present as string in config UI
870 #'type': bool,
871 'default': 'true'},
873 # products
874 {'name': 'sideshow.orders.use_local_products',
875 # nb. this is really a bool but we present as string in config UI
876 #'type': bool,
877 'default': 'true'},
878 {'name': 'sideshow.orders.allow_unknown_products',
879 'type': bool,
880 'default': True},
881 ]
883 # required fields for new product entry
884 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
885 setting = {'name': f'sideshow.orders.unknown_product.fields.{field}.required',
886 'type': bool}
887 if field == 'description':
888 setting['default'] = True
889 settings.append(setting)
891 return settings
893 def configure_get_context(self, **kwargs):
894 """ """
895 context = super().configure_get_context(**kwargs)
897 context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS
899 handlers = self.app.get_batch_handler_specs('neworder')
900 handlers = [{'spec': spec} for spec in handlers]
901 context['batch_handlers'] = handlers
903 return context
905 @classmethod
906 def defaults(cls, config):
907 cls._order_defaults(config)
908 cls._defaults(config)
910 @classmethod
911 def _order_defaults(cls, config):
912 route_prefix = cls.get_route_prefix()
913 permission_prefix = cls.get_permission_prefix()
914 url_prefix = cls.get_url_prefix()
915 model_title = cls.get_model_title()
916 model_title_plural = cls.get_model_title_plural()
918 # fix perm group
919 config.add_wutta_permission_group(permission_prefix,
920 model_title_plural,
921 overwrite=False)
923 # extra perm required to create order with unknown/pending product
924 config.add_wutta_permission(permission_prefix,
925 f'{permission_prefix}.create_unknown_product',
926 f"Create new {model_title} for unknown/pending product")
928 # customer autocomplete
929 config.add_route(f'{route_prefix}.customer_autocomplete',
930 f'{url_prefix}/customer-autocomplete',
931 request_method='GET')
932 config.add_view(cls, attr='customer_autocomplete',
933 route_name=f'{route_prefix}.customer_autocomplete',
934 renderer='json',
935 permission=f'{permission_prefix}.list')
937 # product autocomplete
938 config.add_route(f'{route_prefix}.product_autocomplete',
939 f'{url_prefix}/product-autocomplete',
940 request_method='GET')
941 config.add_view(cls, attr='product_autocomplete',
942 route_name=f'{route_prefix}.product_autocomplete',
943 renderer='json',
944 permission=f'{permission_prefix}.list')
947class OrderItemView(MasterView):
948 """
949 Master view for :class:`~sideshow.db.model.orders.OrderItem`;
950 route prefix is ``order_items``.
952 Notable URLs provided by this class:
954 * ``/order-items/``
955 * ``/order-items/XXX``
957 Note that this does not expose create, edit or delete. The user
958 must perform various other workflow actions to modify the item.
959 """
960 model_class = OrderItem
961 model_title = "Order Item"
962 route_prefix = 'order_items'
963 url_prefix = '/order-items'
964 creatable = False
965 editable = False
966 deletable = False
968 labels = {
969 'order_id': "Order ID",
970 'product_id': "Product ID",
971 'product_scancode': "Scancode",
972 'product_brand': "Brand",
973 'product_description': "Description",
974 'product_size': "Size",
975 'product_weighed': "Sold by Weight",
976 'department_id': "Department ID",
977 'order_uom': "Order UOM",
978 'status_code': "Status",
979 }
981 grid_columns = [
982 'order_id',
983 'customer_name',
984 # 'sequence',
985 'product_scancode',
986 'product_brand',
987 'product_description',
988 'product_size',
989 'department_name',
990 'special_order',
991 'order_qty',
992 'order_uom',
993 'total_price',
994 'status_code',
995 ]
997 sort_defaults = ('order_id', 'desc')
999 form_fields = [
1000 'order',
1001 # 'customer_name',
1002 'sequence',
1003 'product_id',
1004 'local_product',
1005 'pending_product',
1006 'product_scancode',
1007 'product_brand',
1008 'product_description',
1009 'product_size',
1010 'product_weighed',
1011 'department_id',
1012 'department_name',
1013 'special_order',
1014 'case_size',
1015 'unit_cost',
1016 'unit_price_reg',
1017 'unit_price_sale',
1018 'sale_ends',
1019 'unit_price_quoted',
1020 'case_price_quoted',
1021 'order_qty',
1022 'order_uom',
1023 'discount_percent',
1024 'total_price',
1025 'status_code',
1026 'paid_amount',
1027 'payment_transaction_number',
1028 ]
1030 def get_query(self, session=None):
1031 """ """
1032 query = super().get_query(session=session)
1033 model = self.app.model
1034 return query.join(model.Order)
1036 def configure_grid(self, g):
1037 """ """
1038 super().configure_grid(g)
1039 model = self.app.model
1040 # enum = self.app.enum
1042 # order_id
1043 g.set_sorter('order_id', model.Order.order_id)
1044 g.set_renderer('order_id', self.render_order_id)
1045 g.set_link('order_id')
1047 # customer_name
1048 g.set_label('customer_name', "Customer", column_only=True)
1050 # # sequence
1051 # g.set_label('sequence', "Seq.", column_only=True)
1053 # product_scancode
1054 g.set_link('product_scancode')
1056 # product_brand
1057 g.set_link('product_brand')
1059 # product_description
1060 g.set_link('product_description')
1062 # product_size
1063 g.set_link('product_size')
1065 # order_uom
1066 # TODO
1067 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
1069 # total_price
1070 g.set_renderer('total_price', g.render_currency)
1072 # status_code
1073 g.set_renderer('status_code', self.render_status_code)
1075 def render_order_id(self, item, key, value):
1076 """ """
1077 return item.order.order_id
1079 def render_status_code(self, item, key, value):
1080 """ """
1081 enum = self.app.enum
1082 return enum.ORDER_ITEM_STATUS[value]
1084 def get_instance_title(self, item):
1085 """ """
1086 enum = self.app.enum
1087 title = str(item)
1088 status = enum.ORDER_ITEM_STATUS[item.status_code]
1089 return f"({status}) {title}"
1091 def configure_form(self, f):
1092 """ """
1093 super().configure_form(f)
1094 enum = self.app.enum
1095 item = f.model_instance
1097 # order
1098 f.set_node('order', OrderRef(self.request))
1100 # local_product
1101 f.set_node('local_product', LocalProductRef(self.request))
1103 # pending_product
1104 if item.product_id or item.local_product:
1105 f.remove('pending_product')
1106 else:
1107 f.set_node('pending_product', PendingProductRef(self.request))
1109 # order_qty
1110 f.set_node('order_qty', WuttaQuantity(self.request))
1112 # order_uom
1113 f.set_node('order_uom', WuttaDictEnum(self.request, enum.ORDER_UOM))
1115 # case_size
1116 f.set_node('case_size', WuttaQuantity(self.request))
1118 # unit_cost
1119 f.set_node('unit_cost', WuttaMoney(self.request, scale=4))
1121 # unit_price_reg
1122 f.set_node('unit_price_reg', WuttaMoney(self.request))
1124 # unit_price_quoted
1125 f.set_node('unit_price_quoted', WuttaMoney(self.request))
1127 # case_price_quoted
1128 f.set_node('case_price_quoted', WuttaMoney(self.request))
1130 # total_price
1131 f.set_node('total_price', WuttaMoney(self.request))
1133 # status
1134 f.set_node('status_code', WuttaDictEnum(self.request, enum.ORDER_ITEM_STATUS))
1136 # paid_amount
1137 f.set_node('paid_amount', WuttaMoney(self.request))
1139 def get_xref_buttons(self, item):
1140 """ """
1141 buttons = super().get_xref_buttons(item)
1143 if self.request.has_perm('orders.view'):
1144 url = self.request.route_url('orders.view', uuid=item.order_uuid)
1145 buttons.append(
1146 self.make_button("View the Order", url=url,
1147 primary=True, icon_left='eye'))
1149 return buttons
1152def defaults(config, **kwargs):
1153 base = globals()
1155 OrderView = kwargs.get('OrderView', base['OrderView'])
1156 OrderView.defaults(config)
1158 OrderItemView = kwargs.get('OrderItemView', base['OrderItemView'])
1159 OrderItemView.defaults(config)
1162def includeme(config):
1163 defaults(config)