Coverage for .tox / coverage / lib / python3.11 / site-packages / sideshow / web / views / orders.py: 100%
778 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-15 17:10 -0600
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-15 17:10 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# Sideshow -- Case/Special Order Tracker
5# Copyright © 2024-2025 Lance Edgar
6#
7# This file is part of Sideshow.
8#
9# Sideshow is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# Sideshow is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17# General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Views for Orders
25"""
26# pylint: disable=too-many-lines
28import decimal
29import json
30import logging
31import re
33import sqlalchemy as sa
34from sqlalchemy import orm
36from webhelpers2.html import tags, HTML
38from wuttaweb.views import MasterView
39from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaDictEnum
40from wuttaweb.util import make_json_safe
42from sideshow.db.model import Order, OrderItem
43from sideshow.web.forms.schema import (
44 OrderRef,
45 LocalCustomerRef,
46 LocalProductRef,
47 PendingCustomerRef,
48 PendingProductRef,
49)
52log = logging.getLogger(__name__)
55class OrderView(MasterView): # pylint: disable=too-many-public-methods
56 """
57 Master view for :class:`~sideshow.db.model.orders.Order`; route
58 prefix is ``orders``.
60 Notable URLs provided by this class:
62 * ``/orders/``
63 * ``/orders/new``
64 * ``/orders/XXX``
65 * ``/orders/XXX/delete``
67 Note that the "edit" view is not exposed here; user must perform
68 various other workflow actions to modify the order.
70 .. attribute:: order_handler
72 Reference to the :term:`order handler` as returned by
73 :meth:`~sideshow.app.SideshowAppProvider.get_order_handler()`.
74 This gets set in the constructor.
76 .. attribute:: batch_handler
78 Reference to the :term:`new order batch` handler. This gets
79 set in the constructor.
80 """
82 model_class = Order
83 editable = False
84 configurable = True
86 labels = {
87 "order_id": "Order ID",
88 "store_id": "Store ID",
89 "customer_id": "Customer ID",
90 }
92 grid_columns = [
93 "order_id",
94 "store_id",
95 "customer_id",
96 "customer_name",
97 "total_price",
98 "created",
99 "created_by",
100 ]
102 sort_defaults = ("order_id", "desc")
104 # pylint: disable=duplicate-code
105 form_fields = [
106 "order_id",
107 "store_id",
108 "customer_id",
109 "local_customer",
110 "pending_customer",
111 "customer_name",
112 "phone_number",
113 "email_address",
114 "total_price",
115 "created",
116 "created_by",
117 ]
118 # pylint: enable=duplicate-code
120 has_rows = True
121 row_model_class = OrderItem
122 rows_title = "Order Items"
123 rows_sort_defaults = "sequence"
124 rows_viewable = True
126 # pylint: disable=duplicate-code
127 row_labels = {
128 "product_scancode": "Scancode",
129 "product_brand": "Brand",
130 "product_description": "Description",
131 "product_size": "Size",
132 "department_name": "Department",
133 "order_uom": "Order UOM",
134 "status_code": "Status",
135 }
136 # pylint: enable=duplicate-code
138 # pylint: disable=duplicate-code
139 row_grid_columns = [
140 "sequence",
141 "product_scancode",
142 "product_brand",
143 "product_description",
144 "product_size",
145 "department_name",
146 "special_order",
147 "order_qty",
148 "order_uom",
149 "discount_percent",
150 "total_price",
151 "status_code",
152 ]
153 # pylint: enable=duplicate-code
155 # pylint: disable=duplicate-code
156 PENDING_PRODUCT_ENTRY_FIELDS = [
157 "scancode",
158 "brand_name",
159 "description",
160 "size",
161 "department_id",
162 "department_name",
163 "vendor_name",
164 "vendor_item_code",
165 "case_size",
166 "unit_cost",
167 "unit_price_reg",
168 ]
169 # pylint: enable=duplicate-code
171 def __init__(self, request, context=None):
172 super().__init__(request, context=context)
173 self.order_handler = self.app.get_order_handler()
174 self.batch_handler = self.app.get_batch_handler("neworder")
176 def configure_grid(self, grid): # pylint: disable=empty-docstring
177 """ """
178 g = grid
179 super().configure_grid(g)
181 # store_id
182 if not self.order_handler.expose_store_id():
183 g.remove("store_id")
185 # order_id
186 g.set_link("order_id")
188 # customer_id
189 g.set_link("customer_id")
191 # customer_name
192 g.set_link("customer_name")
194 # total_price
195 g.set_renderer("total_price", g.render_currency)
197 def create(self):
198 """
199 Instead of the typical "create" view, this displays a "wizard"
200 of sorts.
202 Under the hood a
203 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` is
204 automatically created for the user when they first visit this
205 page. They can select a customer, add items etc.
207 When user is finished assembling the order (i.e. populating
208 the batch), they submit it. This of course executes the
209 batch, which in turn creates a true
210 :class:`~sideshow.db.model.orders.Order`, and user is
211 redirected to the "view order" page.
213 See also these methods which may be called from this one,
214 based on user actions:
216 * :meth:`start_over()`
217 * :meth:`cancel_order()`
218 * :meth:`set_store()`
219 * :meth:`assign_customer()`
220 * :meth:`unassign_customer()`
221 * :meth:`set_pending_customer()`
222 * :meth:`get_product_info()`
223 * :meth:`add_item()`
224 * :meth:`update_item()`
225 * :meth:`delete_item()`
226 * :meth:`submit_order()`
227 """
228 model = self.app.model
229 session = self.Session()
230 batch = self.get_current_batch()
231 self.creating = True
233 context = self.get_context_customer(batch)
235 if self.request.method == "POST":
237 # first we check for traditional form post
238 action = self.request.POST.get("action")
239 post_actions = [
240 "start_over",
241 "cancel_order",
242 ]
243 if action in post_actions:
244 return getattr(self, action)(batch)
246 # okay then, we'll assume newer JSON-style post params
247 data = dict(self.request.json_body)
248 action = data.pop("action")
249 json_actions = [
250 "set_store",
251 "assign_customer",
252 "unassign_customer",
253 # 'update_phone_number',
254 # 'update_email_address',
255 "set_pending_customer",
256 # 'get_customer_info',
257 # # 'set_customer_data',
258 "get_product_info",
259 "get_past_products",
260 "add_item",
261 "update_item",
262 "delete_item",
263 "submit_order",
264 ]
265 if action in json_actions:
266 try:
267 result = getattr(self, action)(batch, data)
268 except Exception as error: # pylint: disable=broad-exception-caught
269 log.warning("error calling json action for order", exc_info=True)
270 result = {"error": self.app.render_error(error)}
271 return self.json_response(result)
273 return self.json_response({"error": "unknown form action"})
275 context.update(
276 {
277 "batch": batch,
278 "normalized_batch": self.normalize_batch(batch),
279 "order_items": [self.normalize_row(row) for row in batch.rows],
280 "default_uom_choices": self.batch_handler.get_default_uom_choices(),
281 "default_uom": None, # TODO?
282 "expose_store_id": self.order_handler.expose_store_id(),
283 "allow_item_discounts": self.batch_handler.allow_item_discounts(),
284 "allow_unknown_products": (
285 self.batch_handler.allow_unknown_products()
286 and self.has_perm("create_unknown_product")
287 ),
288 "pending_product_required_fields": self.get_pending_product_required_fields(),
289 "allow_past_item_reorder": True, # TODO: make configurable?
290 }
291 )
293 if context["expose_store_id"]:
294 stores = (
295 session.query(model.Store)
296 .filter(
297 model.Store.archived # pylint: disable=singleton-comparison
298 == False
299 )
300 .order_by(model.Store.store_id)
301 .all()
302 )
303 context["stores"] = [
304 {"store_id": store.store_id, "display": store.get_display()}
305 for store in stores
306 ]
308 # set default so things just work
309 if not batch.store_id:
310 batch.store_id = self.batch_handler.get_default_store_id()
312 if context["allow_item_discounts"]:
313 context["allow_item_discounts_if_on_sale"] = (
314 self.batch_handler.allow_item_discounts_if_on_sale()
315 )
316 # nb. render quantity so that '10.0' => '10'
317 context["default_item_discount"] = self.app.render_quantity(
318 self.batch_handler.get_default_item_discount()
319 )
320 context["dept_item_discounts"] = {
321 d["department_id"]: d["default_item_discount"]
322 for d in self.get_dept_item_discounts()
323 }
325 return self.render_to_response("create", context)
327 def get_current_batch(self):
328 """
329 Returns the current batch for the current user.
331 This looks for a new order batch which was created by the
332 user, but not yet executed. If none is found, a new batch is
333 created.
335 :returns:
336 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
337 instance
338 """
339 model = self.app.model
340 session = self.Session()
342 user = self.request.user
343 if not user:
344 raise self.forbidden()
346 try:
347 # there should be at most *one* new batch per user
348 batch = (
349 session.query(model.NewOrderBatch)
350 .filter(model.NewOrderBatch.created_by == user)
351 .filter(
352 model.NewOrderBatch.executed # pylint: disable=singleton-comparison
353 == None
354 )
355 .one()
356 )
358 except orm.exc.NoResultFound:
359 # no batch yet for this user, so make one
360 batch = self.batch_handler.make_batch(session, created_by=user)
361 session.add(batch)
362 session.flush()
364 return batch
366 def customer_autocomplete(self):
367 """
368 AJAX view for customer autocomplete, when entering new order.
370 This invokes one of the following on the
371 :attr:`batch_handler`:
373 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_customers_external()`
374 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_customers_local()`
376 :returns: List of search results; each should be a dict with
377 ``value`` and ``label`` keys.
378 """
379 session = self.Session()
380 term = self.request.GET.get("term", "").strip()
381 if not term:
382 return []
384 handler = self.batch_handler
385 if handler.use_local_customers():
386 return handler.autocomplete_customers_local(
387 session, term, user=self.request.user
388 )
389 return handler.autocomplete_customers_external(
390 session, term, user=self.request.user
391 )
393 def product_autocomplete(self):
394 """
395 AJAX view for product autocomplete, when entering new order.
397 This invokes one of the following on the
398 :attr:`batch_handler`:
400 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_products_external()`
401 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_products_local()`
403 :returns: List of search results; each should be a dict with
404 ``value`` and ``label`` keys.
405 """
406 session = self.Session()
407 term = self.request.GET.get("term", "").strip()
408 if not term:
409 return []
411 handler = self.batch_handler
412 if handler.use_local_products():
413 return handler.autocomplete_products_local(
414 session, term, user=self.request.user
415 )
416 return handler.autocomplete_products_external(
417 session, term, user=self.request.user
418 )
420 def get_pending_product_required_fields(self): # pylint: disable=empty-docstring
421 """ """
422 required = []
423 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
424 require = self.config.get_bool(
425 f"sideshow.orders.unknown_product.fields.{field}.required"
426 )
427 if require is None and field == "description":
428 require = True
429 if require:
430 required.append(field)
431 return required
433 def get_dept_item_discounts(self):
434 """
435 Returns the list of per-department default item discount settings.
437 Each entry in the list will look like::
439 {
440 'department_id': '42',
441 'department_name': 'Grocery',
442 'default_item_discount': 10,
443 }
445 :returns: List of department settings as shown above.
446 """
447 model = self.app.model
448 session = self.Session()
449 pattern = re.compile(
450 r"^sideshow\.orders\.departments\.([^.]+)\.default_item_discount$"
451 )
453 dept_item_discounts = []
454 settings = (
455 session.query(model.Setting)
456 .filter(
457 model.Setting.name.like(
458 "sideshow.orders.departments.%.default_item_discount"
459 )
460 )
461 .all()
462 )
463 for setting in settings:
464 match = pattern.match(setting.name)
465 if not match:
466 log.warning("invalid setting name: %s", setting.name)
467 continue
468 deptid = match.group(1)
469 name = self.app.get_setting(
470 session, f"sideshow.orders.departments.{deptid}.name"
471 )
472 dept_item_discounts.append(
473 {
474 "department_id": deptid,
475 "department_name": name,
476 "default_item_discount": setting.value,
477 }
478 )
479 dept_item_discounts.sort(key=lambda d: d["department_name"])
480 return dept_item_discounts
482 def start_over(self, batch):
483 """
484 This will delete the user's current batch, then redirect user
485 back to "Create Order" page, which in turn will auto-create a
486 new batch for them.
488 This is a "batch action" method which may be called from
489 :meth:`create()`. See also:
491 * :meth:`cancel_order()`
492 * :meth:`submit_order()`
493 """
494 session = self.Session()
496 # drop current batch
497 self.batch_handler.do_delete(batch, self.request.user)
498 session.flush()
500 # send back to "create order" which makes new batch
501 route_prefix = self.get_route_prefix()
502 url = self.request.route_url(f"{route_prefix}.create")
503 return self.redirect(url)
505 def cancel_order(self, batch):
506 """
507 This will delete the user's current batch, then redirect user
508 back to "List Orders" page.
510 This is a "batch action" method which may be called from
511 :meth:`create()`. See also:
513 * :meth:`start_over()`
514 * :meth:`submit_order()`
515 """
516 session = self.Session()
518 self.batch_handler.do_delete(batch, self.request.user)
519 session.flush()
521 # set flash msg just to be more obvious
522 self.request.session.flash("New order has been deleted.")
524 # send user back to orders list, w/ no new batch generated
525 url = self.get_index_url()
526 return self.redirect(url)
528 def set_store(self, batch, data):
529 """
530 Assign the
531 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`
532 for a batch.
534 This is a "batch action" method which may be called from
535 :meth:`create()`.
536 """
537 store_id = data.get("store_id")
538 if not store_id:
539 return {"error": "Must provide store_id"}
541 batch.store_id = store_id
542 return self.get_context_customer(batch)
544 def get_context_customer(self, batch): # pylint: disable=empty-docstring
545 """ """
546 context = {
547 "store_id": batch.store_id,
548 "customer_is_known": True,
549 "customer_id": None,
550 "customer_name": batch.customer_name,
551 "phone_number": batch.phone_number,
552 "email_address": batch.email_address,
553 }
555 # customer_id
556 use_local = self.batch_handler.use_local_customers()
557 if use_local:
558 local = batch.local_customer
559 if local:
560 context["customer_id"] = local.uuid.hex
561 else: # use external
562 context["customer_id"] = batch.customer_id
564 # pending customer
565 pending = batch.pending_customer
566 if pending:
567 context.update(
568 {
569 "new_customer_first_name": pending.first_name,
570 "new_customer_last_name": pending.last_name,
571 "new_customer_full_name": pending.full_name,
572 "new_customer_phone": pending.phone_number,
573 "new_customer_email": pending.email_address,
574 }
575 )
577 # declare customer "not known" only if pending is in use
578 if (
579 pending
580 and not batch.customer_id
581 and not batch.local_customer
582 and batch.customer_name
583 ):
584 context["customer_is_known"] = False
586 return context
588 def assign_customer(self, batch, data):
589 """
590 Assign the true customer account for a batch.
592 This calls
593 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
594 for the heavy lifting.
596 This is a "batch action" method which may be called from
597 :meth:`create()`. See also:
599 * :meth:`unassign_customer()`
600 * :meth:`set_pending_customer()`
601 """
602 customer_id = data.get("customer_id")
603 if not customer_id:
604 return {"error": "Must provide customer_id"}
606 self.batch_handler.set_customer(batch, customer_id)
607 return self.get_context_customer(batch)
609 def unassign_customer(self, batch, data): # pylint: disable=unused-argument
610 """
611 Clear the customer info for a batch.
613 This calls
614 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
615 for the heavy lifting.
617 This is a "batch action" method which may be called from
618 :meth:`create()`. See also:
620 * :meth:`assign_customer()`
621 * :meth:`set_pending_customer()`
622 """
623 self.batch_handler.set_customer(batch, None)
624 return self.get_context_customer(batch)
626 def set_pending_customer(self, batch, data):
627 """
628 This will set/update the batch pending customer info.
630 This calls
631 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
632 for the heavy lifting.
634 This is a "batch action" method which may be called from
635 :meth:`create()`. See also:
637 * :meth:`assign_customer()`
638 * :meth:`unassign_customer()`
639 """
640 self.batch_handler.set_customer(batch, data, user=self.request.user)
641 return self.get_context_customer(batch)
643 def get_product_info( # pylint: disable=unused-argument,too-many-branches
644 self, batch, data
645 ):
646 """
647 Fetch data for a specific product.
649 Depending on config, this calls one of the following to get
650 its primary data:
652 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_local()`
653 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()`
655 It then may supplement the data with additional fields.
657 This is a "batch action" method which may be called from
658 :meth:`create()`.
660 :returns: Dict of product info.
661 """
662 product_id = data.get("product_id")
663 if not product_id:
664 return {"error": "Must specify a product ID"}
666 session = self.Session()
667 use_local = self.batch_handler.use_local_products()
668 if use_local:
669 data = self.batch_handler.get_product_info_local(session, product_id)
670 else:
671 data = self.batch_handler.get_product_info_external(session, product_id)
673 if "error" in data:
674 return data
676 if "unit_price_reg" in data and "unit_price_reg_display" not in data:
677 data["unit_price_reg_display"] = self.app.render_currency(
678 data["unit_price_reg"]
679 )
681 if "unit_price_reg" in data and "unit_price_quoted" not in data:
682 data["unit_price_quoted"] = data["unit_price_reg"]
684 if "unit_price_quoted" in data and "unit_price_quoted_display" not in data:
685 data["unit_price_quoted_display"] = self.app.render_currency(
686 data["unit_price_quoted"]
687 )
689 if "case_price_quoted" not in data:
690 if (
691 data.get("unit_price_quoted") is not None
692 and data.get("case_size") is not None
693 ):
694 data["case_price_quoted"] = (
695 data["unit_price_quoted"] * data["case_size"]
696 )
698 if "case_price_quoted" in data and "case_price_quoted_display" not in data:
699 data["case_price_quoted_display"] = self.app.render_currency(
700 data["case_price_quoted"]
701 )
703 decimal_fields = [
704 "case_size",
705 "unit_price_reg",
706 "unit_price_quoted",
707 "case_price_quoted",
708 "default_item_discount",
709 ]
711 for field in decimal_fields:
712 if field in list(data):
713 value = data[field]
714 if isinstance(value, decimal.Decimal):
715 data[field] = float(value)
717 return data
719 def get_past_products(self, batch, data): # pylint: disable=unused-argument
720 """
721 Fetch past products for convenient re-ordering.
723 This essentially calls
724 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_past_products()`
725 on the :attr:`batch_handler` and returns the result.
727 This is a "batch action" method which may be called from
728 :meth:`create()`.
730 :returns: List of product info dicts.
731 """
732 past_products = self.batch_handler.get_past_products(batch)
733 return make_json_safe(past_products)
735 def add_item(self, batch, data):
736 """
737 This adds a row to the user's current new order batch.
739 This is a "batch action" method which may be called from
740 :meth:`create()`. See also:
742 * :meth:`update_item()`
743 * :meth:`delete_item()`
744 """
745 kw = {"user": self.request.user}
746 if "discount_percent" in data and self.batch_handler.allow_item_discounts():
747 kw["discount_percent"] = data["discount_percent"]
748 row = self.batch_handler.add_item(
749 batch, data["product_info"], data["order_qty"], data["order_uom"], **kw
750 )
752 return {"batch": self.normalize_batch(batch), "row": self.normalize_row(row)}
754 def update_item(self, batch, data):
755 """
756 This updates a row in the user's current new order batch.
758 This is a "batch action" method which may be called from
759 :meth:`create()`. See also:
761 * :meth:`add_item()`
762 * :meth:`delete_item()`
763 """
764 model = self.app.model
765 session = self.Session()
767 uuid = data.get("uuid")
768 if not uuid:
769 return {"error": "Must specify row UUID"}
771 row = session.get(model.NewOrderBatchRow, uuid)
772 if not row:
773 return {"error": "Row not found"}
775 if row.batch is not batch:
776 return {"error": "Row is for wrong batch"}
778 kw = {"user": self.request.user}
779 if "discount_percent" in data and self.batch_handler.allow_item_discounts():
780 kw["discount_percent"] = data["discount_percent"]
781 self.batch_handler.update_item(
782 row, data["product_info"], data["order_qty"], data["order_uom"], **kw
783 )
785 return {"batch": self.normalize_batch(batch), "row": self.normalize_row(row)}
787 def delete_item(self, batch, data):
788 """
789 This deletes a row from the user's current new order batch.
791 This is a "batch action" method which may be called from
792 :meth:`create()`. See also:
794 * :meth:`add_item()`
795 * :meth:`update_item()`
796 """
797 model = self.app.model
798 session = self.app.get_session(batch)
800 uuid = data.get("uuid")
801 if not uuid:
802 return {"error": "Must specify a row UUID"}
804 row = session.get(model.NewOrderBatchRow, uuid)
805 if not row:
806 return {"error": "Row not found"}
808 if row.batch is not batch:
809 return {"error": "Row is for wrong batch"}
811 self.batch_handler.do_remove_row(row)
812 return {"batch": self.normalize_batch(batch)}
814 def submit_order(self, batch, data): # pylint: disable=unused-argument
815 """
816 This submits the user's current new order batch, hence
817 executing the batch and creating the true order.
819 This is a "batch action" method which may be called from
820 :meth:`create()`. See also:
822 * :meth:`start_over()`
823 * :meth:`cancel_order()`
824 """
825 user = self.request.user
826 reason = self.batch_handler.why_not_execute(batch, user=user)
827 if reason:
828 return {"error": reason}
830 try:
831 order = self.batch_handler.do_execute(batch, user)
832 except Exception as error: # pylint: disable=broad-exception-caught
833 log.warning("failed to execute new order batch: %s", batch, exc_info=True)
834 return {"error": self.app.render_error(error)}
836 return {
837 "next_url": self.get_action_url("view", order),
838 }
840 def normalize_batch(self, batch): # pylint: disable=empty-docstring
841 """ """
842 return {
843 "uuid": batch.uuid.hex,
844 "total_price": str(batch.total_price or 0),
845 "total_price_display": self.app.render_currency(batch.total_price),
846 "status_code": batch.status_code,
847 "status_text": batch.status_text,
848 }
850 def normalize_row(self, row): # pylint: disable=empty-docstring
851 """ """
852 data = {
853 "uuid": row.uuid.hex,
854 "sequence": row.sequence,
855 "product_id": None,
856 "product_scancode": row.product_scancode,
857 "product_brand": row.product_brand,
858 "product_description": row.product_description,
859 "product_size": row.product_size,
860 "product_full_description": self.app.make_full_name(
861 row.product_brand, row.product_description, row.product_size
862 ),
863 "product_weighed": row.product_weighed,
864 "department_id": row.department_id,
865 "department_name": row.department_name,
866 "special_order": row.special_order,
867 "vendor_name": row.vendor_name,
868 "vendor_item_code": row.vendor_item_code,
869 "case_size": float(row.case_size) if row.case_size is not None else None,
870 "order_qty": float(row.order_qty),
871 "order_uom": row.order_uom,
872 "discount_percent": self.app.render_quantity(row.discount_percent),
873 "unit_price_quoted": (
874 float(row.unit_price_quoted)
875 if row.unit_price_quoted is not None
876 else None
877 ),
878 "unit_price_quoted_display": self.app.render_currency(
879 row.unit_price_quoted
880 ),
881 "case_price_quoted": (
882 float(row.case_price_quoted)
883 if row.case_price_quoted is not None
884 else None
885 ),
886 "case_price_quoted_display": self.app.render_currency(
887 row.case_price_quoted
888 ),
889 "total_price": (
890 float(row.total_price) if row.total_price is not None else None
891 ),
892 "total_price_display": self.app.render_currency(row.total_price),
893 "status_code": row.status_code,
894 "status_text": row.status_text,
895 }
897 use_local = self.batch_handler.use_local_products()
899 # product_id
900 if use_local:
901 if row.local_product:
902 data["product_id"] = row.local_product.uuid.hex
903 else:
904 data["product_id"] = row.product_id
906 # vendor_name
907 if use_local:
908 if row.local_product:
909 data["vendor_name"] = row.local_product.vendor_name
910 else: # use external
911 pass # TODO
912 if not data.get("product_id") and row.pending_product:
913 data["vendor_name"] = row.pending_product.vendor_name
915 if row.unit_price_reg:
916 data["unit_price_reg"] = float(row.unit_price_reg)
917 data["unit_price_reg_display"] = self.app.render_currency(
918 row.unit_price_reg
919 )
921 if row.unit_price_sale:
922 data["unit_price_sale"] = float(row.unit_price_sale)
923 data["unit_price_sale_display"] = self.app.render_currency(
924 row.unit_price_sale
925 )
926 if row.sale_ends:
927 data["sale_ends"] = str(row.sale_ends)
928 data["sale_ends_display"] = self.app.render_date(row.sale_ends)
930 if row.pending_product:
931 pending = row.pending_product
932 data["pending_product"] = {
933 "uuid": pending.uuid.hex,
934 "scancode": pending.scancode,
935 "brand_name": pending.brand_name,
936 "description": pending.description,
937 "size": pending.size,
938 "department_id": pending.department_id,
939 "department_name": pending.department_name,
940 "unit_price_reg": (
941 float(pending.unit_price_reg)
942 if pending.unit_price_reg is not None
943 else None
944 ),
945 "vendor_name": pending.vendor_name,
946 "vendor_item_code": pending.vendor_item_code,
947 "unit_cost": (
948 float(pending.unit_cost) if pending.unit_cost is not None else None
949 ),
950 "case_size": (
951 float(pending.case_size) if pending.case_size is not None else None
952 ),
953 "notes": pending.notes,
954 "special_order": pending.special_order,
955 }
957 # display text for order qty/uom
958 data["order_qty_display"] = self.order_handler.get_order_qty_uom_text(
959 row.order_qty, row.order_uom, case_size=row.case_size, html=True
960 )
962 return data
964 def get_instance_title(self, instance): # pylint: disable=empty-docstring
965 """ """
966 order = instance
967 return f"#{order.order_id} for {order.customer_name}"
969 def configure_form(self, form): # pylint: disable=empty-docstring
970 """ """
971 f = form
972 super().configure_form(f)
973 order = f.model_instance
975 # store_id
976 if not self.order_handler.expose_store_id():
977 f.remove("store_id")
979 # local_customer
980 if order.customer_id and not order.local_customer:
981 f.remove("local_customer")
982 else:
983 f.set_node("local_customer", LocalCustomerRef(self.request))
985 # pending_customer
986 if order.customer_id or order.local_customer:
987 f.remove("pending_customer")
988 else:
989 f.set_node("pending_customer", PendingCustomerRef(self.request))
991 # total_price
992 f.set_node("total_price", WuttaMoney(self.request))
994 # created_by
995 f.set_node("created_by", UserRef(self.request))
996 f.set_readonly("created_by")
998 def get_xref_buttons(self, obj): # pylint: disable=empty-docstring
999 """ """
1000 order = obj
1001 buttons = super().get_xref_buttons(order)
1002 model = self.app.model
1003 session = self.Session()
1005 if self.request.has_perm("neworder_batches.view"):
1006 batch = (
1007 session.query(model.NewOrderBatch)
1008 .filter(model.NewOrderBatch.id == order.order_id)
1009 .first()
1010 )
1011 if batch:
1012 url = self.request.route_url("neworder_batches.view", uuid=batch.uuid)
1013 buttons.append(
1014 self.make_button(
1015 "View the Batch", primary=True, icon_left="eye", url=url
1016 )
1017 )
1019 return buttons
1021 def get_row_grid_data(self, obj): # pylint: disable=empty-docstring
1022 """ """
1023 order = obj
1024 model = self.app.model
1025 session = self.Session()
1026 return session.query(model.OrderItem).filter(model.OrderItem.order == order)
1028 def get_row_parent(self, row): # pylint: disable=empty-docstring
1029 """ """
1030 item = row
1031 return item.order
1033 def configure_row_grid(self, grid): # pylint: disable=empty-docstring
1034 """ """
1035 g = grid
1036 super().configure_row_grid(g)
1037 # enum = self.app.enum
1039 # sequence
1040 g.set_label("sequence", "Seq.", column_only=True)
1041 g.set_link("sequence")
1043 # product_scancode
1044 g.set_link("product_scancode")
1046 # product_brand
1047 g.set_link("product_brand")
1049 # product_description
1050 g.set_link("product_description")
1052 # product_size
1053 g.set_link("product_size")
1055 # TODO
1056 # order_uom
1057 # g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
1059 # discount_percent
1060 g.set_renderer("discount_percent", "percent")
1061 g.set_label("discount_percent", "Disc. %", column_only=True)
1063 # total_price
1064 g.set_renderer("total_price", g.render_currency)
1066 # status_code
1067 g.set_renderer("status_code", self.render_status_code)
1069 # TODO: upstream should set this automatically
1070 g.row_class = self.row_grid_row_class
1072 def row_grid_row_class( # pylint: disable=unused-argument,empty-docstring
1073 self, item, data, i
1074 ):
1075 """ """
1076 variant = self.order_handler.item_status_to_variant(item.status_code)
1077 if variant:
1078 return f"has-background-{variant}"
1079 return None
1081 def render_status_code( # pylint: disable=unused-argument,empty-docstring
1082 self, item, key, value
1083 ):
1084 """ """
1085 enum = self.app.enum
1086 return enum.ORDER_ITEM_STATUS[value]
1088 def get_row_action_url_view(self, row, i): # pylint: disable=empty-docstring
1089 """ """
1090 item = row
1091 return self.request.route_url("order_items.view", uuid=item.uuid)
1093 def configure_get_simple_settings(self): # pylint: disable=empty-docstring
1094 """ """
1095 settings = [
1096 # stores
1097 {"name": "sideshow.orders.expose_store_id", "type": bool},
1098 {"name": "sideshow.orders.default_store_id"},
1099 # customers
1100 {
1101 "name": "sideshow.orders.use_local_customers",
1102 # nb. this is really a bool but we present as string in config UI
1103 #'type': bool,
1104 "default": "true",
1105 },
1106 # products
1107 {
1108 "name": "sideshow.orders.use_local_products",
1109 # nb. this is really a bool but we present as string in config UI
1110 #'type': bool,
1111 "default": "true",
1112 },
1113 {
1114 "name": "sideshow.orders.allow_unknown_products",
1115 "type": bool,
1116 "default": True,
1117 },
1118 # pricing
1119 {"name": "sideshow.orders.allow_item_discounts", "type": bool},
1120 {"name": "sideshow.orders.allow_item_discounts_if_on_sale", "type": bool},
1121 {"name": "sideshow.orders.default_item_discount", "type": float},
1122 # batches
1123 {"name": "wutta.batch.neworder.handler.spec"},
1124 ]
1126 # required fields for new product entry
1127 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
1128 setting = {
1129 "name": f"sideshow.orders.unknown_product.fields.{field}.required",
1130 "type": bool,
1131 }
1132 if field == "description":
1133 setting["default"] = True
1134 settings.append(setting)
1136 return settings
1138 def configure_get_context( # pylint: disable=empty-docstring,arguments-differ
1139 self, **kwargs
1140 ):
1141 """ """
1142 context = super().configure_get_context(**kwargs)
1144 context["pending_product_fields"] = self.PENDING_PRODUCT_ENTRY_FIELDS
1146 handlers = self.app.get_batch_handler_specs("neworder")
1147 handlers = [{"spec": spec} for spec in handlers]
1148 context["batch_handlers"] = handlers
1150 context["dept_item_discounts"] = self.get_dept_item_discounts()
1152 return context
1154 def configure_gather_settings(
1155 self, data, simple_settings=None
1156 ): # pylint: disable=empty-docstring
1157 """ """
1158 settings = super().configure_gather_settings(
1159 data, simple_settings=simple_settings
1160 )
1162 for dept in json.loads(data["dept_item_discounts"]):
1163 deptid = dept["department_id"]
1164 settings.append(
1165 {
1166 "name": f"sideshow.orders.departments.{deptid}.name",
1167 "value": dept["department_name"],
1168 }
1169 )
1170 settings.append(
1171 {
1172 "name": f"sideshow.orders.departments.{deptid}.default_item_discount",
1173 "value": dept["default_item_discount"],
1174 }
1175 )
1177 return settings
1179 def configure_remove_settings( # pylint: disable=empty-docstring,arguments-differ
1180 self, **kwargs
1181 ):
1182 """ """
1183 model = self.app.model
1184 session = self.Session()
1186 super().configure_remove_settings(**kwargs)
1188 to_delete = (
1189 session.query(model.Setting)
1190 .filter(
1191 sa.or_(
1192 model.Setting.name.like("sideshow.orders.departments.%.name"),
1193 model.Setting.name.like(
1194 "sideshow.orders.departments.%.default_item_discount"
1195 ),
1196 )
1197 )
1198 .all()
1199 )
1200 for setting in to_delete:
1201 self.app.delete_setting(session, setting.name)
1203 @classmethod
1204 def defaults(cls, config):
1205 cls._order_defaults(config)
1206 cls._defaults(config)
1208 @classmethod
1209 def _order_defaults(cls, config):
1210 route_prefix = cls.get_route_prefix()
1211 permission_prefix = cls.get_permission_prefix()
1212 url_prefix = cls.get_url_prefix()
1213 model_title = cls.get_model_title()
1214 model_title_plural = cls.get_model_title_plural()
1216 # fix perm group
1217 config.add_wutta_permission_group(
1218 permission_prefix, model_title_plural, overwrite=False
1219 )
1221 # extra perm required to create order with unknown/pending product
1222 config.add_wutta_permission(
1223 permission_prefix,
1224 f"{permission_prefix}.create_unknown_product",
1225 f"Create new {model_title} for unknown/pending product",
1226 )
1228 # customer autocomplete
1229 config.add_route(
1230 f"{route_prefix}.customer_autocomplete",
1231 f"{url_prefix}/customer-autocomplete",
1232 request_method="GET",
1233 )
1234 config.add_view(
1235 cls,
1236 attr="customer_autocomplete",
1237 route_name=f"{route_prefix}.customer_autocomplete",
1238 renderer="json",
1239 permission=f"{permission_prefix}.list",
1240 )
1242 # product autocomplete
1243 config.add_route(
1244 f"{route_prefix}.product_autocomplete",
1245 f"{url_prefix}/product-autocomplete",
1246 request_method="GET",
1247 )
1248 config.add_view(
1249 cls,
1250 attr="product_autocomplete",
1251 route_name=f"{route_prefix}.product_autocomplete",
1252 renderer="json",
1253 permission=f"{permission_prefix}.list",
1254 )
1257class OrderItemView(MasterView): # pylint: disable=abstract-method
1258 """
1259 Master view for :class:`~sideshow.db.model.orders.OrderItem`;
1260 route prefix is ``order_items``.
1262 Notable URLs provided by this class:
1264 * ``/order-items/``
1265 * ``/order-items/XXX``
1267 This class serves both as a proper master view (for "all" order
1268 items) as well as a base class for other "workflow" master views,
1269 each of which auto-filters by order item status:
1271 * :class:`PlacementView`
1272 * :class:`ReceivingView`
1273 * :class:`ContactView`
1274 * :class:`DeliveryView`
1276 Note that this does not expose create, edit or delete. The user
1277 must perform various other workflow actions to modify the item.
1279 .. attribute:: order_handler
1281 Reference to the :term:`order handler` as returned by
1282 :meth:`get_order_handler()`.
1283 """
1285 model_class = OrderItem
1286 model_title = "Order Item (All)"
1287 model_title_plural = "Order Items (All)"
1288 route_prefix = "order_items"
1289 url_prefix = "/order-items"
1290 creatable = False
1291 editable = False
1292 deletable = False
1294 labels = {
1295 "order_id": "Order ID",
1296 "store_id": "Store ID",
1297 "product_id": "Product ID",
1298 "product_scancode": "Scancode",
1299 "product_brand": "Brand",
1300 "product_description": "Description",
1301 "product_size": "Size",
1302 "product_weighed": "Sold by Weight",
1303 "department_id": "Department ID",
1304 "order_uom": "Order UOM",
1305 "status_code": "Status",
1306 }
1308 grid_columns = [
1309 "order_id",
1310 "store_id",
1311 "customer_name",
1312 # 'sequence',
1313 "product_scancode",
1314 "product_brand",
1315 "product_description",
1316 "product_size",
1317 "department_name",
1318 "special_order",
1319 "order_qty",
1320 "order_uom",
1321 "total_price",
1322 "status_code",
1323 ]
1325 sort_defaults = ("order_id", "desc")
1327 # pylint: disable=duplicate-code
1328 form_fields = [
1329 "order",
1330 # 'customer_name',
1331 "sequence",
1332 "product_id",
1333 "local_product",
1334 "pending_product",
1335 "product_scancode",
1336 "product_brand",
1337 "product_description",
1338 "product_size",
1339 "product_weighed",
1340 "department_id",
1341 "department_name",
1342 "special_order",
1343 "case_size",
1344 "unit_cost",
1345 "unit_price_reg",
1346 "unit_price_sale",
1347 "sale_ends",
1348 "unit_price_quoted",
1349 "case_price_quoted",
1350 "order_qty",
1351 "order_uom",
1352 "discount_percent",
1353 "total_price",
1354 "status_code",
1355 "paid_amount",
1356 "payment_transaction_number",
1357 ]
1358 # pylint: enable=duplicate-code
1360 def __init__(self, request, context=None):
1361 super().__init__(request, context=context)
1362 self.order_handler = self.app.get_order_handler()
1364 def get_fallback_templates(self, template): # pylint: disable=empty-docstring
1365 """ """
1366 templates = super().get_fallback_templates(template)
1367 templates.insert(0, f"/order-items/{template}.mako")
1368 return templates
1370 def get_query(self, session=None): # pylint: disable=empty-docstring
1371 """ """
1372 query = super().get_query(session=session)
1373 model = self.app.model
1374 return query.join(model.Order)
1376 def configure_grid(self, grid): # pylint: disable=empty-docstring
1377 """ """
1378 g = grid
1379 super().configure_grid(g)
1380 model = self.app.model
1381 # enum = self.app.enum
1383 # store_id
1384 if not self.order_handler.expose_store_id():
1385 g.remove("store_id")
1387 # order_id
1388 g.set_sorter("order_id", model.Order.order_id)
1389 g.set_renderer("order_id", self.render_order_attr)
1390 g.set_link("order_id")
1392 # store_id
1393 g.set_sorter("store_id", model.Order.store_id)
1394 g.set_renderer("store_id", self.render_order_attr)
1396 # customer_name
1397 g.set_label("customer_name", "Customer", column_only=True)
1398 g.set_renderer("customer_name", self.render_order_attr)
1399 g.set_sorter("customer_name", model.Order.customer_name)
1400 g.set_filter("customer_name", model.Order.customer_name)
1402 # # sequence
1403 # g.set_label('sequence', "Seq.", column_only=True)
1405 # product_scancode
1406 g.set_link("product_scancode")
1408 # product_brand
1409 g.set_link("product_brand")
1411 # product_description
1412 g.set_link("product_description")
1414 # product_size
1415 g.set_link("product_size")
1417 # order_uom
1418 # TODO
1419 # g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
1421 # total_price
1422 g.set_renderer("total_price", g.render_currency)
1424 # status_code
1425 g.set_renderer("status_code", self.render_status_code)
1427 def render_order_attr( # pylint: disable=unused-argument,empty-docstring
1428 self, item, key, value
1429 ):
1430 """ """
1431 order = item.order
1432 return getattr(order, key)
1434 def render_status_code( # pylint: disable=unused-argument,empty-docstring
1435 self, item, key, value
1436 ):
1437 """ """
1438 enum = self.app.enum
1439 return enum.ORDER_ITEM_STATUS[value]
1441 def grid_row_class( # pylint: disable=unused-argument,empty-docstring
1442 self, item, data, i
1443 ):
1444 """ """
1445 variant = self.order_handler.item_status_to_variant(item.status_code)
1446 if variant:
1447 return f"has-background-{variant}"
1448 return None
1450 def configure_form(self, form): # pylint: disable=empty-docstring
1451 """ """
1452 f = form
1453 super().configure_form(f)
1454 enum = self.app.enum
1455 item = f.model_instance
1457 # order
1458 f.set_node("order", OrderRef(self.request))
1460 # local_product
1461 f.set_node("local_product", LocalProductRef(self.request))
1463 # pending_product
1464 if item.product_id or item.local_product:
1465 f.remove("pending_product")
1466 else:
1467 f.set_node("pending_product", PendingProductRef(self.request))
1469 # order_qty
1470 f.set_node("order_qty", WuttaQuantity(self.request))
1472 # order_uom
1473 f.set_node("order_uom", WuttaDictEnum(self.request, enum.ORDER_UOM))
1475 # case_size
1476 f.set_node("case_size", WuttaQuantity(self.request))
1478 # unit_cost
1479 f.set_node("unit_cost", WuttaMoney(self.request, scale=4))
1481 # unit_price_reg
1482 f.set_node("unit_price_reg", WuttaMoney(self.request))
1484 # unit_price_quoted
1485 f.set_node("unit_price_quoted", WuttaMoney(self.request))
1487 # case_price_quoted
1488 f.set_node("case_price_quoted", WuttaMoney(self.request))
1490 # total_price
1491 f.set_node("total_price", WuttaMoney(self.request))
1493 # status
1494 f.set_node("status_code", WuttaDictEnum(self.request, enum.ORDER_ITEM_STATUS))
1496 # paid_amount
1497 f.set_node("paid_amount", WuttaMoney(self.request))
1499 def get_template_context(self, context): # pylint: disable=empty-docstring
1500 """ """
1501 if self.viewing:
1502 model = self.app.model
1503 enum = self.app.enum
1504 route_prefix = self.get_route_prefix()
1505 item = context["instance"]
1506 form = context["form"]
1508 context["expose_store_id"] = self.order_handler.expose_store_id()
1510 context["item"] = item
1511 context["order"] = item.order
1512 context["order_qty_uom_text"] = self.order_handler.get_order_qty_uom_text(
1513 item.order_qty, item.order_uom, case_size=item.case_size, html=True
1514 )
1515 context["item_status_variant"] = self.order_handler.item_status_to_variant(
1516 item.status_code
1517 )
1519 grid = self.make_grid(
1520 key=f"{route_prefix}.view.events",
1521 model_class=model.OrderItemEvent,
1522 data=item.events,
1523 columns=[
1524 "occurred",
1525 "actor",
1526 "type_code",
1527 "note",
1528 ],
1529 labels={
1530 "occurred": "Date/Time",
1531 "actor": "User",
1532 "type_code": "Event Type",
1533 },
1534 )
1535 grid.set_renderer("type_code", lambda e, k, v: enum.ORDER_ITEM_EVENT[v])
1536 grid.set_renderer("note", self.render_event_note)
1537 if self.request.has_perm("users.view"):
1538 grid.set_renderer(
1539 "actor",
1540 lambda e, k, v: tags.link_to(
1541 e.actor, self.request.route_url("users.view", uuid=e.actor.uuid)
1542 ),
1543 )
1544 form.add_grid_vue_context(grid)
1545 context["events_grid"] = grid
1547 return context
1549 def render_event_note( # pylint: disable=unused-argument,empty-docstring
1550 self, event, key, value
1551 ):
1552 """ """
1553 enum = self.app.enum
1554 if event.type_code == enum.ORDER_ITEM_EVENT_NOTE_ADDED:
1555 return HTML.tag(
1556 "span",
1557 class_="has-background-info-light",
1558 style="padding: 0.25rem 0.5rem;",
1559 c=[value],
1560 )
1561 return value
1563 def get_xref_buttons(self, obj): # pylint: disable=empty-docstring
1564 """ """
1565 item = obj
1566 buttons = super().get_xref_buttons(item)
1568 if self.request.has_perm("orders.view"):
1569 url = self.request.route_url("orders.view", uuid=item.order_uuid)
1570 buttons.append(
1571 self.make_button(
1572 "View the Order", url=url, primary=True, icon_left="eye"
1573 )
1574 )
1576 return buttons
1578 def add_note(self):
1579 """
1580 View which adds a note to an order item. This is POST-only;
1581 will redirect back to the item view.
1582 """
1583 enum = self.app.enum
1584 item = self.get_instance()
1586 item.add_event(
1587 enum.ORDER_ITEM_EVENT_NOTE_ADDED,
1588 self.request.user,
1589 note=self.request.POST["note"],
1590 )
1592 return self.redirect(self.get_action_url("view", item))
1594 def change_status(self):
1595 """
1596 View which changes status for an order item. This is
1597 POST-only; will redirect back to the item view.
1598 """
1599 enum = self.app.enum
1600 main_item = self.get_instance()
1601 redirect = self.redirect(self.get_action_url("view", main_item))
1603 extra_note = self.request.POST.get("note")
1605 # validate new status
1606 new_status_code = int(self.request.POST["new_status"])
1607 if new_status_code not in enum.ORDER_ITEM_STATUS:
1608 self.request.session.flash("Invalid status code", "error")
1609 return redirect
1610 new_status_text = enum.ORDER_ITEM_STATUS[new_status_code]
1612 # locate all items to which new status will be applied
1613 items = [main_item]
1614 # uuids = self.request.POST.get('uuids')
1615 # if uuids:
1616 # for uuid in uuids.split(','):
1617 # item = Session.get(model.OrderItem, uuid)
1618 # if item:
1619 # items.append(item)
1621 # update item(s)
1622 for item in items:
1623 if item.status_code != new_status_code:
1625 # event: change status
1626 note = (
1627 f'status changed from "{enum.ORDER_ITEM_STATUS[item.status_code]}" '
1628 f'to "{new_status_text}"'
1629 )
1630 item.add_event(
1631 enum.ORDER_ITEM_EVENT_STATUS_CHANGE, self.request.user, note=note
1632 )
1634 # event: add note
1635 if extra_note:
1636 item.add_event(
1637 enum.ORDER_ITEM_EVENT_NOTE_ADDED,
1638 self.request.user,
1639 note=extra_note,
1640 )
1642 # new status
1643 item.status_code = new_status_code
1645 self.request.session.flash(f"Status has been updated to: {new_status_text}")
1646 return redirect
1648 def get_order_items(self, uuids):
1649 """
1650 This method provides common logic to fetch a list of order
1651 items based on a list of UUID keys. It is used by various
1652 workflow action methods.
1654 Note that if no order items are found, this will set a flash
1655 warning message and raise a redirect back to the index page.
1657 :param uuids: List (or comma-delimited string) of UUID keys.
1659 :returns: List of :class:`~sideshow.db.model.orders.OrderItem`
1660 records.
1661 """
1662 model = self.app.model
1663 session = self.Session()
1665 if uuids is None:
1666 uuids = []
1667 elif isinstance(uuids, str):
1668 uuids = uuids.split(",")
1670 items = []
1671 for uuid in uuids:
1672 if isinstance(uuid, str):
1673 uuid = uuid.strip()
1674 if uuid:
1675 try:
1676 item = session.get(model.OrderItem, uuid)
1677 except sa.exc.StatementError:
1678 pass # nb. invalid UUID
1679 else:
1680 if item:
1681 items.append(item)
1683 if not items:
1684 self.request.session.flash("Must specify valid order item(s).", "warning")
1685 raise self.redirect(self.get_index_url())
1687 return items
1689 @classmethod
1690 def defaults(cls, config): # pylint: disable=empty-docstring
1691 """ """
1692 cls._order_item_defaults(config)
1693 cls._defaults(config)
1695 @classmethod
1696 def _order_item_defaults(cls, config):
1697 """ """
1698 route_prefix = cls.get_route_prefix()
1699 permission_prefix = cls.get_permission_prefix()
1700 instance_url_prefix = cls.get_instance_url_prefix()
1701 model_title = cls.get_model_title()
1702 model_title_plural = cls.get_model_title_plural()
1704 # fix perm group
1705 config.add_wutta_permission_group(
1706 permission_prefix, model_title_plural, overwrite=False
1707 )
1709 # add note
1710 config.add_route(
1711 f"{route_prefix}.add_note",
1712 f"{instance_url_prefix}/add_note",
1713 request_method="POST",
1714 )
1715 config.add_view(
1716 cls,
1717 attr="add_note",
1718 route_name=f"{route_prefix}.add_note",
1719 renderer="json",
1720 permission=f"{permission_prefix}.add_note",
1721 )
1722 config.add_wutta_permission(
1723 permission_prefix,
1724 f"{permission_prefix}.add_note",
1725 f"Add note for {model_title}",
1726 )
1728 # change status
1729 config.add_route(
1730 f"{route_prefix}.change_status",
1731 f"{instance_url_prefix}/change-status",
1732 request_method="POST",
1733 )
1734 config.add_view(
1735 cls,
1736 attr="change_status",
1737 route_name=f"{route_prefix}.change_status",
1738 renderer="json",
1739 permission=f"{permission_prefix}.change_status",
1740 )
1741 config.add_wutta_permission(
1742 permission_prefix,
1743 f"{permission_prefix}.change_status",
1744 f"Change status for {model_title}",
1745 )
1748class PlacementView(OrderItemView): # pylint: disable=abstract-method
1749 """
1750 Master view for the "placement" phase of
1751 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1752 ``placement``. This is a subclass of :class:`OrderItemView`.
1754 This class auto-filters so only order items with the following
1755 status codes are shown:
1757 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY`
1759 Notable URLs provided by this class:
1761 * ``/placement/``
1762 * ``/placement/XXX``
1763 """
1765 model_title = "Order Item (Placement)"
1766 model_title_plural = "Order Items (Placement)"
1767 route_prefix = "order_items_placement"
1768 url_prefix = "/placement"
1770 grid_columns = [
1771 "order_id",
1772 "store_id",
1773 "customer_name",
1774 "product_brand",
1775 "product_description",
1776 "product_size",
1777 "department_name",
1778 "special_order",
1779 "vendor_name",
1780 "vendor_item_code",
1781 "order_qty",
1782 "order_uom",
1783 "total_price",
1784 ]
1786 filter_defaults = {
1787 "vendor_name": {"active": True},
1788 }
1790 def get_query(self, session=None): # pylint: disable=empty-docstring
1791 """ """
1792 query = super().get_query(session=session)
1793 model = self.app.model
1794 enum = self.app.enum
1795 return query.filter(model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_READY)
1797 def configure_grid(self, grid): # pylint: disable=empty-docstring
1798 """ """
1799 g = grid
1800 super().configure_grid(g)
1802 # checkable
1803 if self.has_perm("process_placement"):
1804 g.checkable = True
1806 # tool button: Order Placed
1807 if self.has_perm("process_placement"):
1808 button = self.make_button(
1809 "Order Placed",
1810 primary=True,
1811 icon_left="arrow-circle-right",
1812 **{
1813 "@click": "$emit('process-placement', checkedRows)",
1814 ":disabled": "!checkedRows.length",
1815 },
1816 )
1817 g.add_tool(button, key="process_placement")
1819 def process_placement(self):
1820 """
1821 View to process the "placement" step for some order item(s).
1823 This requires a POST request with data:
1825 :param item_uuids: Comma-delimited list of
1826 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1828 :param vendor_name: Optional name of vendor.
1830 :param po_number: Optional PO number.
1832 :param note: Optional note text from the user.
1834 This invokes
1835 :meth:`~sideshow.orders.OrderHandler.process_placement()` on
1836 the :attr:`~OrderItemView.order_handler`, then redirects user
1837 back to the index page.
1838 """
1839 items = self.get_order_items(self.request.POST.get("item_uuids", ""))
1840 vendor_name = self.request.POST.get("vendor_name", "").strip() or None
1841 po_number = self.request.POST.get("po_number", "").strip() or None
1842 note = self.request.POST.get("note", "").strip() or None
1844 self.order_handler.process_placement(
1845 items,
1846 self.request.user,
1847 vendor_name=vendor_name,
1848 po_number=po_number,
1849 note=note,
1850 )
1852 self.request.session.flash(f"{len(items)} Order Items were marked as placed")
1853 return self.redirect(self.get_index_url())
1855 @classmethod
1856 def defaults(cls, config):
1857 cls._order_item_defaults(config)
1858 cls._placement_defaults(config)
1859 cls._defaults(config)
1861 @classmethod
1862 def _placement_defaults(cls, config):
1863 route_prefix = cls.get_route_prefix()
1864 permission_prefix = cls.get_permission_prefix()
1865 url_prefix = cls.get_url_prefix()
1866 model_title_plural = cls.get_model_title_plural()
1868 # process placement
1869 config.add_wutta_permission(
1870 permission_prefix,
1871 f"{permission_prefix}.process_placement",
1872 f"Process placement for {model_title_plural}",
1873 )
1874 config.add_route(
1875 f"{route_prefix}.process_placement",
1876 f"{url_prefix}/process-placement",
1877 request_method="POST",
1878 )
1879 config.add_view(
1880 cls,
1881 attr="process_placement",
1882 route_name=f"{route_prefix}.process_placement",
1883 permission=f"{permission_prefix}.process_placement",
1884 )
1887class ReceivingView(OrderItemView): # pylint: disable=abstract-method
1888 """
1889 Master view for the "receiving" phase of
1890 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1891 ``receiving``. This is a subclass of :class:`OrderItemView`.
1893 This class auto-filters so only order items with the following
1894 status codes are shown:
1896 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_PLACED`
1898 Notable URLs provided by this class:
1900 * ``/receiving/``
1901 * ``/receiving/XXX``
1902 """
1904 model_title = "Order Item (Receiving)"
1905 model_title_plural = "Order Items (Receiving)"
1906 route_prefix = "order_items_receiving"
1907 url_prefix = "/receiving"
1909 grid_columns = [
1910 "order_id",
1911 "store_id",
1912 "customer_name",
1913 "product_brand",
1914 "product_description",
1915 "product_size",
1916 "department_name",
1917 "special_order",
1918 "vendor_name",
1919 "vendor_item_code",
1920 "order_qty",
1921 "order_uom",
1922 "total_price",
1923 ]
1925 filter_defaults = {
1926 "vendor_name": {"active": True},
1927 }
1929 def get_query(self, session=None): # pylint: disable=empty-docstring
1930 """ """
1931 query = super().get_query(session=session)
1932 model = self.app.model
1933 enum = self.app.enum
1934 return query.filter(
1935 model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_PLACED
1936 )
1938 def configure_grid(self, grid): # pylint: disable=empty-docstring
1939 """ """
1940 g = grid
1941 super().configure_grid(g)
1943 # checkable
1944 if self.has_any_perm("process_receiving", "process_reorder"):
1945 g.checkable = True
1947 # tool button: Received
1948 if self.has_perm("process_receiving"):
1949 button = self.make_button(
1950 "Received",
1951 primary=True,
1952 icon_left="arrow-circle-right",
1953 **{
1954 "@click": "$emit('process-receiving', checkedRows)",
1955 ":disabled": "!checkedRows.length",
1956 },
1957 )
1958 g.add_tool(button, key="process_receiving")
1960 # tool button: Re-Order
1961 if self.has_perm("process_reorder"):
1962 button = self.make_button(
1963 "Re-Order",
1964 icon_left="redo",
1965 **{
1966 "@click": "$emit('process-reorder', checkedRows)",
1967 ":disabled": "!checkedRows.length",
1968 },
1969 )
1970 g.add_tool(button, key="process_reorder")
1972 def process_receiving(self):
1973 """
1974 View to process the "receiving" step for some order item(s).
1976 This requires a POST request with data:
1978 :param item_uuids: Comma-delimited list of
1979 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1981 :param vendor_name: Optional name of vendor.
1983 :param invoice_number: Optional invoice number.
1985 :param po_number: Optional PO number.
1987 :param note: Optional note text from the user.
1989 This invokes
1990 :meth:`~sideshow.orders.OrderHandler.process_receiving()` on
1991 the :attr:`~OrderItemView.order_handler`, then redirects user
1992 back to the index page.
1993 """
1994 items = self.get_order_items(self.request.POST.get("item_uuids", ""))
1995 vendor_name = self.request.POST.get("vendor_name", "").strip() or None
1996 invoice_number = self.request.POST.get("invoice_number", "").strip() or None
1997 po_number = self.request.POST.get("po_number", "").strip() or None
1998 note = self.request.POST.get("note", "").strip() or None
2000 self.order_handler.process_receiving(
2001 items,
2002 self.request.user,
2003 vendor_name=vendor_name,
2004 invoice_number=invoice_number,
2005 po_number=po_number,
2006 note=note,
2007 )
2009 self.request.session.flash(f"{len(items)} Order Items were marked as received")
2010 return self.redirect(self.get_index_url())
2012 def process_reorder(self):
2013 """
2014 View to process the "reorder" step for some order item(s).
2016 This requires a POST request with data:
2018 :param item_uuids: Comma-delimited list of
2019 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
2021 :param note: Optional note text from the user.
2023 This invokes
2024 :meth:`~sideshow.orders.OrderHandler.process_reorder()` on the
2025 :attr:`~OrderItemView.order_handler`, then redirects user back
2026 to the index page.
2027 """
2028 items = self.get_order_items(self.request.POST.get("item_uuids", ""))
2029 note = self.request.POST.get("note", "").strip() or None
2031 self.order_handler.process_reorder(items, self.request.user, note=note)
2033 self.request.session.flash(
2034 f"{len(items)} Order Items were marked as ready for placement"
2035 )
2036 return self.redirect(self.get_index_url())
2038 @classmethod
2039 def defaults(cls, config):
2040 cls._order_item_defaults(config)
2041 cls._receiving_defaults(config)
2042 cls._defaults(config)
2044 @classmethod
2045 def _receiving_defaults(cls, config):
2046 route_prefix = cls.get_route_prefix()
2047 permission_prefix = cls.get_permission_prefix()
2048 url_prefix = cls.get_url_prefix()
2049 model_title_plural = cls.get_model_title_plural()
2051 # process receiving
2052 config.add_wutta_permission(
2053 permission_prefix,
2054 f"{permission_prefix}.process_receiving",
2055 f"Process receiving for {model_title_plural}",
2056 )
2057 config.add_route(
2058 f"{route_prefix}.process_receiving",
2059 f"{url_prefix}/process-receiving",
2060 request_method="POST",
2061 )
2062 config.add_view(
2063 cls,
2064 attr="process_receiving",
2065 route_name=f"{route_prefix}.process_receiving",
2066 permission=f"{permission_prefix}.process_receiving",
2067 )
2069 # process reorder
2070 config.add_wutta_permission(
2071 permission_prefix,
2072 f"{permission_prefix}.process_reorder",
2073 f"Process re-order for {model_title_plural}",
2074 )
2075 config.add_route(
2076 f"{route_prefix}.process_reorder",
2077 f"{url_prefix}/process-reorder",
2078 request_method="POST",
2079 )
2080 config.add_view(
2081 cls,
2082 attr="process_reorder",
2083 route_name=f"{route_prefix}.process_reorder",
2084 permission=f"{permission_prefix}.process_reorder",
2085 )
2088class ContactView(OrderItemView): # pylint: disable=abstract-method
2089 """
2090 Master view for the "contact" phase of
2091 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
2092 ``contact``. This is a subclass of :class:`OrderItemView`.
2094 This class auto-filters so only order items with the following
2095 status codes are shown:
2097 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED`
2098 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACT_FAILED`
2100 Notable URLs provided by this class:
2102 * ``/contact/``
2103 * ``/contact/XXX``
2104 """
2106 model_title = "Order Item (Contact)"
2107 model_title_plural = "Order Items (Contact)"
2108 route_prefix = "order_items_contact"
2109 url_prefix = "/contact"
2111 def get_query(self, session=None): # pylint: disable=empty-docstring
2112 """ """
2113 query = super().get_query(session=session)
2114 model = self.app.model
2115 enum = self.app.enum
2116 return query.filter(
2117 model.OrderItem.status_code.in_(
2118 (enum.ORDER_ITEM_STATUS_RECEIVED, enum.ORDER_ITEM_STATUS_CONTACT_FAILED)
2119 )
2120 )
2122 def configure_grid(self, grid): # pylint: disable=empty-docstring
2123 """ """
2124 g = grid
2125 super().configure_grid(g)
2127 # checkable
2128 if self.has_perm("process_contact"):
2129 g.checkable = True
2131 # tool button: Contact Success
2132 if self.has_perm("process_contact"):
2133 button = self.make_button(
2134 "Contact Success",
2135 primary=True,
2136 icon_left="phone",
2137 **{
2138 "@click": "$emit('process-contact-success', checkedRows)",
2139 ":disabled": "!checkedRows.length",
2140 },
2141 )
2142 g.add_tool(button, key="process_contact_success")
2144 # tool button: Contact Failure
2145 if self.has_perm("process_contact"):
2146 button = self.make_button(
2147 "Contact Failure",
2148 variant="is-warning",
2149 icon_left="phone",
2150 **{
2151 "@click": "$emit('process-contact-failure', checkedRows)",
2152 ":disabled": "!checkedRows.length",
2153 },
2154 )
2155 g.add_tool(button, key="process_contact_failure")
2157 def process_contact_success(self):
2158 """
2159 View to process the "contact success" step for some order
2160 item(s).
2162 This requires a POST request with data:
2164 :param item_uuids: Comma-delimited list of
2165 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
2167 :param note: Optional note text from the user.
2169 This invokes
2170 :meth:`~sideshow.orders.OrderHandler.process_contact_success()`
2171 on the :attr:`~OrderItemView.order_handler`, then redirects
2172 user back to the index page.
2173 """
2174 items = self.get_order_items(self.request.POST.get("item_uuids", ""))
2175 note = self.request.POST.get("note", "").strip() or None
2177 self.order_handler.process_contact_success(items, self.request.user, note=note)
2179 self.request.session.flash(f"{len(items)} Order Items were marked as contacted")
2180 return self.redirect(self.get_index_url())
2182 def process_contact_failure(self):
2183 """
2184 View to process the "contact failure" step for some order
2185 item(s).
2187 This requires a POST request with data:
2189 :param item_uuids: Comma-delimited list of
2190 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
2192 :param note: Optional note text from the user.
2194 This invokes
2195 :meth:`~sideshow.orders.OrderHandler.process_contact_failure()`
2196 on the :attr:`~OrderItemView.order_handler`, then redirects
2197 user back to the index page.
2198 """
2199 items = self.get_order_items(self.request.POST.get("item_uuids", ""))
2200 note = self.request.POST.get("note", "").strip() or None
2202 self.order_handler.process_contact_failure(items, self.request.user, note=note)
2204 self.request.session.flash(
2205 f"{len(items)} Order Items were marked as contact failed"
2206 )
2207 return self.redirect(self.get_index_url())
2209 @classmethod
2210 def defaults(cls, config):
2211 cls._order_item_defaults(config)
2212 cls._contact_defaults(config)
2213 cls._defaults(config)
2215 @classmethod
2216 def _contact_defaults(cls, config):
2217 route_prefix = cls.get_route_prefix()
2218 permission_prefix = cls.get_permission_prefix()
2219 url_prefix = cls.get_url_prefix()
2220 model_title_plural = cls.get_model_title_plural()
2222 # common perm for processing contact success + failure
2223 config.add_wutta_permission(
2224 permission_prefix,
2225 f"{permission_prefix}.process_contact",
2226 f"Process contact success/failure for {model_title_plural}",
2227 )
2229 # process contact success
2230 config.add_route(
2231 f"{route_prefix}.process_contact_success",
2232 f"{url_prefix}/process-contact-success",
2233 request_method="POST",
2234 )
2235 config.add_view(
2236 cls,
2237 attr="process_contact_success",
2238 route_name=f"{route_prefix}.process_contact_success",
2239 permission=f"{permission_prefix}.process_contact",
2240 )
2242 # process contact failure
2243 config.add_route(
2244 f"{route_prefix}.process_contact_failure",
2245 f"{url_prefix}/process-contact-failure",
2246 request_method="POST",
2247 )
2248 config.add_view(
2249 cls,
2250 attr="process_contact_failure",
2251 route_name=f"{route_prefix}.process_contact_failure",
2252 permission=f"{permission_prefix}.process_contact",
2253 )
2256class DeliveryView(OrderItemView): # pylint: disable=abstract-method
2257 """
2258 Master view for the "delivery" phase of
2259 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
2260 ``delivery``. This is a subclass of :class:`OrderItemView`.
2262 This class auto-filters so only order items with the following
2263 status codes are shown:
2265 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED`
2266 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACTED`
2268 Notable URLs provided by this class:
2270 * ``/delivery/``
2271 * ``/delivery/XXX``
2272 """
2274 model_title = "Order Item (Delivery)"
2275 model_title_plural = "Order Items (Delivery)"
2276 route_prefix = "order_items_delivery"
2277 url_prefix = "/delivery"
2279 def get_query(self, session=None): # pylint: disable=empty-docstring
2280 """ """
2281 query = super().get_query(session=session)
2282 model = self.app.model
2283 enum = self.app.enum
2284 return query.filter(
2285 model.OrderItem.status_code.in_(
2286 (enum.ORDER_ITEM_STATUS_RECEIVED, enum.ORDER_ITEM_STATUS_CONTACTED)
2287 )
2288 )
2290 def configure_grid(self, grid): # pylint: disable=empty-docstring
2291 """ """
2292 g = grid
2293 super().configure_grid(g)
2295 # checkable
2296 if self.has_any_perm("process_delivery", "process_restock"):
2297 g.checkable = True
2299 # tool button: Delivered
2300 if self.has_perm("process_delivery"):
2301 button = self.make_button(
2302 "Delivered",
2303 primary=True,
2304 icon_left="check",
2305 **{
2306 "@click": "$emit('process-delivery', checkedRows)",
2307 ":disabled": "!checkedRows.length",
2308 },
2309 )
2310 g.add_tool(button, key="process_delivery")
2312 # tool button: Restocked
2313 if self.has_perm("process_restock"):
2314 button = self.make_button(
2315 "Restocked",
2316 icon_left="redo",
2317 **{
2318 "@click": "$emit('process-restock', checkedRows)",
2319 ":disabled": "!checkedRows.length",
2320 },
2321 )
2322 g.add_tool(button, key="process_restock")
2324 def process_delivery(self):
2325 """
2326 View to process the "delivery" step for some order item(s).
2328 This requires a POST request with data:
2330 :param item_uuids: Comma-delimited list of
2331 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
2333 :param note: Optional note text from the user.
2335 This invokes
2336 :meth:`~sideshow.orders.OrderHandler.process_delivery()` on
2337 the :attr:`~OrderItemView.order_handler`, then redirects user
2338 back to the index page.
2339 """
2340 items = self.get_order_items(self.request.POST.get("item_uuids", ""))
2341 note = self.request.POST.get("note", "").strip() or None
2343 self.order_handler.process_delivery(items, self.request.user, note=note)
2345 self.request.session.flash(f"{len(items)} Order Items were marked as delivered")
2346 return self.redirect(self.get_index_url())
2348 def process_restock(self):
2349 """
2350 View to process the "restock" step for some order item(s).
2352 This requires a POST request with data:
2354 :param item_uuids: Comma-delimited list of
2355 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
2357 :param note: Optional note text from the user.
2359 This invokes
2360 :meth:`~sideshow.orders.OrderHandler.process_restock()` on the
2361 :attr:`~OrderItemView.order_handler`, then redirects user back
2362 to the index page.
2363 """
2364 items = self.get_order_items(self.request.POST.get("item_uuids", ""))
2365 note = self.request.POST.get("note", "").strip() or None
2367 self.order_handler.process_restock(items, self.request.user, note=note)
2369 self.request.session.flash(f"{len(items)} Order Items were marked as restocked")
2370 return self.redirect(self.get_index_url())
2372 @classmethod
2373 def defaults(cls, config):
2374 cls._order_item_defaults(config)
2375 cls._delivery_defaults(config)
2376 cls._defaults(config)
2378 @classmethod
2379 def _delivery_defaults(cls, config):
2380 route_prefix = cls.get_route_prefix()
2381 permission_prefix = cls.get_permission_prefix()
2382 url_prefix = cls.get_url_prefix()
2383 model_title_plural = cls.get_model_title_plural()
2385 # process delivery
2386 config.add_wutta_permission(
2387 permission_prefix,
2388 f"{permission_prefix}.process_delivery",
2389 f"Process delivery for {model_title_plural}",
2390 )
2391 config.add_route(
2392 f"{route_prefix}.process_delivery",
2393 f"{url_prefix}/process-delivery",
2394 request_method="POST",
2395 )
2396 config.add_view(
2397 cls,
2398 attr="process_delivery",
2399 route_name=f"{route_prefix}.process_delivery",
2400 permission=f"{permission_prefix}.process_delivery",
2401 )
2403 # process restock
2404 config.add_wutta_permission(
2405 permission_prefix,
2406 f"{permission_prefix}.process_restock",
2407 f"Process restock for {model_title_plural}",
2408 )
2409 config.add_route(
2410 f"{route_prefix}.process_restock",
2411 f"{url_prefix}/process-restock",
2412 request_method="POST",
2413 )
2414 config.add_view(
2415 cls,
2416 attr="process_restock",
2417 route_name=f"{route_prefix}.process_restock",
2418 permission=f"{permission_prefix}.process_restock",
2419 )
2422def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
2423 base = globals()
2425 OrderView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name
2426 "OrderView", base["OrderView"]
2427 )
2428 OrderView.defaults(config)
2430 OrderItemView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name
2431 "OrderItemView", base["OrderItemView"]
2432 )
2433 OrderItemView.defaults(config)
2435 PlacementView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name
2436 "PlacementView", base["PlacementView"]
2437 )
2438 PlacementView.defaults(config)
2440 ReceivingView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name
2441 "ReceivingView", base["ReceivingView"]
2442 )
2443 ReceivingView.defaults(config)
2445 ContactView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name
2446 "ContactView", base["ContactView"]
2447 )
2448 ContactView.defaults(config)
2450 DeliveryView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name
2451 "DeliveryView", base["DeliveryView"]
2452 )
2453 DeliveryView.defaults(config)
2456def includeme(config): # pylint: disable=missing-function-docstring
2457 defaults(config)