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

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 WuttaMoney, WuttaQuantity 

29 

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 

34 

35 

36class LocalProductView(MasterView): # pylint: disable=abstract-method 

37 """ 

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

39 route prefix is ``local_products``. 

40 

41 Notable URLs provided by this class: 

42 

43 * ``/local/products/`` 

44 * ``/local/products/new`` 

45 * ``/local/products/XXX`` 

46 * ``/local/products/XXX/edit`` 

47 * ``/local/products/XXX/delete`` 

48 """ 

49 

50 model_class = LocalProduct 

51 model_title = "Local Product" 

52 route_prefix = "local_products" 

53 url_prefix = "/local/products" 

54 

55 labels = { 

56 "external_id": "External ID", 

57 "department_id": "Department ID", 

58 } 

59 

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 ] 

71 

72 sort_defaults = "scancode" 

73 

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 

94 

95 def configure_grid(self, grid): # pylint: disable=empty-docstring 

96 """ """ 

97 g = grid 

98 super().configure_grid(g) 

99 

100 # unit_cost 

101 g.set_renderer("unit_cost", "currency", scale=4) 

102 

103 # unit_price_reg 

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

105 g.set_renderer("unit_price_reg", "currency") 

106 

107 # links 

108 g.set_link("scancode") 

109 g.set_link("brand_name") 

110 g.set_link("description") 

111 g.set_link("size") 

112 

113 def configure_form(self, form): # pylint: disable=empty-docstring 

114 """ """ 

115 f = form 

116 super().configure_form(f) 

117 product = f.model_instance 

118 

119 # external_id 

120 if self.creating: 

121 f.remove("external_id") 

122 else: 

123 f.set_readonly("external_id") 

124 

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 

128 

129 # case_size 

130 f.set_node("case_size", WuttaQuantity(self.request)) 

131 f.set_required("case_size", False) 

132 

133 # unit_cost 

134 f.set_node("unit_cost", WuttaMoney(self.request, scale=4)) 

135 f.set_required("unit_cost", False) 

136 

137 # unit_price_reg 

138 f.set_node("unit_price_reg", WuttaMoney(self.request)) 

139 f.set_required("unit_price_reg", False) 

140 

141 # notes 

142 f.set_widget("notes", "notes") 

143 

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

149 

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

155 

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) 

162 

163 return make_orders_grid( 

164 self.request, route_prefix=self.get_route_prefix(), data=orders 

165 ) 

166 

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) 

173 

174 return make_new_order_batches_grid( 

175 self.request, 

176 route_prefix=self.get_route_prefix(), 

177 data=batches, 

178 ) 

179 

180 

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``. 

186 

187 Notable URLs provided by this class: 

188 

189 * ``/pending/products/`` 

190 * ``/pending/products/new`` 

191 * ``/pending/products/XXX`` 

192 * ``/pending/products/XXX/edit`` 

193 * ``/pending/products/XXX/delete`` 

194 """ 

195 

196 model_class = PendingProduct 

197 model_title = "Pending Product" 

198 route_prefix = "pending_products" 

199 url_prefix = "/pending/products" 

200 

201 labels = { 

202 "department_id": "Department ID", 

203 "product_id": "Product ID", 

204 } 

205 

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 ] 

220 

221 sort_defaults = ("created", "desc") 

222 

223 filter_defaults = { 

224 "status": {"active": True, "value": PendingProductStatus.READY.name}, 

225 } 

226 

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 ] 

247 

248 def configure_grid(self, grid): # pylint: disable=empty-docstring 

249 """ """ 

250 g = grid 

251 super().configure_grid(g) 

252 enum = self.app.enum 

253 

254 # unit_cost 

255 g.set_renderer("unit_cost", "currency", scale=4) 

256 

257 # unit_price_reg 

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

259 g.set_renderer("unit_price_reg", "currency") 

260 

261 # status 

262 g.set_enum("status", enum.PendingProductStatus) 

263 

264 # links 

265 g.set_link("scancode") 

266 g.set_link("brand_name") 

267 g.set_link("description") 

268 g.set_link("size") 

269 

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 

278 

279 def configure_form(self, form): # pylint: disable=empty-docstring 

280 """ """ 

281 f = form 

282 super().configure_form(f) 

283 

284 self.configure_form_pending(f) 

285 

286 # product_id 

287 if self.creating: 

288 f.remove("product_id") 

289 else: 

290 f.set_readonly("product_id") 

291 

292 # unit_price_reg 

293 f.set_node("unit_price_reg", WuttaMoney(self.request)) 

294 

295 # notes 

296 f.set_widget("notes", "notes") 

297 

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) 

304 

305 return make_orders_grid( 

306 self.request, route_prefix=self.get_route_prefix(), data=orders 

307 ) 

308 

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) 

315 

316 return make_new_order_batches_grid( 

317 self.request, 

318 route_prefix=self.get_route_prefix(), 

319 data=batches, 

320 ) 

321 

322 def get_template_context(self, context): # pylint: disable=empty-docstring 

323 """ """ 

324 enum = self.app.enum 

325 

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

333 

334 return context 

335 

336 def delete_instance(self, obj): # pylint: disable=empty-docstring 

337 """ """ 

338 product = obj 

339 

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

350 

351 # go ahead and delete per usual 

352 super().delete_instance(product) 

353 

354 def resolve(self): 

355 """ 

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

357 :term:`external product`. 

358 

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

360 desired external product. 

361 

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. 

367 

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

369 """ 

370 enum = self.app.enum 

371 session = self.Session() 

372 product = self.get_instance() 

373 

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

379 

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

384 

385 batch_handler = self.app.get_batch_handler("neworder") 

386 order_handler = self.app.get_order_handler() 

387 

388 info = batch_handler.get_product_info_external(session, product_id) 

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

390 

391 return self.redirect(self.get_action_url("view", product)) 

392 

393 def ignore(self): 

394 """ 

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

396 longer prompted to resolve it. 

397 

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

399 "ignored". 

400 

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

402 """ 

403 enum = self.app.enum 

404 product = self.get_instance() 

405 

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

411 

412 product.status = enum.PendingProductStatus.IGNORED 

413 return self.redirect(self.get_action_url("view", product)) 

414 

415 @classmethod 

416 def defaults(cls, config): # pylint: disable=empty-docstring 

417 """ """ 

418 cls._defaults(config) 

419 cls._pending_product_defaults(config) 

420 

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

427 

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 ) 

443 

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 ) 

459 

460 

461def defaults(config, **kwargs): # pylint: disable=missing-function-docstring 

462 base = globals() 

463 

464 LocalProductView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name 

465 "LocalProductView", base["LocalProductView"] 

466 ) 

467 LocalProductView.defaults(config) 

468 

469 PendingProductView = ( # pylint: disable=redefined-outer-name,invalid-name 

470 kwargs.get("PendingProductView", base["PendingProductView"]) 

471 ) 

472 PendingProductView.defaults(config) 

473 

474 

475def includeme(config): # pylint: disable=missing-function-docstring 

476 defaults(config)