Coverage for .tox / coverage / lib / python3.11 / site-packages / sideshow / web / views / products.py: 100%
161 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 Products
25"""
27from wuttaweb.views import MasterView
28from wuttaweb.forms.schema import WuttaMoney, WuttaQuantity
30from sideshow.enum import PendingProductStatus
31from sideshow.db.model import LocalProduct, PendingProduct
32from sideshow.web.views.shared import PendingMixin
33from sideshow.web.util import make_new_order_batches_grid, make_orders_grid
36class LocalProductView(MasterView): # pylint: disable=abstract-method
37 """
38 Master view for :class:`~sideshow.db.model.products.LocalProduct`;
39 route prefix is ``local_products``.
41 Notable URLs provided by this class:
43 * ``/local/products/``
44 * ``/local/products/new``
45 * ``/local/products/XXX``
46 * ``/local/products/XXX/edit``
47 * ``/local/products/XXX/delete``
48 """
50 model_class = LocalProduct
51 model_title = "Local Product"
52 route_prefix = "local_products"
53 url_prefix = "/local/products"
55 labels = {
56 "external_id": "External ID",
57 "department_id": "Department ID",
58 }
60 grid_columns = [
61 "scancode",
62 "brand_name",
63 "description",
64 "size",
65 "department_name",
66 "special_order",
67 "case_size",
68 "unit_cost",
69 "unit_price_reg",
70 ]
72 sort_defaults = "scancode"
74 # pylint: disable=duplicate-code
75 form_fields = [
76 "external_id",
77 "scancode",
78 "brand_name",
79 "description",
80 "size",
81 "department_id",
82 "department_name",
83 "special_order",
84 "vendor_name",
85 "vendor_item_code",
86 "case_size",
87 "unit_cost",
88 "unit_price_reg",
89 "notes",
90 "orders",
91 "new_order_batches",
92 ]
93 # pylint: enable=duplicate-code
95 def configure_grid(self, grid): # pylint: disable=empty-docstring
96 """ """
97 g = grid
98 super().configure_grid(g)
100 # unit_cost
101 g.set_renderer("unit_cost", "currency", scale=4)
103 # unit_price_reg
104 g.set_label("unit_price_reg", "Reg. Price", column_only=True)
105 g.set_renderer("unit_price_reg", "currency")
107 # links
108 g.set_link("scancode")
109 g.set_link("brand_name")
110 g.set_link("description")
111 g.set_link("size")
113 def configure_form(self, form): # pylint: disable=empty-docstring
114 """ """
115 f = form
116 super().configure_form(f)
117 product = f.model_instance
119 # external_id
120 if self.creating:
121 f.remove("external_id")
122 else:
123 f.set_readonly("external_id")
125 # TODO: should not have to explicitly mark these nodes
126 # as required=False.. i guess i do for now b/c i am
127 # totally overriding the node from colanderlachemy
129 # case_size
130 f.set_node("case_size", WuttaQuantity(self.request))
131 f.set_required("case_size", False)
133 # unit_cost
134 f.set_node("unit_cost", WuttaMoney(self.request, scale=4))
135 f.set_required("unit_cost", False)
137 # unit_price_reg
138 f.set_node("unit_price_reg", WuttaMoney(self.request))
139 f.set_required("unit_price_reg", False)
141 # notes
142 f.set_widget("notes", "notes")
144 # orders
145 if self.creating or self.editing:
146 f.remove("orders")
147 else:
148 f.set_grid("orders", self.make_orders_grid(product))
150 # new_order_batches
151 if self.creating or self.editing:
152 f.remove("new_order_batches")
153 else:
154 f.set_grid("new_order_batches", self.make_new_order_batches_grid(product))
156 def make_orders_grid(self, product):
157 """
158 Make and return the grid for the Orders field.
159 """
160 orders = {item.order for item in product.order_items}
161 orders = sorted(orders, key=lambda order: order.order_id)
163 return make_orders_grid(
164 self.request, route_prefix=self.get_route_prefix(), data=orders
165 )
167 def make_new_order_batches_grid(self, product):
168 """
169 Make and return the grid for the New Order Batches field.
170 """
171 batches = {row.batch for row in product.new_order_batch_rows}
172 batches = sorted(batches, key=lambda batch: batch.id)
174 return make_new_order_batches_grid(
175 self.request,
176 route_prefix=self.get_route_prefix(),
177 data=batches,
178 )
181class PendingProductView(PendingMixin, MasterView): # pylint: disable=abstract-method
182 """
183 Master view for
184 :class:`~sideshow.db.model.products.PendingProduct`; route
185 prefix is ``pending_products``.
187 Notable URLs provided by this class:
189 * ``/pending/products/``
190 * ``/pending/products/new``
191 * ``/pending/products/XXX``
192 * ``/pending/products/XXX/edit``
193 * ``/pending/products/XXX/delete``
194 """
196 model_class = PendingProduct
197 model_title = "Pending Product"
198 route_prefix = "pending_products"
199 url_prefix = "/pending/products"
201 labels = {
202 "department_id": "Department ID",
203 "product_id": "Product ID",
204 }
206 grid_columns = [
207 "scancode",
208 "department_name",
209 "brand_name",
210 "description",
211 "size",
212 "unit_cost",
213 "case_size",
214 "unit_price_reg",
215 "special_order",
216 "status",
217 "created",
218 "created_by",
219 ]
221 sort_defaults = ("created", "desc")
223 filter_defaults = {
224 "status": {"active": True, "value": PendingProductStatus.READY.name},
225 }
227 form_fields = [
228 "product_id",
229 "scancode",
230 "department_id",
231 "department_name",
232 "brand_name",
233 "description",
234 "size",
235 "vendor_name",
236 "vendor_item_code",
237 "unit_cost",
238 "case_size",
239 "unit_price_reg",
240 "special_order",
241 "notes",
242 "created",
243 "created_by",
244 "orders",
245 "new_order_batches",
246 ]
248 def configure_grid(self, grid): # pylint: disable=empty-docstring
249 """ """
250 g = grid
251 super().configure_grid(g)
252 enum = self.app.enum
254 # unit_cost
255 g.set_renderer("unit_cost", "currency", scale=4)
257 # unit_price_reg
258 g.set_label("unit_price_reg", "Reg. Price", column_only=True)
259 g.set_renderer("unit_price_reg", "currency")
261 # status
262 g.set_enum("status", enum.PendingProductStatus)
264 # links
265 g.set_link("scancode")
266 g.set_link("brand_name")
267 g.set_link("description")
268 g.set_link("size")
270 def grid_row_class( # pylint: disable=unused-argument,empty-docstring
271 self, product, data, i
272 ):
273 """ """
274 enum = self.app.enum
275 if product.status == enum.PendingProductStatus.IGNORED:
276 return "has-background-warning"
277 return None
279 def configure_form(self, form): # pylint: disable=empty-docstring
280 """ """
281 f = form
282 super().configure_form(f)
284 self.configure_form_pending(f)
286 # product_id
287 if self.creating:
288 f.remove("product_id")
289 else:
290 f.set_readonly("product_id")
292 # unit_price_reg
293 f.set_node("unit_price_reg", WuttaMoney(self.request))
295 # notes
296 f.set_widget("notes", "notes")
298 def make_orders_grid(self, product):
299 """
300 Make and return the grid for the Orders field.
301 """
302 orders = {item.order for item in product.order_items}
303 orders = sorted(orders, key=lambda order: order.order_id)
305 return make_orders_grid(
306 self.request, route_prefix=self.get_route_prefix(), data=orders
307 )
309 def make_new_order_batches_grid(self, product):
310 """
311 Make and return the grid for the New Order Batches field.
312 """
313 batches = {row.batch for row in product.new_order_batch_rows}
314 batches = sorted(batches, key=lambda batch: batch.id)
316 return make_new_order_batches_grid(
317 self.request,
318 route_prefix=self.get_route_prefix(),
319 data=batches,
320 )
322 def get_template_context(self, context): # pylint: disable=empty-docstring
323 """ """
324 enum = self.app.enum
326 if self.viewing:
327 product = context["instance"]
328 if product.status == enum.PendingProductStatus.READY and self.has_any_perm(
329 "resolve", "ignore"
330 ):
331 handler = self.app.get_batch_handler("neworder")
332 context["use_local_products"] = handler.use_local_products()
334 return context
336 def delete_instance(self, obj): # pylint: disable=empty-docstring
337 """ """
338 product = obj
340 # avoid deleting if still referenced by new order batch(es)
341 for row in product.new_order_batch_rows:
342 if not row.batch.executed:
343 model_title = self.get_model_title()
344 self.request.session.flash(
345 f"Cannot delete {model_title} still attached "
346 "to New Order Batch(es)",
347 "warning",
348 )
349 raise self.redirect(self.get_action_url("view", product))
351 # go ahead and delete per usual
352 super().delete_instance(product)
354 def resolve(self):
355 """
356 View to "resolve" a :term:`pending product` with the real
357 :term:`external product`.
359 This view requires POST, with ``product_id`` referencing the
360 desired external product.
362 It will call
363 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()`
364 to fetch product info, then with that it calls
365 :meth:`~sideshow.orders.OrderHandler.resolve_pending_product()`
366 to update related :term:`order items <order item>` etc.
368 See also :meth:`ignore()`.
369 """
370 enum = self.app.enum
371 session = self.Session()
372 product = self.get_instance()
374 if product.status != enum.PendingProductStatus.READY:
375 self.request.session.flash(
376 "pending product does not have 'ready' status!", "error"
377 )
378 return self.redirect(self.get_action_url("view", product))
380 product_id = self.request.POST.get("product_id")
381 if not product_id:
382 self.request.session.flash("must specify valid product_id", "error")
383 return self.redirect(self.get_action_url("view", product))
385 batch_handler = self.app.get_batch_handler("neworder")
386 order_handler = self.app.get_order_handler()
388 info = batch_handler.get_product_info_external(session, product_id)
389 order_handler.resolve_pending_product(product, info, self.request.user)
391 return self.redirect(self.get_action_url("view", product))
393 def ignore(self):
394 """
395 View to "ignore" a :term:`pending product` so the user is no
396 longer prompted to resolve it.
398 This view requires POST; it merely sets the product status to
399 "ignored".
401 See also :meth:`resolve()`.
402 """
403 enum = self.app.enum
404 product = self.get_instance()
406 if product.status != enum.PendingProductStatus.READY:
407 self.request.session.flash(
408 "pending product does not have 'ready' status!", "error"
409 )
410 return self.redirect(self.get_action_url("view", product))
412 product.status = enum.PendingProductStatus.IGNORED
413 return self.redirect(self.get_action_url("view", product))
415 @classmethod
416 def defaults(cls, config): # pylint: disable=empty-docstring
417 """ """
418 cls._defaults(config)
419 cls._pending_product_defaults(config)
421 @classmethod
422 def _pending_product_defaults(cls, config):
423 route_prefix = cls.get_route_prefix()
424 permission_prefix = cls.get_permission_prefix()
425 instance_url_prefix = cls.get_instance_url_prefix()
426 model_title = cls.get_model_title()
428 # resolve
429 config.add_wutta_permission(
430 permission_prefix, f"{permission_prefix}.resolve", f"Resolve {model_title}"
431 )
432 config.add_route(
433 f"{route_prefix}.resolve",
434 f"{instance_url_prefix}/resolve",
435 request_method="POST",
436 )
437 config.add_view(
438 cls,
439 attr="resolve",
440 route_name=f"{route_prefix}.resolve",
441 permission=f"{permission_prefix}.resolve",
442 )
444 # ignore
445 config.add_wutta_permission(
446 permission_prefix, f"{permission_prefix}.ignore", f"Ignore {model_title}"
447 )
448 config.add_route(
449 f"{route_prefix}.ignore",
450 f"{instance_url_prefix}/ignore",
451 request_method="POST",
452 )
453 config.add_view(
454 cls,
455 attr="ignore",
456 route_name=f"{route_prefix}.ignore",
457 permission=f"{permission_prefix}.ignore",
458 )
461def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
462 base = globals()
464 LocalProductView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name
465 "LocalProductView", base["LocalProductView"]
466 )
467 LocalProductView.defaults(config)
469 PendingProductView = ( # pylint: disable=redefined-outer-name,invalid-name
470 kwargs.get("PendingProductView", base["PendingProductView"])
471 )
472 PendingProductView.defaults(config)
475def includeme(config): # pylint: disable=missing-function-docstring
476 defaults(config)