Coverage for src / sideshow / web / views / orders.py: 100%
778 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-15 17:03 -0600
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-15 17:03 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# Sideshow -- Case/Special Order Tracker
5# Copyright © 2024-2025 Lance Edgar
6#
7# This file is part of Sideshow.
8#
9# Sideshow is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# Sideshow is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17# General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Views for Orders
25"""
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 # raise NotImplementedError
1031 item = row
1032 return item.order
1034 def configure_row_grid(self, grid): # pylint: disable=empty-docstring
1035 """ """
1036 g = grid
1037 super().configure_row_grid(g)
1038 # enum = self.app.enum
1040 # sequence
1041 g.set_label("sequence", "Seq.", column_only=True)
1042 g.set_link("sequence")
1044 # product_scancode
1045 g.set_link("product_scancode")
1047 # product_brand
1048 g.set_link("product_brand")
1050 # product_description
1051 g.set_link("product_description")
1053 # product_size
1054 g.set_link("product_size")
1056 # TODO
1057 # order_uom
1058 # g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
1060 # discount_percent
1061 g.set_renderer("discount_percent", "percent")
1062 g.set_label("discount_percent", "Disc. %", column_only=True)
1064 # total_price
1065 g.set_renderer("total_price", g.render_currency)
1067 # status_code
1068 g.set_renderer("status_code", self.render_status_code)
1070 # TODO: upstream should set this automatically
1071 g.row_class = self.row_grid_row_class
1073 def row_grid_row_class( # pylint: disable=unused-argument,empty-docstring
1074 self, item, data, i
1075 ):
1076 """ """
1077 variant = self.order_handler.item_status_to_variant(item.status_code)
1078 if variant:
1079 return f"has-background-{variant}"
1080 return None
1082 def render_status_code( # pylint: disable=unused-argument,empty-docstring
1083 self, item, key, value
1084 ):
1085 """ """
1086 enum = self.app.enum
1087 return enum.ORDER_ITEM_STATUS[value]
1089 def get_row_action_url_view(self, row, i): # pylint: disable=empty-docstring
1090 """ """
1091 item = row
1092 return self.request.route_url("order_items.view", uuid=item.uuid)
1094 def configure_get_simple_settings(self): # pylint: disable=empty-docstring
1095 """ """
1096 settings = [
1097 # stores
1098 {"name": "sideshow.orders.expose_store_id", "type": bool},
1099 {"name": "sideshow.orders.default_store_id"},
1100 # customers
1101 {
1102 "name": "sideshow.orders.use_local_customers",
1103 # nb. this is really a bool but we present as string in config UI
1104 #'type': bool,
1105 "default": "true",
1106 },
1107 # products
1108 {
1109 "name": "sideshow.orders.use_local_products",
1110 # nb. this is really a bool but we present as string in config UI
1111 #'type': bool,
1112 "default": "true",
1113 },
1114 {
1115 "name": "sideshow.orders.allow_unknown_products",
1116 "type": bool,
1117 "default": True,
1118 },
1119 # pricing
1120 {"name": "sideshow.orders.allow_item_discounts", "type": bool},
1121 {"name": "sideshow.orders.allow_item_discounts_if_on_sale", "type": bool},
1122 {"name": "sideshow.orders.default_item_discount", "type": float},
1123 # batches
1124 {"name": "wutta.batch.neworder.handler.spec"},
1125 ]
1127 # required fields for new product entry
1128 for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
1129 setting = {
1130 "name": f"sideshow.orders.unknown_product.fields.{field}.required",
1131 "type": bool,
1132 }
1133 if field == "description":
1134 setting["default"] = True
1135 settings.append(setting)
1137 return settings
1139 def configure_get_context( # pylint: disable=empty-docstring,arguments-differ
1140 self, **kwargs
1141 ):
1142 """ """
1143 context = super().configure_get_context(**kwargs)
1145 context["pending_product_fields"] = self.PENDING_PRODUCT_ENTRY_FIELDS
1147 handlers = self.app.get_batch_handler_specs("neworder")
1148 handlers = [{"spec": spec} for spec in handlers]
1149 context["batch_handlers"] = handlers
1151 context["dept_item_discounts"] = self.get_dept_item_discounts()
1153 return context
1155 def configure_gather_settings(
1156 self, data, simple_settings=None
1157 ): # pylint: disable=empty-docstring
1158 """ """
1159 settings = super().configure_gather_settings(
1160 data, simple_settings=simple_settings
1161 )
1163 for dept in json.loads(data["dept_item_discounts"]):
1164 deptid = dept["department_id"]
1165 settings.append(
1166 {
1167 "name": f"sideshow.orders.departments.{deptid}.name",
1168 "value": dept["department_name"],
1169 }
1170 )
1171 settings.append(
1172 {
1173 "name": f"sideshow.orders.departments.{deptid}.default_item_discount",
1174 "value": dept["default_item_discount"],
1175 }
1176 )
1178 return settings
1180 def configure_remove_settings( # pylint: disable=empty-docstring,arguments-differ
1181 self, **kwargs
1182 ):
1183 """ """
1184 model = self.app.model
1185 session = self.Session()
1187 super().configure_remove_settings(**kwargs)
1189 to_delete = (
1190 session.query(model.Setting)
1191 .filter(
1192 sa.or_(
1193 model.Setting.name.like("sideshow.orders.departments.%.name"),
1194 model.Setting.name.like(
1195 "sideshow.orders.departments.%.default_item_discount"
1196 ),
1197 )
1198 )
1199 .all()
1200 )
1201 for setting in to_delete:
1202 self.app.delete_setting(session, setting.name)
1204 @classmethod
1205 def defaults(cls, config):
1206 cls._order_defaults(config)
1207 cls._defaults(config)
1209 @classmethod
1210 def _order_defaults(cls, config):
1211 route_prefix = cls.get_route_prefix()
1212 permission_prefix = cls.get_permission_prefix()
1213 url_prefix = cls.get_url_prefix()
1214 model_title = cls.get_model_title()
1215 model_title_plural = cls.get_model_title_plural()
1217 # fix perm group
1218 config.add_wutta_permission_group(
1219 permission_prefix, model_title_plural, overwrite=False
1220 )
1222 # extra perm required to create order with unknown/pending product
1223 config.add_wutta_permission(
1224 permission_prefix,
1225 f"{permission_prefix}.create_unknown_product",
1226 f"Create new {model_title} for unknown/pending product",
1227 )
1229 # customer autocomplete
1230 config.add_route(
1231 f"{route_prefix}.customer_autocomplete",
1232 f"{url_prefix}/customer-autocomplete",
1233 request_method="GET",
1234 )
1235 config.add_view(
1236 cls,
1237 attr="customer_autocomplete",
1238 route_name=f"{route_prefix}.customer_autocomplete",
1239 renderer="json",
1240 permission=f"{permission_prefix}.list",
1241 )
1243 # product autocomplete
1244 config.add_route(
1245 f"{route_prefix}.product_autocomplete",
1246 f"{url_prefix}/product-autocomplete",
1247 request_method="GET",
1248 )
1249 config.add_view(
1250 cls,
1251 attr="product_autocomplete",
1252 route_name=f"{route_prefix}.product_autocomplete",
1253 renderer="json",
1254 permission=f"{permission_prefix}.list",
1255 )
1258class OrderItemView(MasterView): # pylint: disable=abstract-method
1259 """
1260 Master view for :class:`~sideshow.db.model.orders.OrderItem`;
1261 route prefix is ``order_items``.
1263 Notable URLs provided by this class:
1265 * ``/order-items/``
1266 * ``/order-items/XXX``
1268 This class serves both as a proper master view (for "all" order
1269 items) as well as a base class for other "workflow" master views,
1270 each of which auto-filters by order item status:
1272 * :class:`PlacementView`
1273 * :class:`ReceivingView`
1274 * :class:`ContactView`
1275 * :class:`DeliveryView`
1277 Note that this does not expose create, edit or delete. The user
1278 must perform various other workflow actions to modify the item.
1280 .. attribute:: order_handler
1282 Reference to the :term:`order handler` as returned by
1283 :meth:`get_order_handler()`.
1284 """
1286 model_class = OrderItem
1287 model_title = "Order Item (All)"
1288 model_title_plural = "Order Items (All)"
1289 route_prefix = "order_items"
1290 url_prefix = "/order-items"
1291 creatable = False
1292 editable = False
1293 deletable = False
1295 labels = {
1296 "order_id": "Order ID",
1297 "store_id": "Store ID",
1298 "product_id": "Product ID",
1299 "product_scancode": "Scancode",
1300 "product_brand": "Brand",
1301 "product_description": "Description",
1302 "product_size": "Size",
1303 "product_weighed": "Sold by Weight",
1304 "department_id": "Department ID",
1305 "order_uom": "Order UOM",
1306 "status_code": "Status",
1307 }
1309 grid_columns = [
1310 "order_id",
1311 "store_id",
1312 "customer_name",
1313 # 'sequence',
1314 "product_scancode",
1315 "product_brand",
1316 "product_description",
1317 "product_size",
1318 "department_name",
1319 "special_order",
1320 "order_qty",
1321 "order_uom",
1322 "total_price",
1323 "status_code",
1324 ]
1326 sort_defaults = ("order_id", "desc")
1328 # pylint: disable=duplicate-code
1329 form_fields = [
1330 "order",
1331 # 'customer_name',
1332 "sequence",
1333 "product_id",
1334 "local_product",
1335 "pending_product",
1336 "product_scancode",
1337 "product_brand",
1338 "product_description",
1339 "product_size",
1340 "product_weighed",
1341 "department_id",
1342 "department_name",
1343 "special_order",
1344 "case_size",
1345 "unit_cost",
1346 "unit_price_reg",
1347 "unit_price_sale",
1348 "sale_ends",
1349 "unit_price_quoted",
1350 "case_price_quoted",
1351 "order_qty",
1352 "order_uom",
1353 "discount_percent",
1354 "total_price",
1355 "status_code",
1356 "paid_amount",
1357 "payment_transaction_number",
1358 ]
1359 # pylint: enable=duplicate-code
1361 def __init__(self, request, context=None):
1362 super().__init__(request, context=context)
1363 self.order_handler = self.app.get_order_handler()
1365 def get_fallback_templates(self, template): # pylint: disable=empty-docstring
1366 """ """
1367 templates = super().get_fallback_templates(template)
1368 templates.insert(0, f"/order-items/{template}.mako")
1369 return templates
1371 def get_query(self, session=None): # pylint: disable=empty-docstring
1372 """ """
1373 query = super().get_query(session=session)
1374 model = self.app.model
1375 return query.join(model.Order)
1377 def configure_grid(self, grid): # pylint: disable=empty-docstring
1378 """ """
1379 g = grid
1380 super().configure_grid(g)
1381 model = self.app.model
1382 # enum = self.app.enum
1384 # store_id
1385 if not self.order_handler.expose_store_id():
1386 g.remove("store_id")
1388 # order_id
1389 g.set_sorter("order_id", model.Order.order_id)
1390 g.set_renderer("order_id", self.render_order_attr)
1391 g.set_link("order_id")
1393 # store_id
1394 g.set_sorter("store_id", model.Order.store_id)
1395 g.set_renderer("store_id", self.render_order_attr)
1397 # customer_name
1398 g.set_label("customer_name", "Customer", column_only=True)
1399 g.set_renderer("customer_name", self.render_order_attr)
1400 g.set_sorter("customer_name", model.Order.customer_name)
1401 g.set_filter("customer_name", model.Order.customer_name)
1403 # # sequence
1404 # g.set_label('sequence', "Seq.", column_only=True)
1406 # product_scancode
1407 g.set_link("product_scancode")
1409 # product_brand
1410 g.set_link("product_brand")
1412 # product_description
1413 g.set_link("product_description")
1415 # product_size
1416 g.set_link("product_size")
1418 # order_uom
1419 # TODO
1420 # g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
1422 # total_price
1423 g.set_renderer("total_price", g.render_currency)
1425 # status_code
1426 g.set_renderer("status_code", self.render_status_code)
1428 def render_order_attr( # pylint: disable=unused-argument,empty-docstring
1429 self, item, key, value
1430 ):
1431 """ """
1432 order = item.order
1433 return getattr(order, key)
1435 def render_status_code( # pylint: disable=unused-argument,empty-docstring
1436 self, item, key, value
1437 ):
1438 """ """
1439 enum = self.app.enum
1440 return enum.ORDER_ITEM_STATUS[value]
1442 def grid_row_class( # pylint: disable=unused-argument,empty-docstring
1443 self, item, data, i
1444 ):
1445 """ """
1446 variant = self.order_handler.item_status_to_variant(item.status_code)
1447 if variant:
1448 return f"has-background-{variant}"
1449 return None
1451 def configure_form(self, form): # pylint: disable=empty-docstring
1452 """ """
1453 f = form
1454 super().configure_form(f)
1455 enum = self.app.enum
1456 item = f.model_instance
1458 # order
1459 f.set_node("order", OrderRef(self.request))
1461 # local_product
1462 f.set_node("local_product", LocalProductRef(self.request))
1464 # pending_product
1465 if item.product_id or item.local_product:
1466 f.remove("pending_product")
1467 else:
1468 f.set_node("pending_product", PendingProductRef(self.request))
1470 # order_qty
1471 f.set_node("order_qty", WuttaQuantity(self.request))
1473 # order_uom
1474 f.set_node("order_uom", WuttaDictEnum(self.request, enum.ORDER_UOM))
1476 # case_size
1477 f.set_node("case_size", WuttaQuantity(self.request))
1479 # unit_cost
1480 f.set_node("unit_cost", WuttaMoney(self.request, scale=4))
1482 # unit_price_reg
1483 f.set_node("unit_price_reg", WuttaMoney(self.request))
1485 # unit_price_quoted
1486 f.set_node("unit_price_quoted", WuttaMoney(self.request))
1488 # case_price_quoted
1489 f.set_node("case_price_quoted", WuttaMoney(self.request))
1491 # total_price
1492 f.set_node("total_price", WuttaMoney(self.request))
1494 # status
1495 f.set_node("status_code", WuttaDictEnum(self.request, enum.ORDER_ITEM_STATUS))
1497 # paid_amount
1498 f.set_node("paid_amount", WuttaMoney(self.request))
1500 def get_template_context(self, context): # pylint: disable=empty-docstring
1501 """ """
1502 if self.viewing:
1503 model = self.app.model
1504 enum = self.app.enum
1505 route_prefix = self.get_route_prefix()
1506 item = context["instance"]
1507 form = context["form"]
1509 context["expose_store_id"] = self.order_handler.expose_store_id()
1511 context["item"] = item
1512 context["order"] = item.order
1513 context["order_qty_uom_text"] = self.order_handler.get_order_qty_uom_text(
1514 item.order_qty, item.order_uom, case_size=item.case_size, html=True
1515 )
1516 context["item_status_variant"] = self.order_handler.item_status_to_variant(
1517 item.status_code
1518 )
1520 grid = self.make_grid(
1521 key=f"{route_prefix}.view.events",
1522 model_class=model.OrderItemEvent,
1523 data=item.events,
1524 columns=[
1525 "occurred",
1526 "actor",
1527 "type_code",
1528 "note",
1529 ],
1530 labels={
1531 "occurred": "Date/Time",
1532 "actor": "User",
1533 "type_code": "Event Type",
1534 },
1535 )
1536 grid.set_renderer("type_code", lambda e, k, v: enum.ORDER_ITEM_EVENT[v])
1537 grid.set_renderer("note", self.render_event_note)
1538 if self.request.has_perm("users.view"):
1539 grid.set_renderer(
1540 "actor",
1541 lambda e, k, v: tags.link_to(
1542 e.actor, self.request.route_url("users.view", uuid=e.actor.uuid)
1543 ),
1544 )
1545 form.add_grid_vue_context(grid)
1546 context["events_grid"] = grid
1548 return context
1550 def render_event_note( # pylint: disable=unused-argument,empty-docstring
1551 self, event, key, value
1552 ):
1553 """ """
1554 enum = self.app.enum
1555 if event.type_code == enum.ORDER_ITEM_EVENT_NOTE_ADDED:
1556 return HTML.tag(
1557 "span",
1558 class_="has-background-info-light",
1559 style="padding: 0.25rem 0.5rem;",
1560 c=[value],
1561 )
1562 return value
1564 def get_xref_buttons(self, obj): # pylint: disable=empty-docstring
1565 """ """
1566 item = obj
1567 buttons = super().get_xref_buttons(item)
1569 if self.request.has_perm("orders.view"):
1570 url = self.request.route_url("orders.view", uuid=item.order_uuid)
1571 buttons.append(
1572 self.make_button(
1573 "View the Order", url=url, primary=True, icon_left="eye"
1574 )
1575 )
1577 return buttons
1579 def add_note(self):
1580 """
1581 View which adds a note to an order item. This is POST-only;
1582 will redirect back to the item view.
1583 """
1584 enum = self.app.enum
1585 item = self.get_instance()
1587 item.add_event(
1588 enum.ORDER_ITEM_EVENT_NOTE_ADDED,
1589 self.request.user,
1590 note=self.request.POST["note"],
1591 )
1593 return self.redirect(self.get_action_url("view", item))
1595 def change_status(self):
1596 """
1597 View which changes status for an order item. This is
1598 POST-only; will redirect back to the item view.
1599 """
1600 enum = self.app.enum
1601 main_item = self.get_instance()
1602 redirect = self.redirect(self.get_action_url("view", main_item))
1604 extra_note = self.request.POST.get("note")
1606 # validate new status
1607 new_status_code = int(self.request.POST["new_status"])
1608 if new_status_code not in enum.ORDER_ITEM_STATUS:
1609 self.request.session.flash("Invalid status code", "error")
1610 return redirect
1611 new_status_text = enum.ORDER_ITEM_STATUS[new_status_code]
1613 # locate all items to which new status will be applied
1614 items = [main_item]
1615 # uuids = self.request.POST.get('uuids')
1616 # if uuids:
1617 # for uuid in uuids.split(','):
1618 # item = Session.get(model.OrderItem, uuid)
1619 # if item:
1620 # items.append(item)
1622 # update item(s)
1623 for item in items:
1624 if item.status_code != new_status_code:
1626 # event: change status
1627 note = (
1628 f'status changed from "{enum.ORDER_ITEM_STATUS[item.status_code]}" '
1629 f'to "{new_status_text}"'
1630 )
1631 item.add_event(
1632 enum.ORDER_ITEM_EVENT_STATUS_CHANGE, self.request.user, note=note
1633 )
1635 # event: add note
1636 if extra_note:
1637 item.add_event(
1638 enum.ORDER_ITEM_EVENT_NOTE_ADDED,
1639 self.request.user,
1640 note=extra_note,
1641 )
1643 # new status
1644 item.status_code = new_status_code
1646 self.request.session.flash(f"Status has been updated to: {new_status_text}")
1647 return redirect
1649 def get_order_items(self, uuids):
1650 """
1651 This method provides common logic to fetch a list of order
1652 items based on a list of UUID keys. It is used by various
1653 workflow action methods.
1655 Note that if no order items are found, this will set a flash
1656 warning message and raise a redirect back to the index page.
1658 :param uuids: List (or comma-delimited string) of UUID keys.
1660 :returns: List of :class:`~sideshow.db.model.orders.OrderItem`
1661 records.
1662 """
1663 model = self.app.model
1664 session = self.Session()
1666 if uuids is None:
1667 uuids = []
1668 elif isinstance(uuids, str):
1669 uuids = uuids.split(",")
1671 items = []
1672 for uuid in uuids:
1673 if isinstance(uuid, str):
1674 uuid = uuid.strip()
1675 if uuid:
1676 try:
1677 item = session.get(model.OrderItem, uuid)
1678 except sa.exc.StatementError:
1679 pass # nb. invalid UUID
1680 else:
1681 if item:
1682 items.append(item)
1684 if not items:
1685 self.request.session.flash("Must specify valid order item(s).", "warning")
1686 raise self.redirect(self.get_index_url())
1688 return items
1690 @classmethod
1691 def defaults(cls, config): # pylint: disable=empty-docstring
1692 """ """
1693 cls._order_item_defaults(config)
1694 cls._defaults(config)
1696 @classmethod
1697 def _order_item_defaults(cls, config):
1698 """ """
1699 route_prefix = cls.get_route_prefix()
1700 permission_prefix = cls.get_permission_prefix()
1701 instance_url_prefix = cls.get_instance_url_prefix()
1702 model_title = cls.get_model_title()
1703 model_title_plural = cls.get_model_title_plural()
1705 # fix perm group
1706 config.add_wutta_permission_group(
1707 permission_prefix, model_title_plural, overwrite=False
1708 )
1710 # add note
1711 config.add_route(
1712 f"{route_prefix}.add_note",
1713 f"{instance_url_prefix}/add_note",
1714 request_method="POST",
1715 )
1716 config.add_view(
1717 cls,
1718 attr="add_note",
1719 route_name=f"{route_prefix}.add_note",
1720 renderer="json",
1721 permission=f"{permission_prefix}.add_note",
1722 )
1723 config.add_wutta_permission(
1724 permission_prefix,
1725 f"{permission_prefix}.add_note",
1726 f"Add note for {model_title}",
1727 )
1729 # change status
1730 config.add_route(
1731 f"{route_prefix}.change_status",
1732 f"{instance_url_prefix}/change-status",
1733 request_method="POST",
1734 )
1735 config.add_view(
1736 cls,
1737 attr="change_status",
1738 route_name=f"{route_prefix}.change_status",
1739 renderer="json",
1740 permission=f"{permission_prefix}.change_status",
1741 )
1742 config.add_wutta_permission(
1743 permission_prefix,
1744 f"{permission_prefix}.change_status",
1745 f"Change status for {model_title}",
1746 )
1749class PlacementView(OrderItemView): # pylint: disable=abstract-method
1750 """
1751 Master view for the "placement" phase of
1752 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1753 ``placement``. This is a subclass of :class:`OrderItemView`.
1755 This class auto-filters so only order items with the following
1756 status codes are shown:
1758 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY`
1760 Notable URLs provided by this class:
1762 * ``/placement/``
1763 * ``/placement/XXX``
1764 """
1766 model_title = "Order Item (Placement)"
1767 model_title_plural = "Order Items (Placement)"
1768 route_prefix = "order_items_placement"
1769 url_prefix = "/placement"
1771 grid_columns = [
1772 "order_id",
1773 "store_id",
1774 "customer_name",
1775 "product_brand",
1776 "product_description",
1777 "product_size",
1778 "department_name",
1779 "special_order",
1780 "vendor_name",
1781 "vendor_item_code",
1782 "order_qty",
1783 "order_uom",
1784 "total_price",
1785 ]
1787 filter_defaults = {
1788 "vendor_name": {"active": True},
1789 }
1791 def get_query(self, session=None): # pylint: disable=empty-docstring
1792 """ """
1793 query = super().get_query(session=session)
1794 model = self.app.model
1795 enum = self.app.enum
1796 return query.filter(model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_READY)
1798 def configure_grid(self, grid): # pylint: disable=empty-docstring
1799 """ """
1800 g = grid
1801 super().configure_grid(g)
1803 # checkable
1804 if self.has_perm("process_placement"):
1805 g.checkable = True
1807 # tool button: Order Placed
1808 if self.has_perm("process_placement"):
1809 button = self.make_button(
1810 "Order Placed",
1811 primary=True,
1812 icon_left="arrow-circle-right",
1813 **{
1814 "@click": "$emit('process-placement', checkedRows)",
1815 ":disabled": "!checkedRows.length",
1816 },
1817 )
1818 g.add_tool(button, key="process_placement")
1820 def process_placement(self):
1821 """
1822 View to process the "placement" step for some order item(s).
1824 This requires a POST request with data:
1826 :param item_uuids: Comma-delimited list of
1827 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1829 :param vendor_name: Optional name of vendor.
1831 :param po_number: Optional PO number.
1833 :param note: Optional note text from the user.
1835 This invokes
1836 :meth:`~sideshow.orders.OrderHandler.process_placement()` on
1837 the :attr:`~OrderItemView.order_handler`, then redirects user
1838 back to the index page.
1839 """
1840 items = self.get_order_items(self.request.POST.get("item_uuids", ""))
1841 vendor_name = self.request.POST.get("vendor_name", "").strip() or None
1842 po_number = self.request.POST.get("po_number", "").strip() or None
1843 note = self.request.POST.get("note", "").strip() or None
1845 self.order_handler.process_placement(
1846 items,
1847 self.request.user,
1848 vendor_name=vendor_name,
1849 po_number=po_number,
1850 note=note,
1851 )
1853 self.request.session.flash(f"{len(items)} Order Items were marked as placed")
1854 return self.redirect(self.get_index_url())
1856 @classmethod
1857 def defaults(cls, config):
1858 cls._order_item_defaults(config)
1859 cls._placement_defaults(config)
1860 cls._defaults(config)
1862 @classmethod
1863 def _placement_defaults(cls, config):
1864 route_prefix = cls.get_route_prefix()
1865 permission_prefix = cls.get_permission_prefix()
1866 url_prefix = cls.get_url_prefix()
1867 model_title_plural = cls.get_model_title_plural()
1869 # process placement
1870 config.add_wutta_permission(
1871 permission_prefix,
1872 f"{permission_prefix}.process_placement",
1873 f"Process placement for {model_title_plural}",
1874 )
1875 config.add_route(
1876 f"{route_prefix}.process_placement",
1877 f"{url_prefix}/process-placement",
1878 request_method="POST",
1879 )
1880 config.add_view(
1881 cls,
1882 attr="process_placement",
1883 route_name=f"{route_prefix}.process_placement",
1884 permission=f"{permission_prefix}.process_placement",
1885 )
1888class ReceivingView(OrderItemView): # pylint: disable=abstract-method
1889 """
1890 Master view for the "receiving" phase of
1891 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
1892 ``receiving``. This is a subclass of :class:`OrderItemView`.
1894 This class auto-filters so only order items with the following
1895 status codes are shown:
1897 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_PLACED`
1899 Notable URLs provided by this class:
1901 * ``/receiving/``
1902 * ``/receiving/XXX``
1903 """
1905 model_title = "Order Item (Receiving)"
1906 model_title_plural = "Order Items (Receiving)"
1907 route_prefix = "order_items_receiving"
1908 url_prefix = "/receiving"
1910 grid_columns = [
1911 "order_id",
1912 "store_id",
1913 "customer_name",
1914 "product_brand",
1915 "product_description",
1916 "product_size",
1917 "department_name",
1918 "special_order",
1919 "vendor_name",
1920 "vendor_item_code",
1921 "order_qty",
1922 "order_uom",
1923 "total_price",
1924 ]
1926 filter_defaults = {
1927 "vendor_name": {"active": True},
1928 }
1930 def get_query(self, session=None): # pylint: disable=empty-docstring
1931 """ """
1932 query = super().get_query(session=session)
1933 model = self.app.model
1934 enum = self.app.enum
1935 return query.filter(
1936 model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_PLACED
1937 )
1939 def configure_grid(self, grid): # pylint: disable=empty-docstring
1940 """ """
1941 g = grid
1942 super().configure_grid(g)
1944 # checkable
1945 if self.has_any_perm("process_receiving", "process_reorder"):
1946 g.checkable = True
1948 # tool button: Received
1949 if self.has_perm("process_receiving"):
1950 button = self.make_button(
1951 "Received",
1952 primary=True,
1953 icon_left="arrow-circle-right",
1954 **{
1955 "@click": "$emit('process-receiving', checkedRows)",
1956 ":disabled": "!checkedRows.length",
1957 },
1958 )
1959 g.add_tool(button, key="process_receiving")
1961 # tool button: Re-Order
1962 if self.has_perm("process_reorder"):
1963 button = self.make_button(
1964 "Re-Order",
1965 icon_left="redo",
1966 **{
1967 "@click": "$emit('process-reorder', checkedRows)",
1968 ":disabled": "!checkedRows.length",
1969 },
1970 )
1971 g.add_tool(button, key="process_reorder")
1973 def process_receiving(self):
1974 """
1975 View to process the "receiving" step for some order item(s).
1977 This requires a POST request with data:
1979 :param item_uuids: Comma-delimited list of
1980 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
1982 :param vendor_name: Optional name of vendor.
1984 :param invoice_number: Optional invoice number.
1986 :param po_number: Optional PO number.
1988 :param note: Optional note text from the user.
1990 This invokes
1991 :meth:`~sideshow.orders.OrderHandler.process_receiving()` on
1992 the :attr:`~OrderItemView.order_handler`, then redirects user
1993 back to the index page.
1994 """
1995 items = self.get_order_items(self.request.POST.get("item_uuids", ""))
1996 vendor_name = self.request.POST.get("vendor_name", "").strip() or None
1997 invoice_number = self.request.POST.get("invoice_number", "").strip() or None
1998 po_number = self.request.POST.get("po_number", "").strip() or None
1999 note = self.request.POST.get("note", "").strip() or None
2001 self.order_handler.process_receiving(
2002 items,
2003 self.request.user,
2004 vendor_name=vendor_name,
2005 invoice_number=invoice_number,
2006 po_number=po_number,
2007 note=note,
2008 )
2010 self.request.session.flash(f"{len(items)} Order Items were marked as received")
2011 return self.redirect(self.get_index_url())
2013 def process_reorder(self):
2014 """
2015 View to process the "reorder" step for some order item(s).
2017 This requires a POST request with data:
2019 :param item_uuids: Comma-delimited list of
2020 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
2022 :param note: Optional note text from the user.
2024 This invokes
2025 :meth:`~sideshow.orders.OrderHandler.process_reorder()` on the
2026 :attr:`~OrderItemView.order_handler`, then redirects user back
2027 to the index page.
2028 """
2029 items = self.get_order_items(self.request.POST.get("item_uuids", ""))
2030 note = self.request.POST.get("note", "").strip() or None
2032 self.order_handler.process_reorder(items, self.request.user, note=note)
2034 self.request.session.flash(
2035 f"{len(items)} Order Items were marked as ready for placement"
2036 )
2037 return self.redirect(self.get_index_url())
2039 @classmethod
2040 def defaults(cls, config):
2041 cls._order_item_defaults(config)
2042 cls._receiving_defaults(config)
2043 cls._defaults(config)
2045 @classmethod
2046 def _receiving_defaults(cls, config):
2047 route_prefix = cls.get_route_prefix()
2048 permission_prefix = cls.get_permission_prefix()
2049 url_prefix = cls.get_url_prefix()
2050 model_title_plural = cls.get_model_title_plural()
2052 # process receiving
2053 config.add_wutta_permission(
2054 permission_prefix,
2055 f"{permission_prefix}.process_receiving",
2056 f"Process receiving for {model_title_plural}",
2057 )
2058 config.add_route(
2059 f"{route_prefix}.process_receiving",
2060 f"{url_prefix}/process-receiving",
2061 request_method="POST",
2062 )
2063 config.add_view(
2064 cls,
2065 attr="process_receiving",
2066 route_name=f"{route_prefix}.process_receiving",
2067 permission=f"{permission_prefix}.process_receiving",
2068 )
2070 # process reorder
2071 config.add_wutta_permission(
2072 permission_prefix,
2073 f"{permission_prefix}.process_reorder",
2074 f"Process re-order for {model_title_plural}",
2075 )
2076 config.add_route(
2077 f"{route_prefix}.process_reorder",
2078 f"{url_prefix}/process-reorder",
2079 request_method="POST",
2080 )
2081 config.add_view(
2082 cls,
2083 attr="process_reorder",
2084 route_name=f"{route_prefix}.process_reorder",
2085 permission=f"{permission_prefix}.process_reorder",
2086 )
2089class ContactView(OrderItemView): # pylint: disable=abstract-method
2090 """
2091 Master view for the "contact" phase of
2092 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
2093 ``contact``. This is a subclass of :class:`OrderItemView`.
2095 This class auto-filters so only order items with the following
2096 status codes are shown:
2098 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED`
2099 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACT_FAILED`
2101 Notable URLs provided by this class:
2103 * ``/contact/``
2104 * ``/contact/XXX``
2105 """
2107 model_title = "Order Item (Contact)"
2108 model_title_plural = "Order Items (Contact)"
2109 route_prefix = "order_items_contact"
2110 url_prefix = "/contact"
2112 def get_query(self, session=None): # pylint: disable=empty-docstring
2113 """ """
2114 query = super().get_query(session=session)
2115 model = self.app.model
2116 enum = self.app.enum
2117 return query.filter(
2118 model.OrderItem.status_code.in_(
2119 (enum.ORDER_ITEM_STATUS_RECEIVED, enum.ORDER_ITEM_STATUS_CONTACT_FAILED)
2120 )
2121 )
2123 def configure_grid(self, grid): # pylint: disable=empty-docstring
2124 """ """
2125 g = grid
2126 super().configure_grid(g)
2128 # checkable
2129 if self.has_perm("process_contact"):
2130 g.checkable = True
2132 # tool button: Contact Success
2133 if self.has_perm("process_contact"):
2134 button = self.make_button(
2135 "Contact Success",
2136 primary=True,
2137 icon_left="phone",
2138 **{
2139 "@click": "$emit('process-contact-success', checkedRows)",
2140 ":disabled": "!checkedRows.length",
2141 },
2142 )
2143 g.add_tool(button, key="process_contact_success")
2145 # tool button: Contact Failure
2146 if self.has_perm("process_contact"):
2147 button = self.make_button(
2148 "Contact Failure",
2149 variant="is-warning",
2150 icon_left="phone",
2151 **{
2152 "@click": "$emit('process-contact-failure', checkedRows)",
2153 ":disabled": "!checkedRows.length",
2154 },
2155 )
2156 g.add_tool(button, key="process_contact_failure")
2158 def process_contact_success(self):
2159 """
2160 View to process the "contact success" step for some order
2161 item(s).
2163 This requires a POST request with data:
2165 :param item_uuids: Comma-delimited list of
2166 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
2168 :param note: Optional note text from the user.
2170 This invokes
2171 :meth:`~sideshow.orders.OrderHandler.process_contact_success()`
2172 on the :attr:`~OrderItemView.order_handler`, then redirects
2173 user back to the index page.
2174 """
2175 items = self.get_order_items(self.request.POST.get("item_uuids", ""))
2176 note = self.request.POST.get("note", "").strip() or None
2178 self.order_handler.process_contact_success(items, self.request.user, note=note)
2180 self.request.session.flash(f"{len(items)} Order Items were marked as contacted")
2181 return self.redirect(self.get_index_url())
2183 def process_contact_failure(self):
2184 """
2185 View to process the "contact failure" step for some order
2186 item(s).
2188 This requires a POST request with data:
2190 :param item_uuids: Comma-delimited list of
2191 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
2193 :param note: Optional note text from the user.
2195 This invokes
2196 :meth:`~sideshow.orders.OrderHandler.process_contact_failure()`
2197 on the :attr:`~OrderItemView.order_handler`, then redirects
2198 user back to the index page.
2199 """
2200 items = self.get_order_items(self.request.POST.get("item_uuids", ""))
2201 note = self.request.POST.get("note", "").strip() or None
2203 self.order_handler.process_contact_failure(items, self.request.user, note=note)
2205 self.request.session.flash(
2206 f"{len(items)} Order Items were marked as contact failed"
2207 )
2208 return self.redirect(self.get_index_url())
2210 @classmethod
2211 def defaults(cls, config):
2212 cls._order_item_defaults(config)
2213 cls._contact_defaults(config)
2214 cls._defaults(config)
2216 @classmethod
2217 def _contact_defaults(cls, config):
2218 route_prefix = cls.get_route_prefix()
2219 permission_prefix = cls.get_permission_prefix()
2220 url_prefix = cls.get_url_prefix()
2221 model_title_plural = cls.get_model_title_plural()
2223 # common perm for processing contact success + failure
2224 config.add_wutta_permission(
2225 permission_prefix,
2226 f"{permission_prefix}.process_contact",
2227 f"Process contact success/failure for {model_title_plural}",
2228 )
2230 # process contact success
2231 config.add_route(
2232 f"{route_prefix}.process_contact_success",
2233 f"{url_prefix}/process-contact-success",
2234 request_method="POST",
2235 )
2236 config.add_view(
2237 cls,
2238 attr="process_contact_success",
2239 route_name=f"{route_prefix}.process_contact_success",
2240 permission=f"{permission_prefix}.process_contact",
2241 )
2243 # process contact failure
2244 config.add_route(
2245 f"{route_prefix}.process_contact_failure",
2246 f"{url_prefix}/process-contact-failure",
2247 request_method="POST",
2248 )
2249 config.add_view(
2250 cls,
2251 attr="process_contact_failure",
2252 route_name=f"{route_prefix}.process_contact_failure",
2253 permission=f"{permission_prefix}.process_contact",
2254 )
2257class DeliveryView(OrderItemView): # pylint: disable=abstract-method
2258 """
2259 Master view for the "delivery" phase of
2260 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
2261 ``delivery``. This is a subclass of :class:`OrderItemView`.
2263 This class auto-filters so only order items with the following
2264 status codes are shown:
2266 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED`
2267 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACTED`
2269 Notable URLs provided by this class:
2271 * ``/delivery/``
2272 * ``/delivery/XXX``
2273 """
2275 model_title = "Order Item (Delivery)"
2276 model_title_plural = "Order Items (Delivery)"
2277 route_prefix = "order_items_delivery"
2278 url_prefix = "/delivery"
2280 def get_query(self, session=None): # pylint: disable=empty-docstring
2281 """ """
2282 query = super().get_query(session=session)
2283 model = self.app.model
2284 enum = self.app.enum
2285 return query.filter(
2286 model.OrderItem.status_code.in_(
2287 (enum.ORDER_ITEM_STATUS_RECEIVED, enum.ORDER_ITEM_STATUS_CONTACTED)
2288 )
2289 )
2291 def configure_grid(self, grid): # pylint: disable=empty-docstring
2292 """ """
2293 g = grid
2294 super().configure_grid(g)
2296 # checkable
2297 if self.has_any_perm("process_delivery", "process_restock"):
2298 g.checkable = True
2300 # tool button: Delivered
2301 if self.has_perm("process_delivery"):
2302 button = self.make_button(
2303 "Delivered",
2304 primary=True,
2305 icon_left="check",
2306 **{
2307 "@click": "$emit('process-delivery', checkedRows)",
2308 ":disabled": "!checkedRows.length",
2309 },
2310 )
2311 g.add_tool(button, key="process_delivery")
2313 # tool button: Restocked
2314 if self.has_perm("process_restock"):
2315 button = self.make_button(
2316 "Restocked",
2317 icon_left="redo",
2318 **{
2319 "@click": "$emit('process-restock', checkedRows)",
2320 ":disabled": "!checkedRows.length",
2321 },
2322 )
2323 g.add_tool(button, key="process_restock")
2325 def process_delivery(self):
2326 """
2327 View to process the "delivery" step for some order item(s).
2329 This requires a POST request with data:
2331 :param item_uuids: Comma-delimited list of
2332 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
2334 :param note: Optional note text from the user.
2336 This invokes
2337 :meth:`~sideshow.orders.OrderHandler.process_delivery()` on
2338 the :attr:`~OrderItemView.order_handler`, then redirects user
2339 back to the index page.
2340 """
2341 items = self.get_order_items(self.request.POST.get("item_uuids", ""))
2342 note = self.request.POST.get("note", "").strip() or None
2344 self.order_handler.process_delivery(items, self.request.user, note=note)
2346 self.request.session.flash(f"{len(items)} Order Items were marked as delivered")
2347 return self.redirect(self.get_index_url())
2349 def process_restock(self):
2350 """
2351 View to process the "restock" step for some order item(s).
2353 This requires a POST request with data:
2355 :param item_uuids: Comma-delimited list of
2356 :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
2358 :param note: Optional note text from the user.
2360 This invokes
2361 :meth:`~sideshow.orders.OrderHandler.process_restock()` on the
2362 :attr:`~OrderItemView.order_handler`, then redirects user back
2363 to the index page.
2364 """
2365 items = self.get_order_items(self.request.POST.get("item_uuids", ""))
2366 note = self.request.POST.get("note", "").strip() or None
2368 self.order_handler.process_restock(items, self.request.user, note=note)
2370 self.request.session.flash(f"{len(items)} Order Items were marked as restocked")
2371 return self.redirect(self.get_index_url())
2373 @classmethod
2374 def defaults(cls, config):
2375 cls._order_item_defaults(config)
2376 cls._delivery_defaults(config)
2377 cls._defaults(config)
2379 @classmethod
2380 def _delivery_defaults(cls, config):
2381 route_prefix = cls.get_route_prefix()
2382 permission_prefix = cls.get_permission_prefix()
2383 url_prefix = cls.get_url_prefix()
2384 model_title_plural = cls.get_model_title_plural()
2386 # process delivery
2387 config.add_wutta_permission(
2388 permission_prefix,
2389 f"{permission_prefix}.process_delivery",
2390 f"Process delivery for {model_title_plural}",
2391 )
2392 config.add_route(
2393 f"{route_prefix}.process_delivery",
2394 f"{url_prefix}/process-delivery",
2395 request_method="POST",
2396 )
2397 config.add_view(
2398 cls,
2399 attr="process_delivery",
2400 route_name=f"{route_prefix}.process_delivery",
2401 permission=f"{permission_prefix}.process_delivery",
2402 )
2404 # process restock
2405 config.add_wutta_permission(
2406 permission_prefix,
2407 f"{permission_prefix}.process_restock",
2408 f"Process restock for {model_title_plural}",
2409 )
2410 config.add_route(
2411 f"{route_prefix}.process_restock",
2412 f"{url_prefix}/process-restock",
2413 request_method="POST",
2414 )
2415 config.add_view(
2416 cls,
2417 attr="process_restock",
2418 route_name=f"{route_prefix}.process_restock",
2419 permission=f"{permission_prefix}.process_restock",
2420 )
2423def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
2424 base = globals()
2426 OrderView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name
2427 "OrderView", base["OrderView"]
2428 )
2429 OrderView.defaults(config)
2431 OrderItemView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name
2432 "OrderItemView", base["OrderItemView"]
2433 )
2434 OrderItemView.defaults(config)
2436 PlacementView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name
2437 "PlacementView", base["PlacementView"]
2438 )
2439 PlacementView.defaults(config)
2441 ReceivingView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name
2442 "ReceivingView", base["ReceivingView"]
2443 )
2444 ReceivingView.defaults(config)
2446 ContactView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name
2447 "ContactView", base["ContactView"]
2448 )
2449 ContactView.defaults(config)
2451 DeliveryView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name
2452 "DeliveryView", base["DeliveryView"]
2453 )
2454 DeliveryView.defaults(config)
2457def includeme(config): # pylint: disable=missing-function-docstring
2458 defaults(config)