Coverage for src/sideshow/web/views/orders.py: 100%
420 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-09 12:06 -0600
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-09 12:06 -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.
60 """
61 model_class = Order
62 editable = False
63 configurable = True
65 labels = {
66 'order_id': "Order ID",
67 'store_id': "Store ID",
68 'customer_id': "Customer ID",
69 }
71 grid_columns = [
72 'order_id',
73 'store_id',
74 'customer_id',
75 'customer_name',
76 'total_price',
77 'created',
78 'created_by',
79 ]
81 sort_defaults = ('order_id', 'desc')
83 form_fields = [
84 'order_id',
85 'store_id',
86 'customer_id',
87 'local_customer',
88 'pending_customer',
89 'customer_name',
90 'phone_number',
91 'email_address',
92 'total_price',
93 'created',
94 'created_by',
95 ]
97 has_rows = True
98 row_model_class = OrderItem
99 rows_title = "Order Items"
100 rows_sort_defaults = 'sequence'
101 rows_viewable = True
103 row_labels = {
104 'product_scancode': "Scancode",
105 'product_brand': "Brand",
106 'product_description': "Description",
107 'product_size': "Size",
108 'department_name': "Department",
109 'order_uom': "Order UOM",
110 'status_code': "Status",
111 }
113 row_grid_columns = [
114 'sequence',
115 'product_scancode',
116 'product_brand',
117 'product_description',
118 'product_size',
119 'department_name',
120 'special_order',
121 'order_qty',
122 'order_uom',
123 'total_price',
124 'status_code',
125 ]
127 PENDING_PRODUCT_ENTRY_FIELDS = [
128 'scancode',
129 'brand_name',
130 'description',
131 'size',
132 'department_name',
133 'vendor_name',
134 'vendor_item_code',
135 'case_size',
136 'unit_cost',
137 'unit_price_reg',
138 ]
140 def configure_grid(self, g):
141 """ """
142 super().configure_grid(g)
144 # order_id
145 g.set_link('order_id')
147 # customer_id
148 g.set_link('customer_id')
150 # customer_name
151 g.set_link('customer_name')
153 # total_price
154 g.set_renderer('total_price', g.render_currency)
156 def create(self):
157 """
158 Instead of the typical "create" view, this displays a "wizard"
159 of sorts.
161 Under the hood a
162 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` is
163 automatically created for the user when they first visit this
164 page. They can select a customer, add items etc.
166 When user is finished assembling the order (i.e. populating
167 the batch), they submit it. This of course executes the
168 batch, which in turn creates a true
169 :class:`~sideshow.db.model.orders.Order`, and user is
170 redirected to the "view order" page.
172 See also these methods which may be called from this one,
173 based on user actions:
175 * :meth:`start_over()`
176 * :meth:`cancel_order()`
177 * :meth:`assign_customer()`
178 * :meth:`unassign_customer()`
179 * :meth:`set_pending_customer()`
180 * :meth:`get_product_info()`
181 * :meth:`add_item()`
182 * :meth:`update_item()`
183 * :meth:`delete_item()`
184 * :meth:`submit_order()`
185 """
186 enum = self.app.enum
187 self.creating = True
188 self.batch_handler = NewOrderBatchHandler(self.config)
189 batch = self.get_current_batch()
191 context = self.get_context_customer(batch)
193 if self.request.method == 'POST':
195 # first we check for traditional form post
196 action = self.request.POST.get('action')
197 post_actions = [
198 'start_over',
199 'cancel_order',
200 ]
201 if action in post_actions:
202 return getattr(self, action)(batch)
204 # okay then, we'll assume newer JSON-style post params
205 data = dict(self.request.json_body)
206 action = data.pop('action')
207 json_actions = [
208 'assign_customer',
209 'unassign_customer',
210 # 'update_phone_number',
211 # 'update_email_address',
212 'set_pending_customer',
213 # 'get_customer_info',
214 # # 'set_customer_data',
215 'get_product_info',
216 # 'get_past_items',
217 'add_item',
218 'update_item',
219 'delete_item',
220 'submit_order',
221 ]
222 if action in json_actions:
223 try:
224 result = getattr(self, action)(batch, data)
225 except Exception as error:
226 result = {'error': self.app.render_error(error)}
227 return self.json_response(result)
229 return self.json_response({'error': "unknown form action"})
231 context.update({
232 'batch': batch,
233 'normalized_batch': self.normalize_batch(batch),
234 'order_items': [self.normalize_row(row)
235 for row in batch.rows],
236 'default_uom_choices': self.get_default_uom_choices(),
237 'default_uom': None, # TODO?
238 'allow_unknown_products': (self.batch_handler.allow_unknown_products()
239 and self.has_perm('create_unknown_product')),
240 'pending_product_required_fields': self.get_pending_product_required_fields(),
241 })
242 return self.render_to_response('create', context)
244 def get_current_batch(self):
245 """
246 Returns the current batch for the current user.
248 This looks for a new order batch which was created by the
249 user, but not yet executed. If none is found, a new batch is
250 created.
252 :returns:
253 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
254 instance
255 """
256 model = self.app.model
257 session = self.Session()
259 user = self.request.user
260 if not user:
261 raise self.forbidden()
263 try:
264 # there should be at most *one* new batch per user
265 batch = session.query(model.NewOrderBatch)\
266 .filter(model.NewOrderBatch.created_by == user)\
267 .filter(model.NewOrderBatch.executed == None)\
268 .one()
270 except orm.exc.NoResultFound:
271 # no batch yet for this user, so make one
272 batch = self.batch_handler.make_batch(session, created_by=user)
273 session.add(batch)
274 session.flush()
276 return batch
278 def customer_autocomplete(self):
279 """
280 AJAX view for customer autocomplete, when entering new order.
282 This should invoke a configured handler for the autocomplete
283 behavior, but that is not yet implemented. For now it uses
284 built-in logic only, which queries the
285 :class:`~sideshow.db.model.customers.LocalCustomer` table.
286 """
287 session = self.Session()
288 term = self.request.GET.get('term', '').strip()
289 if not term:
290 return []
291 return self.mock_autocomplete_customers(session, term, user=self.request.user)
293 # TODO: move this to some handler
294 def mock_autocomplete_customers(self, session, term, user=None):
295 """ """
296 import sqlalchemy as sa
298 model = self.app.model
300 # base query
301 query = session.query(model.LocalCustomer)
303 # filter query
304 criteria = [model.LocalCustomer.full_name.ilike(f'%{word}%')
305 for word in term.split()]
306 query = query.filter(sa.and_(*criteria))
308 # sort query
309 query = query.order_by(model.LocalCustomer.full_name)
311 # get data
312 # TODO: need max_results option
313 customers = query.all()
315 # get results
316 def result(customer):
317 return {'value': customer.uuid.hex,
318 'label': customer.full_name}
319 return [result(c) for c in customers]
321 def product_autocomplete(self):
322 """
323 AJAX view for product autocomplete, when entering new order.
325 This should invoke a configured handler for the autocomplete
326 behavior, but that is not yet implemented. For now it uses
327 built-in logic only, which queries the
328 :class:`~sideshow.db.model.products.LocalProduct` table.
329 """
330 session = self.Session()
331 term = self.request.GET.get('term', '').strip()
332 if not term:
333 return []
334 return self.mock_autocomplete_products(session, term, user=self.request.user)
336 # TODO: move this to some handler
337 def mock_autocomplete_products(self, session, term, user=None):
338 """ """
339 import sqlalchemy as sa
341 model = self.app.model
343 # base query
344 query = session.query(model.LocalProduct)
346 # filter query
347 criteria = []
348 for word in term.split():
349 criteria.append(sa.or_(
350 model.LocalProduct.brand_name.ilike(f'%{word}%'),
351 model.LocalProduct.description.ilike(f'%{word}%')))
352 query = query.filter(sa.and_(*criteria))
354 # sort query
355 query = query.order_by(model.LocalProduct.brand_name,
356 model.LocalProduct.description)
358 # get data
359 # TODO: need max_results option
360 products = query.all()
362 # get results
363 def result(product):
364 return {'value': product.uuid.hex,
365 'label': product.full_description}
366 return [result(c) for c in products]
368 def get_pending_product_required_fields(self):
369 """ """
370 required = []
371 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
372 require = self.config.get_bool(
373 f'sideshow.orders.unknown_product.fields.{field}.required')
374 if require is None and field == 'description':
375 require = True
376 if require:
377 required.append(field)
378 return required
380 def start_over(self, batch):
381 """
382 This will delete the user's current batch, then redirect user
383 back to "Create Order" page, which in turn will auto-create a
384 new batch for them.
386 This is a "batch action" method which may be called from
387 :meth:`create()`. See also:
389 * :meth:`cancel_order()`
390 * :meth:`submit_order()`
391 """
392 # drop current batch
393 self.batch_handler.do_delete(batch, self.request.user)
394 self.Session.flush()
396 # send back to "create order" which makes new batch
397 route_prefix = self.get_route_prefix()
398 url = self.request.route_url(f'{route_prefix}.create')
399 return self.redirect(url)
401 def cancel_order(self, batch):
402 """
403 This will delete the user's current batch, then redirect user
404 back to "List Orders" page.
406 This is a "batch action" method which may be called from
407 :meth:`create()`. See also:
409 * :meth:`start_over()`
410 * :meth:`submit_order()`
411 """
412 self.batch_handler.do_delete(batch, self.request.user)
413 self.Session.flush()
415 # set flash msg just to be more obvious
416 self.request.session.flash("New order has been deleted.")
418 # send user back to orders list, w/ no new batch generated
419 url = self.get_index_url()
420 return self.redirect(url)
422 def get_context_customer(self, batch):
423 """ """
424 context = {
425 'customer_is_known': True,
426 'customer_id': None,
427 'customer_name': batch.customer_name,
428 'phone_number': batch.phone_number,
429 'email_address': batch.email_address,
430 }
432 # customer_id
433 use_local = self.batch_handler.use_local_customers()
434 if use_local:
435 local = batch.local_customer
436 if local:
437 context['customer_id'] = local.uuid.hex
438 else: # use external
439 context['customer_id'] = batch.customer_id
441 # pending customer
442 pending = batch.pending_customer
443 if pending:
444 context.update({
445 'new_customer_first_name': pending.first_name,
446 'new_customer_last_name': pending.last_name,
447 'new_customer_full_name': pending.full_name,
448 'new_customer_phone': pending.phone_number,
449 'new_customer_email': pending.email_address,
450 })
452 # declare customer "not known" only if pending is in use
453 if (pending
454 and not batch.customer_id and not batch.local_customer
455 and batch.customer_name):
456 context['customer_is_known'] = False
458 return context
460 def assign_customer(self, batch, data):
461 """
462 Assign the true customer account for a batch.
464 This calls
465 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
466 for the heavy lifting.
468 This is a "batch action" method which may be called from
469 :meth:`create()`. See also:
471 * :meth:`unassign_customer()`
472 * :meth:`set_pending_customer()`
473 """
474 customer_id = data.get('customer_id')
475 if not customer_id:
476 return {'error': "Must provide customer_id"}
478 self.batch_handler.set_customer(batch, customer_id)
479 return self.get_context_customer(batch)
481 def unassign_customer(self, batch, data):
482 """
483 Clear the customer info for a batch.
485 This calls
486 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
487 for the heavy lifting.
489 This is a "batch action" method which may be called from
490 :meth:`create()`. See also:
492 * :meth:`assign_customer()`
493 * :meth:`set_pending_customer()`
494 """
495 self.batch_handler.set_customer(batch, None)
496 return self.get_context_customer(batch)
498 def set_pending_customer(self, batch, data):
499 """
500 This will set/update the batch pending customer info.
502 This calls
503 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
504 for the heavy lifting.
506 This is a "batch action" method which may be called from
507 :meth:`create()`. See also:
509 * :meth:`assign_customer()`
510 * :meth:`unassign_customer()`
511 """
512 self.batch_handler.set_customer(batch, data, user=self.request.user)
513 return self.get_context_customer(batch)
515 def get_product_info(self, batch, data):
516 """
517 Fetch data for a specific product. (Nothing is modified.)
519 Depending on config, this will fetch a :term:`local product`
520 or :term:`external product` to get the data.
522 This should invoke a configured handler for the query
523 behavior, but that is not yet implemented. For now it uses
524 built-in logic only, which queries the
525 :class:`~sideshow.db.model.products.LocalProduct` table.
527 This is a "batch action" method which may be called from
528 :meth:`create()`.
529 """
530 product_id = data.get('product_id')
531 if not product_id:
532 return {'error': "Must specify a product ID"}
534 use_local = self.batch_handler.use_local_products()
535 if use_local:
536 data = self.get_local_product_info(product_id)
537 else:
538 raise NotImplementedError("TODO: add integration handler")
540 if 'error' in data:
541 return data
543 if 'unit_price_reg' in data and 'unit_price_reg_display' not in data:
544 data['unit_price_reg_display'] = self.app.render_currency(data['unit_price_reg'])
546 if 'unit_price_reg' in data and 'unit_price_quoted' not in data:
547 data['unit_price_quoted'] = data['unit_price_reg']
549 if 'unit_price_quoted' in data and 'unit_price_quoted_display' not in data:
550 data['unit_price_quoted_display'] = self.app.render_currency(data['unit_price_quoted'])
552 if 'case_price_quoted' not in data:
553 if data.get('unit_price_quoted') is not None and data.get('case_size') is not None:
554 data['case_price_quoted'] = data['unit_price_quoted'] * data['case_size']
556 if 'case_price_quoted' in data and 'case_price_quoted_display' not in data:
557 data['case_price_quoted_display'] = self.app.render_currency(data['case_price_quoted'])
559 decimal_fields = [
560 'case_size',
561 'unit_price_reg',
562 'unit_price_quoted',
563 'case_price_quoted',
564 ]
566 for field in decimal_fields:
567 if field in list(data):
568 value = data[field]
569 if isinstance(value, decimal.Decimal):
570 data[field] = float(value)
572 return data
574 # TODO: move this to some handler
575 def get_local_product_info(self, product_id):
576 """ """
577 model = self.app.model
578 session = self.Session()
579 product = session.get(model.LocalProduct, product_id)
580 if not product:
581 return {'error': "Product not found"}
583 return {
584 'product_id': product.uuid.hex,
585 'scancode': product.scancode,
586 'brand_name': product.brand_name,
587 'description': product.description,
588 'size': product.size,
589 'full_description': product.full_description,
590 'weighed': product.weighed,
591 'special_order': product.special_order,
592 'department_id': product.department_id,
593 'department_name': product.department_name,
594 'case_size': product.case_size,
595 'unit_price_reg': product.unit_price_reg,
596 'vendor_name': product.vendor_name,
597 'vendor_item_code': product.vendor_item_code,
598 }
600 def add_item(self, batch, data):
601 """
602 This adds a row to the user's current new order batch.
604 This is a "batch action" method which may be called from
605 :meth:`create()`. See also:
607 * :meth:`update_item()`
608 * :meth:`delete_item()`
609 """
610 row = self.batch_handler.add_item(batch, data['product_info'],
611 data['order_qty'], data['order_uom'])
613 return {'batch': self.normalize_batch(batch),
614 'row': self.normalize_row(row)}
616 def update_item(self, batch, data):
617 """
618 This updates a row in the user's current new order batch.
620 This is a "batch action" method which may be called from
621 :meth:`create()`. See also:
623 * :meth:`add_item()`
624 * :meth:`delete_item()`
625 """
626 model = self.app.model
627 session = self.Session()
629 uuid = data.get('uuid')
630 if not uuid:
631 return {'error': "Must specify row UUID"}
633 row = session.get(model.NewOrderBatchRow, uuid)
634 if not row:
635 return {'error': "Row not found"}
637 if row.batch is not batch:
638 return {'error': "Row is for wrong batch"}
640 self.batch_handler.update_item(row, data['product_info'],
641 data['order_qty'], data['order_uom'])
643 return {'batch': self.normalize_batch(batch),
644 'row': self.normalize_row(row)}
646 def delete_item(self, batch, data):
647 """
648 This deletes a row from the user's current new order batch.
650 This is a "batch action" method which may be called from
651 :meth:`create()`. See also:
653 * :meth:`add_item()`
654 * :meth:`update_item()`
655 """
656 model = self.app.model
657 session = self.app.get_session(batch)
659 uuid = data.get('uuid')
660 if not uuid:
661 return {'error': "Must specify a row UUID"}
663 row = session.get(model.NewOrderBatchRow, uuid)
664 if not row:
665 return {'error': "Row not found"}
667 if row.batch is not batch:
668 return {'error': "Row is for wrong batch"}
670 self.batch_handler.do_remove_row(row)
671 return {'batch': self.normalize_batch(batch)}
673 def submit_order(self, batch, data):
674 """
675 This submits the user's current new order batch, hence
676 executing the batch and creating the true order.
678 This is a "batch action" method which may be called from
679 :meth:`create()`. See also:
681 * :meth:`start_over()`
682 * :meth:`cancel_order()`
683 """
684 user = self.request.user
685 reason = self.batch_handler.why_not_execute(batch, user=user)
686 if reason:
687 return {'error': reason}
689 try:
690 order = self.batch_handler.do_execute(batch, user)
691 except Exception as error:
692 log.warning("failed to execute new order batch: %s", batch,
693 exc_info=True)
694 return {'error': self.app.render_error(error)}
696 return {
697 'next_url': self.get_action_url('view', order),
698 }
700 def normalize_batch(self, batch):
701 """ """
702 return {
703 'uuid': batch.uuid.hex,
704 'total_price': str(batch.total_price or 0),
705 'total_price_display': self.app.render_currency(batch.total_price),
706 'status_code': batch.status_code,
707 'status_text': batch.status_text,
708 }
710 def get_default_uom_choices(self):
711 """ """
712 enum = self.app.enum
713 return [{'key': key, 'value': val}
714 for key, val in enum.ORDER_UOM.items()]
716 def normalize_row(self, row):
717 """ """
718 enum = self.app.enum
720 data = {
721 'uuid': row.uuid.hex,
722 'sequence': row.sequence,
723 'product_id': None,
724 'product_scancode': row.product_scancode,
725 'product_brand': row.product_brand,
726 'product_description': row.product_description,
727 'product_size': row.product_size,
728 'product_weighed': row.product_weighed,
729 'department_display': row.department_name,
730 'special_order': row.special_order,
731 'case_size': float(row.case_size) if row.case_size is not None else None,
732 'order_qty': float(row.order_qty),
733 'order_uom': row.order_uom,
734 'order_uom_choices': self.get_default_uom_choices(),
735 'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None,
736 'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted),
737 'case_price_quoted': float(row.case_price_quoted) if row.case_price_quoted is not None else None,
738 'case_price_quoted_display': self.app.render_currency(row.case_price_quoted),
739 'total_price': float(row.total_price) if row.total_price is not None else None,
740 'total_price_display': self.app.render_currency(row.total_price),
741 'status_code': row.status_code,
742 'status_text': row.status_text,
743 }
745 use_local = self.batch_handler.use_local_products()
747 # product_id
748 if use_local:
749 if row.local_product:
750 data['product_id'] = row.local_product.uuid.hex
751 else:
752 data['product_id'] = row.product_id
754 # product_full_description
755 if use_local:
756 if row.local_product:
757 data['product_full_description'] = row.local_product.full_description
758 else: # use external
759 pass # TODO
760 if not data.get('product_id') and row.pending_product:
761 data['product_full_description'] = row.pending_product.full_description
763 # vendor_name
764 if use_local:
765 if row.local_product:
766 data['vendor_name'] = row.local_product.vendor_name
767 else: # use external
768 pass # TODO
769 if not data.get('product_id') and row.pending_product:
770 data['vendor_name'] = row.pending_product.vendor_name
772 if row.unit_price_reg:
773 data['unit_price_reg'] = float(row.unit_price_reg)
774 data['unit_price_reg_display'] = self.app.render_currency(row.unit_price_reg)
776 if row.unit_price_sale:
777 data['unit_price_sale'] = float(row.unit_price_sale)
778 data['unit_price_sale_display'] = self.app.render_currency(row.unit_price_sale)
779 if row.sale_ends:
780 sale_ends = row.sale_ends
781 data['sale_ends'] = str(row.sale_ends)
782 data['sale_ends_display'] = self.app.render_date(row.sale_ends)
784 if row.pending_product:
785 pending = row.pending_product
786 data['pending_product'] = {
787 'uuid': pending.uuid.hex,
788 'scancode': pending.scancode,
789 'brand_name': pending.brand_name,
790 'description': pending.description,
791 'size': pending.size,
792 'department_id': pending.department_id,
793 'department_name': pending.department_name,
794 'unit_price_reg': float(pending.unit_price_reg) if pending.unit_price_reg is not None else None,
795 'vendor_name': pending.vendor_name,
796 'vendor_item_code': pending.vendor_item_code,
797 'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None,
798 'case_size': float(pending.case_size) if pending.case_size is not None else None,
799 'notes': pending.notes,
800 'special_order': pending.special_order,
801 }
803 # display text for order qty/uom
804 if row.order_uom == enum.ORDER_UOM_CASE:
805 order_qty = self.app.render_quantity(row.order_qty)
806 if row.case_size is None:
807 case_qty = unit_qty = '??'
808 else:
809 case_qty = self.app.render_quantity(row.case_size)
810 unit_qty = self.app.render_quantity(row.order_qty * row.case_size)
811 CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE]
812 EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
813 data['order_qty_display'] = (f"{order_qty} {CS} "
814 f"(× {case_qty} = {unit_qty} {EA})")
815 else:
816 unit_qty = self.app.render_quantity(row.order_qty)
817 EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
818 data['order_qty_display'] = f"{unit_qty} {EA}"
820 return data
822 def get_instance_title(self, order):
823 """ """
824 return f"#{order.order_id} for {order.customer_name}"
826 def configure_form(self, f):
827 """ """
828 super().configure_form(f)
829 order = f.model_instance
831 # local_customer
832 f.set_node('local_customer', LocalCustomerRef(self.request))
834 # pending_customer
835 if order.customer_id or order.local_customer:
836 f.remove('pending_customer')
837 else:
838 f.set_node('pending_customer', PendingCustomerRef(self.request))
840 # total_price
841 f.set_node('total_price', WuttaMoney(self.request))
843 # created_by
844 f.set_node('created_by', UserRef(self.request))
845 f.set_readonly('created_by')
847 def get_xref_buttons(self, order):
848 """ """
849 buttons = super().get_xref_buttons(order)
850 model = self.app.model
851 session = self.Session()
853 if self.request.has_perm('neworder_batches.view'):
854 batch = session.query(model.NewOrderBatch)\
855 .filter(model.NewOrderBatch.id == order.order_id)\
856 .first()
857 if batch:
858 url = self.request.route_url('neworder_batches.view', uuid=batch.uuid)
859 buttons.append(
860 self.make_button("View the Batch", primary=True, icon_left='eye', url=url))
862 return buttons
864 def get_row_grid_data(self, order):
865 """ """
866 model = self.app.model
867 session = self.Session()
868 return session.query(model.OrderItem)\
869 .filter(model.OrderItem.order == order)
871 def configure_row_grid(self, g):
872 """ """
873 super().configure_row_grid(g)
874 enum = self.app.enum
876 # sequence
877 g.set_label('sequence', "Seq.", column_only=True)
878 g.set_link('sequence')
880 # product_scancode
881 g.set_link('product_scancode')
883 # product_brand
884 g.set_link('product_brand')
886 # product_description
887 g.set_link('product_description')
889 # product_size
890 g.set_link('product_size')
892 # TODO
893 # order_uom
894 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
896 # total_price
897 g.set_renderer('total_price', g.render_currency)
899 # status_code
900 g.set_renderer('status_code', self.render_status_code)
902 def render_status_code(self, item, key, value):
903 """ """
904 enum = self.app.enum
905 return enum.ORDER_ITEM_STATUS[value]
907 def get_row_action_url_view(self, item, i):
908 """ """
909 return self.request.route_url('order_items.view', uuid=item.uuid)
911 def configure_get_simple_settings(self):
912 """ """
913 settings = [
915 # products
916 {'name': 'sideshow.orders.allow_unknown_products',
917 'type': bool,
918 'default': True},
919 ]
921 # required fields for new product entry
922 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
923 setting = {'name': f'sideshow.orders.unknown_product.fields.{field}.required',
924 'type': bool}
925 if field == 'description':
926 setting['default'] = True
927 settings.append(setting)
929 return settings
931 def configure_get_context(self, **kwargs):
932 """ """
933 context = super().configure_get_context(**kwargs)
935 context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS
937 return context
939 @classmethod
940 def defaults(cls, config):
941 cls._order_defaults(config)
942 cls._defaults(config)
944 @classmethod
945 def _order_defaults(cls, config):
946 route_prefix = cls.get_route_prefix()
947 permission_prefix = cls.get_permission_prefix()
948 url_prefix = cls.get_url_prefix()
949 model_title = cls.get_model_title()
950 model_title_plural = cls.get_model_title_plural()
952 # fix perm group
953 config.add_wutta_permission_group(permission_prefix,
954 model_title_plural,
955 overwrite=False)
957 # extra perm required to create order with unknown/pending product
958 config.add_wutta_permission(permission_prefix,
959 f'{permission_prefix}.create_unknown_product',
960 f"Create new {model_title} for unknown/pending product")
962 # customer autocomplete
963 config.add_route(f'{route_prefix}.customer_autocomplete',
964 f'{url_prefix}/customer-autocomplete',
965 request_method='GET')
966 config.add_view(cls, attr='customer_autocomplete',
967 route_name=f'{route_prefix}.customer_autocomplete',
968 renderer='json',
969 permission=f'{permission_prefix}.list')
971 # product autocomplete
972 config.add_route(f'{route_prefix}.product_autocomplete',
973 f'{url_prefix}/product-autocomplete',
974 request_method='GET')
975 config.add_view(cls, attr='product_autocomplete',
976 route_name=f'{route_prefix}.product_autocomplete',
977 renderer='json',
978 permission=f'{permission_prefix}.list')
981class OrderItemView(MasterView):
982 """
983 Master view for :class:`~sideshow.db.model.orders.OrderItem`;
984 route prefix is ``order_items``.
986 Notable URLs provided by this class:
988 * ``/order-items/``
989 * ``/order-items/XXX``
991 Note that this does not expose create, edit or delete. The user
992 must perform various other workflow actions to modify the item.
993 """
994 model_class = OrderItem
995 model_title = "Order Item"
996 route_prefix = 'order_items'
997 url_prefix = '/order-items'
998 creatable = False
999 editable = False
1000 deletable = False
1002 labels = {
1003 'order_id': "Order ID",
1004 'product_id': "Product ID",
1005 'product_scancode': "Scancode",
1006 'product_brand': "Brand",
1007 'product_description': "Description",
1008 'product_size': "Size",
1009 'product_weighed': "Sold by Weight",
1010 'department_id': "Department ID",
1011 'order_uom': "Order UOM",
1012 'status_code': "Status",
1013 }
1015 grid_columns = [
1016 'order_id',
1017 'customer_name',
1018 # 'sequence',
1019 'product_scancode',
1020 'product_brand',
1021 'product_description',
1022 'product_size',
1023 'department_name',
1024 'special_order',
1025 'order_qty',
1026 'order_uom',
1027 'total_price',
1028 'status_code',
1029 ]
1031 sort_defaults = ('order_id', 'desc')
1033 form_fields = [
1034 'order',
1035 # 'customer_name',
1036 'sequence',
1037 'product_id',
1038 'local_product',
1039 'pending_product',
1040 'product_scancode',
1041 'product_brand',
1042 'product_description',
1043 'product_size',
1044 'product_weighed',
1045 'department_id',
1046 'department_name',
1047 'special_order',
1048 'order_qty',
1049 'order_uom',
1050 'case_size',
1051 'unit_cost',
1052 'unit_price_reg',
1053 'unit_price_sale',
1054 'sale_ends',
1055 'unit_price_quoted',
1056 'case_price_quoted',
1057 'discount_percent',
1058 'total_price',
1059 'status_code',
1060 'paid_amount',
1061 'payment_transaction_number',
1062 ]
1064 def get_query(self, session=None):
1065 """ """
1066 query = super().get_query(session=session)
1067 model = self.app.model
1068 return query.join(model.Order)
1070 def configure_grid(self, g):
1071 """ """
1072 super().configure_grid(g)
1073 model = self.app.model
1074 # enum = self.app.enum
1076 # order_id
1077 g.set_sorter('order_id', model.Order.order_id)
1078 g.set_renderer('order_id', self.render_order_id)
1079 g.set_link('order_id')
1081 # customer_name
1082 g.set_label('customer_name', "Customer", column_only=True)
1084 # # sequence
1085 # g.set_label('sequence', "Seq.", column_only=True)
1087 # product_scancode
1088 g.set_link('product_scancode')
1090 # product_brand
1091 g.set_link('product_brand')
1093 # product_description
1094 g.set_link('product_description')
1096 # product_size
1097 g.set_link('product_size')
1099 # order_uom
1100 # TODO
1101 #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
1103 # total_price
1104 g.set_renderer('total_price', g.render_currency)
1106 # status_code
1107 g.set_renderer('status_code', self.render_status_code)
1109 def render_order_id(self, item, key, value):
1110 """ """
1111 return item.order.order_id
1113 def render_status_code(self, item, key, value):
1114 """ """
1115 enum = self.app.enum
1116 return enum.ORDER_ITEM_STATUS[value]
1118 def get_instance_title(self, item):
1119 """ """
1120 enum = self.app.enum
1121 title = str(item)
1122 status = enum.ORDER_ITEM_STATUS[item.status_code]
1123 return f"({status}) {title}"
1125 def configure_form(self, f):
1126 """ """
1127 super().configure_form(f)
1128 enum = self.app.enum
1129 item = f.model_instance
1131 # order
1132 f.set_node('order', OrderRef(self.request))
1134 # local_product
1135 f.set_node('local_product', LocalProductRef(self.request))
1137 # pending_product
1138 if item.product_id or item.local_product:
1139 f.remove('pending_product')
1140 else:
1141 f.set_node('pending_product', PendingProductRef(self.request))
1143 # order_qty
1144 f.set_node('order_qty', WuttaQuantity(self.request))
1146 # order_uom
1147 f.set_node('order_uom', WuttaDictEnum(self.request, enum.ORDER_UOM))
1149 # case_size
1150 f.set_node('case_size', WuttaQuantity(self.request))
1152 # unit_cost
1153 f.set_node('unit_cost', WuttaMoney(self.request, scale=4))
1155 # unit_price_reg
1156 f.set_node('unit_price_reg', WuttaMoney(self.request))
1158 # unit_price_quoted
1159 f.set_node('unit_price_quoted', WuttaMoney(self.request))
1161 # case_price_quoted
1162 f.set_node('case_price_quoted', WuttaMoney(self.request))
1164 # total_price
1165 f.set_node('total_price', WuttaMoney(self.request))
1167 # status
1168 f.set_node('status_code', WuttaDictEnum(self.request, enum.ORDER_ITEM_STATUS))
1170 # paid_amount
1171 f.set_node('paid_amount', WuttaMoney(self.request))
1173 def get_xref_buttons(self, item):
1174 """ """
1175 buttons = super().get_xref_buttons(item)
1177 if self.request.has_perm('orders.view'):
1178 url = self.request.route_url('orders.view', uuid=item.order_uuid)
1179 buttons.append(
1180 self.make_button("View the Order", url=url,
1181 primary=True, icon_left='eye'))
1183 return buttons
1186def defaults(config, **kwargs):
1187 base = globals()
1189 OrderView = kwargs.get('OrderView', base['OrderView'])
1190 OrderView.defaults(config)
1192 OrderItemView = kwargs.get('OrderItemView', base['OrderItemView'])
1193 OrderItemView.defaults(config)
1196def includeme(config):
1197 defaults(config)