Coverage for .tox/coverage/lib/python3.11/site-packages/sideshow/web/views/products.py: 100%
196 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-16 07:16 -0500
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-16 07:16 -0500
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 UserRef, WuttaEnum, WuttaMoney, WuttaQuantity
30from sideshow.enum import PendingProductStatus
31from sideshow.db.model import LocalProduct, PendingProduct
34class LocalProductView(MasterView):
35 """
36 Master view for :class:`~sideshow.db.model.products.LocalProduct`;
37 route prefix is ``local_products``.
39 Notable URLs provided by this class:
41 * ``/local/products/``
42 * ``/local/products/new``
43 * ``/local/products/XXX``
44 * ``/local/products/XXX/edit``
45 * ``/local/products/XXX/delete``
46 """
47 model_class = LocalProduct
48 model_title = "Local Product"
49 route_prefix = 'local_products'
50 url_prefix = '/local/products'
52 labels = {
53 'external_id': "External ID",
54 'department_id': "Department ID",
55 }
57 grid_columns = [
58 'scancode',
59 'brand_name',
60 'description',
61 'size',
62 'department_name',
63 'special_order',
64 'case_size',
65 'unit_cost',
66 'unit_price_reg',
67 ]
69 sort_defaults = 'scancode'
71 form_fields = [
72 'external_id',
73 'scancode',
74 'brand_name',
75 'description',
76 'size',
77 'department_id',
78 'department_name',
79 'special_order',
80 'vendor_name',
81 'vendor_item_code',
82 'case_size',
83 'unit_cost',
84 'unit_price_reg',
85 'notes',
86 'orders',
87 'new_order_batches',
88 ]
90 def configure_grid(self, g):
91 """ """
92 super().configure_grid(g)
94 # unit_cost
95 g.set_renderer('unit_cost', 'currency', scale=4)
97 # unit_price_reg
98 g.set_label('unit_price_reg', "Reg. Price", column_only=True)
99 g.set_renderer('unit_price_reg', 'currency')
101 # links
102 g.set_link('scancode')
103 g.set_link('brand_name')
104 g.set_link('description')
105 g.set_link('size')
107 def configure_form(self, f):
108 """ """
109 super().configure_form(f)
110 enum = self.app.enum
111 product = f.model_instance
113 # external_id
114 if self.creating:
115 f.remove('external_id')
116 else:
117 f.set_readonly('external_id')
119 # TODO: should not have to explicitly mark these nodes
120 # as required=False.. i guess i do for now b/c i am
121 # totally overriding the node from colanderlachemy
123 # case_size
124 f.set_node('case_size', WuttaQuantity(self.request))
125 f.set_required('case_size', False)
127 # unit_cost
128 f.set_node('unit_cost', WuttaMoney(self.request, scale=4))
129 f.set_required('unit_cost', False)
131 # unit_price_reg
132 f.set_node('unit_price_reg', WuttaMoney(self.request))
133 f.set_required('unit_price_reg', False)
135 # notes
136 f.set_widget('notes', 'notes')
138 # orders
139 if self.creating or self.editing:
140 f.remove('orders')
141 else:
142 f.set_grid('orders', self.make_orders_grid(product))
144 # new_order_batches
145 if self.creating or self.editing:
146 f.remove('new_order_batches')
147 else:
148 f.set_grid('new_order_batches', self.make_new_order_batches_grid(product))
150 def make_orders_grid(self, product):
151 """
152 Make and return the grid for the Orders field.
153 """
154 model = self.app.model
155 route_prefix = self.get_route_prefix()
157 orders = set([item.order for item in product.order_items])
158 orders = sorted(orders, key=lambda order: order.order_id)
160 grid = self.make_grid(key=f'{route_prefix}.view.orders',
161 model_class=model.Order,
162 data=orders,
163 columns=[
164 'order_id',
165 'total_price',
166 'created',
167 'created_by',
168 ],
169 labels={
170 'order_id': "Order ID",
171 },
172 renderers={
173 'total_price': 'currency',
174 })
176 if self.request.has_perm('orders.view'):
177 url = lambda order, i: self.request.route_url('orders.view', uuid=order.uuid)
178 grid.add_action('view', icon='eye', url=url)
179 grid.set_link('order_id')
181 return grid
183 def make_new_order_batches_grid(self, product):
184 """
185 Make and return the grid for the New Order Batches field.
186 """
187 model = self.app.model
188 route_prefix = self.get_route_prefix()
190 batches = set([row.batch for row in product.new_order_batch_rows])
191 batches = sorted(batches, key=lambda batch: batch.id)
193 grid = self.make_grid(key=f'{route_prefix}.view.new_order_batches',
194 model_class=model.NewOrderBatch,
195 data=batches,
196 columns=[
197 'id',
198 'total_price',
199 'created',
200 'created_by',
201 'executed',
202 ],
203 labels={
204 'id': "Batch ID",
205 'status_code': "Status",
206 },
207 renderers={
208 'id': 'batch_id',
209 })
211 if self.request.has_perm('neworder_batches.view'):
212 url = lambda batch, i: self.request.route_url('neworder_batches.view', uuid=batch.uuid)
213 grid.add_action('view', icon='eye', url=url)
214 grid.set_link('id')
216 return grid
219class PendingProductView(MasterView):
220 """
221 Master view for
222 :class:`~sideshow.db.model.products.PendingProduct`; route
223 prefix is ``pending_products``.
225 Notable URLs provided by this class:
227 * ``/pending/products/``
228 * ``/pending/products/new``
229 * ``/pending/products/XXX``
230 * ``/pending/products/XXX/edit``
231 * ``/pending/products/XXX/delete``
232 """
233 model_class = PendingProduct
234 model_title = "Pending Product"
235 route_prefix = 'pending_products'
236 url_prefix = '/pending/products'
238 labels = {
239 'department_id': "Department ID",
240 'product_id': "Product ID",
241 }
243 grid_columns = [
244 'scancode',
245 'department_name',
246 'brand_name',
247 'description',
248 'size',
249 'unit_cost',
250 'case_size',
251 'unit_price_reg',
252 'special_order',
253 'status',
254 'created',
255 'created_by',
256 ]
258 sort_defaults = ('created', 'desc')
260 filter_defaults = {
261 'status': {'active': True,
262 'value': PendingProductStatus.READY.name},
263 }
265 form_fields = [
266 'product_id',
267 'scancode',
268 'department_id',
269 'department_name',
270 'brand_name',
271 'description',
272 'size',
273 'vendor_name',
274 'vendor_item_code',
275 'unit_cost',
276 'case_size',
277 'unit_price_reg',
278 'special_order',
279 'notes',
280 'created',
281 'created_by',
282 'orders',
283 'new_order_batches',
284 ]
286 def configure_grid(self, g):
287 """ """
288 super().configure_grid(g)
289 enum = self.app.enum
291 # unit_cost
292 g.set_renderer('unit_cost', 'currency', scale=4)
294 # unit_price_reg
295 g.set_label('unit_price_reg', "Reg. Price", column_only=True)
296 g.set_renderer('unit_price_reg', 'currency')
298 # status
299 g.set_enum('status', enum.PendingProductStatus)
301 # links
302 g.set_link('scancode')
303 g.set_link('brand_name')
304 g.set_link('description')
305 g.set_link('size')
307 def grid_row_class(self, product, data, i):
308 """ """
309 enum = self.app.enum
310 if product.status == enum.PendingProductStatus.IGNORED:
311 return 'has-background-warning'
313 def configure_form(self, f):
314 """ """
315 super().configure_form(f)
316 enum = self.app.enum
317 product = f.model_instance
319 # product_id
320 if self.creating:
321 f.remove('product_id')
322 else:
323 f.set_readonly('product_id')
325 # unit_price_reg
326 f.set_node('unit_price_reg', WuttaMoney(self.request))
328 # notes
329 f.set_widget('notes', 'notes')
331 # created
332 if self.creating:
333 f.remove('created')
334 else:
335 f.set_readonly('created')
337 # created_by
338 if self.creating:
339 f.remove('created_by')
340 else:
341 f.set_node('created_by', UserRef(self.request))
342 f.set_readonly('created_by')
344 # orders
345 if self.creating or self.editing:
346 f.remove('orders')
347 else:
348 f.set_grid('orders', self.make_orders_grid(product))
350 # new_order_batches
351 if self.creating or self.editing:
352 f.remove('new_order_batches')
353 else:
354 f.set_grid('new_order_batches', self.make_new_order_batches_grid(product))
356 def make_orders_grid(self, product):
357 """
358 Make and return the grid for the Orders field.
359 """
360 model = self.app.model
361 route_prefix = self.get_route_prefix()
363 orders = set([item.order for item in product.order_items])
364 orders = sorted(orders, key=lambda order: order.order_id)
366 grid = self.make_grid(key=f'{route_prefix}.view.orders',
367 model_class=model.Order,
368 data=orders,
369 columns=[
370 'order_id',
371 'total_price',
372 'created',
373 'created_by',
374 ],
375 labels={
376 'order_id': "Order ID",
377 },
378 renderers={
379 'total_price': 'currency',
380 })
382 if self.request.has_perm('orders.view'):
383 url = lambda order, i: self.request.route_url('orders.view', uuid=order.uuid)
384 grid.add_action('view', icon='eye', url=url)
385 grid.set_link('order_id')
387 return grid
389 def make_new_order_batches_grid(self, product):
390 """
391 Make and return the grid for the New Order Batches field.
392 """
393 model = self.app.model
394 route_prefix = self.get_route_prefix()
396 batches = set([row.batch for row in product.new_order_batch_rows])
397 batches = sorted(batches, key=lambda batch: batch.id)
399 grid = self.make_grid(key=f'{route_prefix}.view.new_order_batches',
400 model_class=model.NewOrderBatch,
401 data=batches,
402 columns=[
403 'id',
404 'total_price',
405 'created',
406 'created_by',
407 'executed',
408 ],
409 labels={
410 'id': "Batch ID",
411 'status_code': "Status",
412 },
413 renderers={
414 'id': 'batch_id',
415 })
417 if self.request.has_perm('neworder_batches.view'):
418 url = lambda batch, i: self.request.route_url('neworder_batches.view', uuid=batch.uuid)
419 grid.add_action('view', icon='eye', url=url)
420 grid.set_link('id')
422 return grid
424 def get_template_context(self, context):
425 """ """
426 enum = self.app.enum
428 if self.viewing:
429 product = context['instance']
430 if (product.status == enum.PendingProductStatus.READY
431 and self.has_any_perm('resolve', 'ignore')):
432 handler = self.app.get_batch_handler('neworder')
433 context['use_local_products'] = handler.use_local_products()
435 return context
437 def delete_instance(self, product):
438 """ """
440 # avoid deleting if still referenced by new order batch(es)
441 for row in product.new_order_batch_rows:
442 if not row.batch.executed:
443 model_title = self.get_model_title()
444 self.request.session.flash(f"Cannot delete {model_title} still attached "
445 "to New Order Batch(es)", 'warning')
446 raise self.redirect(self.get_action_url('view', product))
448 # go ahead and delete per usual
449 super().delete_instance(product)
451 def resolve(self):
452 """
453 View to "resolve" a :term:`pending product` with the real
454 :term:`external product`.
456 This view requires POST, with ``product_id`` referencing the
457 desired external product.
459 It will call
460 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()`
461 to fetch product info, then with that it calls
462 :meth:`~sideshow.orders.OrderHandler.resolve_pending_product()`
463 to update related :term:`order items <order item>` etc.
465 See also :meth:`ignore()`.
466 """
467 enum = self.app.enum
468 session = self.Session()
469 product = self.get_instance()
471 if product.status != enum.PendingProductStatus.READY:
472 self.request.session.flash("pending product does not have 'ready' status!", 'error')
473 return self.redirect(self.get_action_url('view', product))
475 product_id = self.request.POST.get('product_id')
476 if not product_id:
477 self.request.session.flash("must specify valid product_id", 'error')
478 return self.redirect(self.get_action_url('view', product))
480 batch_handler = self.app.get_batch_handler('neworder')
481 order_handler = self.app.get_order_handler()
483 info = batch_handler.get_product_info_external(session, product_id)
484 order_handler.resolve_pending_product(product, info, self.request.user)
486 return self.redirect(self.get_action_url('view', product))
488 def ignore(self):
489 """
490 View to "ignore" a :term:`pending product` so the user is no
491 longer prompted to resolve it.
493 This view requires POST; it merely sets the product status to
494 "ignored".
496 See also :meth:`resolve()`.
497 """
498 enum = self.app.enum
499 product = self.get_instance()
501 if product.status != enum.PendingProductStatus.READY:
502 self.request.session.flash("pending product does not have 'ready' status!", 'error')
503 return self.redirect(self.get_action_url('view', product))
505 product.status = enum.PendingProductStatus.IGNORED
506 return self.redirect(self.get_action_url('view', product))
508 @classmethod
509 def defaults(cls, config):
510 """ """
511 cls._defaults(config)
512 cls._pending_product_defaults(config)
514 @classmethod
515 def _pending_product_defaults(cls, config):
516 route_prefix = cls.get_route_prefix()
517 permission_prefix = cls.get_permission_prefix()
518 instance_url_prefix = cls.get_instance_url_prefix()
519 model_title = cls.get_model_title()
521 # resolve
522 config.add_wutta_permission(permission_prefix,
523 f'{permission_prefix}.resolve',
524 f"Resolve {model_title}")
525 config.add_route(f'{route_prefix}.resolve',
526 f'{instance_url_prefix}/resolve',
527 request_method='POST')
528 config.add_view(cls, attr='resolve',
529 route_name=f'{route_prefix}.resolve',
530 permission=f'{permission_prefix}.resolve')
532 # ignore
533 config.add_wutta_permission(permission_prefix,
534 f'{permission_prefix}.ignore',
535 f"Ignore {model_title}")
536 config.add_route(f'{route_prefix}.ignore',
537 f'{instance_url_prefix}/ignore',
538 request_method='POST')
539 config.add_view(cls, attr='ignore',
540 route_name=f'{route_prefix}.ignore',
541 permission=f'{permission_prefix}.ignore')
544def defaults(config, **kwargs):
545 base = globals()
547 LocalProductView = kwargs.get('LocalProductView', base['LocalProductView'])
548 LocalProductView.defaults(config)
550 PendingProductView = kwargs.get('PendingProductView', base['PendingProductView'])
551 PendingProductView.defaults(config)
554def includeme(config):
555 defaults(config)