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

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""" 

26 

27from wuttaweb.views import MasterView 

28from wuttaweb.forms.schema import UserRef, WuttaEnum, WuttaMoney, WuttaQuantity 

29 

30from sideshow.enum import PendingProductStatus 

31from sideshow.db.model import LocalProduct, PendingProduct 

32 

33 

34class LocalProductView(MasterView): 

35 """ 

36 Master view for :class:`~sideshow.db.model.products.LocalProduct`; 

37 route prefix is ``local_products``. 

38 

39 Notable URLs provided by this class: 

40 

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' 

51 

52 labels = { 

53 'external_id': "External ID", 

54 'department_id': "Department ID", 

55 } 

56 

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 ] 

68 

69 sort_defaults = 'scancode' 

70 

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 ] 

89 

90 def configure_grid(self, g): 

91 """ """ 

92 super().configure_grid(g) 

93 

94 # unit_cost 

95 g.set_renderer('unit_cost', 'currency', scale=4) 

96 

97 # unit_price_reg 

98 g.set_label('unit_price_reg', "Reg. Price", column_only=True) 

99 g.set_renderer('unit_price_reg', 'currency') 

100 

101 # links 

102 g.set_link('scancode') 

103 g.set_link('brand_name') 

104 g.set_link('description') 

105 g.set_link('size') 

106 

107 def configure_form(self, f): 

108 """ """ 

109 super().configure_form(f) 

110 enum = self.app.enum 

111 product = f.model_instance 

112 

113 # external_id 

114 if self.creating: 

115 f.remove('external_id') 

116 else: 

117 f.set_readonly('external_id') 

118 

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 

122 

123 # case_size 

124 f.set_node('case_size', WuttaQuantity(self.request)) 

125 f.set_required('case_size', False) 

126 

127 # unit_cost 

128 f.set_node('unit_cost', WuttaMoney(self.request, scale=4)) 

129 f.set_required('unit_cost', False) 

130 

131 # unit_price_reg 

132 f.set_node('unit_price_reg', WuttaMoney(self.request)) 

133 f.set_required('unit_price_reg', False) 

134 

135 # notes 

136 f.set_widget('notes', 'notes') 

137 

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)) 

143 

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)) 

149 

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() 

156 

157 orders = set([item.order for item in product.order_items]) 

158 orders = sorted(orders, key=lambda order: order.order_id) 

159 

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 }) 

175 

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') 

180 

181 return grid 

182 

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() 

189 

190 batches = set([row.batch for row in product.new_order_batch_rows]) 

191 batches = sorted(batches, key=lambda batch: batch.id) 

192 

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 }) 

210 

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') 

215 

216 return grid 

217 

218 

219class PendingProductView(MasterView): 

220 """ 

221 Master view for 

222 :class:`~sideshow.db.model.products.PendingProduct`; route 

223 prefix is ``pending_products``. 

224 

225 Notable URLs provided by this class: 

226 

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' 

237 

238 labels = { 

239 'department_id': "Department ID", 

240 'product_id': "Product ID", 

241 } 

242 

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 ] 

257 

258 sort_defaults = ('created', 'desc') 

259 

260 filter_defaults = { 

261 'status': {'active': True, 

262 'value': PendingProductStatus.READY.name}, 

263 } 

264 

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 ] 

285 

286 def configure_grid(self, g): 

287 """ """ 

288 super().configure_grid(g) 

289 enum = self.app.enum 

290 

291 # unit_cost 

292 g.set_renderer('unit_cost', 'currency', scale=4) 

293 

294 # unit_price_reg 

295 g.set_label('unit_price_reg', "Reg. Price", column_only=True) 

296 g.set_renderer('unit_price_reg', 'currency') 

297 

298 # status 

299 g.set_enum('status', enum.PendingProductStatus) 

300 

301 # links 

302 g.set_link('scancode') 

303 g.set_link('brand_name') 

304 g.set_link('description') 

305 g.set_link('size') 

306 

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' 

312 

313 def configure_form(self, f): 

314 """ """ 

315 super().configure_form(f) 

316 enum = self.app.enum 

317 product = f.model_instance 

318 

319 # product_id 

320 if self.creating: 

321 f.remove('product_id') 

322 else: 

323 f.set_readonly('product_id') 

324 

325 # unit_price_reg 

326 f.set_node('unit_price_reg', WuttaMoney(self.request)) 

327 

328 # notes 

329 f.set_widget('notes', 'notes') 

330 

331 # created 

332 if self.creating: 

333 f.remove('created') 

334 else: 

335 f.set_readonly('created') 

336 

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') 

343 

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)) 

349 

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)) 

355 

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() 

362 

363 orders = set([item.order for item in product.order_items]) 

364 orders = sorted(orders, key=lambda order: order.order_id) 

365 

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 }) 

381 

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') 

386 

387 return grid 

388 

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() 

395 

396 batches = set([row.batch for row in product.new_order_batch_rows]) 

397 batches = sorted(batches, key=lambda batch: batch.id) 

398 

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 }) 

416 

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') 

421 

422 return grid 

423 

424 def get_template_context(self, context): 

425 """ """ 

426 enum = self.app.enum 

427 

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() 

434 

435 return context 

436 

437 def delete_instance(self, product): 

438 """ """ 

439 

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)) 

447 

448 # go ahead and delete per usual 

449 super().delete_instance(product) 

450 

451 def resolve(self): 

452 """ 

453 View to "resolve" a :term:`pending product` with the real 

454 :term:`external product`. 

455 

456 This view requires POST, with ``product_id`` referencing the 

457 desired external product. 

458 

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. 

464 

465 See also :meth:`ignore()`. 

466 """ 

467 enum = self.app.enum 

468 session = self.Session() 

469 product = self.get_instance() 

470 

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)) 

474 

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)) 

479 

480 batch_handler = self.app.get_batch_handler('neworder') 

481 order_handler = self.app.get_order_handler() 

482 

483 info = batch_handler.get_product_info_external(session, product_id) 

484 order_handler.resolve_pending_product(product, info, self.request.user) 

485 

486 return self.redirect(self.get_action_url('view', product)) 

487 

488 def ignore(self): 

489 """ 

490 View to "ignore" a :term:`pending product` so the user is no 

491 longer prompted to resolve it. 

492 

493 This view requires POST; it merely sets the product status to 

494 "ignored". 

495 

496 See also :meth:`resolve()`. 

497 """ 

498 enum = self.app.enum 

499 product = self.get_instance() 

500 

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)) 

504 

505 product.status = enum.PendingProductStatus.IGNORED 

506 return self.redirect(self.get_action_url('view', product)) 

507 

508 @classmethod 

509 def defaults(cls, config): 

510 """ """ 

511 cls._defaults(config) 

512 cls._pending_product_defaults(config) 

513 

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() 

520 

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') 

531 

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') 

542 

543 

544def defaults(config, **kwargs): 

545 base = globals() 

546 

547 LocalProductView = kwargs.get('LocalProductView', base['LocalProductView']) 

548 LocalProductView.defaults(config) 

549 

550 PendingProductView = kwargs.get('PendingProductView', base['PendingProductView']) 

551 PendingProductView.defaults(config) 

552 

553 

554def includeme(config): 

555 defaults(config)