Coverage for .tox / coverage / lib / python3.11 / site-packages / sideshow / web / views / orders.py: 100%

778 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 Orders 

25""" 

26# pylint: disable=too-many-lines 

27 

28import decimal 

29import json 

30import logging 

31import re 

32 

33import sqlalchemy as sa 

34from sqlalchemy import orm 

35 

36from webhelpers2.html import tags, HTML 

37 

38from wuttaweb.views import MasterView 

39from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaDictEnum 

40from wuttaweb.util import make_json_safe 

41 

42from sideshow.db.model import Order, OrderItem 

43from sideshow.web.forms.schema import ( 

44 OrderRef, 

45 LocalCustomerRef, 

46 LocalProductRef, 

47 PendingCustomerRef, 

48 PendingProductRef, 

49) 

50 

51 

52log = logging.getLogger(__name__) 

53 

54 

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

59 

60 Notable URLs provided by this class: 

61 

62 * ``/orders/`` 

63 * ``/orders/new`` 

64 * ``/orders/XXX`` 

65 * ``/orders/XXX/delete`` 

66 

67 Note that the "edit" view is not exposed here; user must perform 

68 various other workflow actions to modify the order. 

69 

70 .. attribute:: order_handler 

71 

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. 

75 

76 .. attribute:: batch_handler 

77 

78 Reference to the :term:`new order batch` handler. This gets 

79 set in the constructor. 

80 """ 

81 

82 model_class = Order 

83 editable = False 

84 configurable = True 

85 

86 labels = { 

87 "order_id": "Order ID", 

88 "store_id": "Store ID", 

89 "customer_id": "Customer ID", 

90 } 

91 

92 grid_columns = [ 

93 "order_id", 

94 "store_id", 

95 "customer_id", 

96 "customer_name", 

97 "total_price", 

98 "created", 

99 "created_by", 

100 ] 

101 

102 sort_defaults = ("order_id", "desc") 

103 

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 

119 

120 has_rows = True 

121 row_model_class = OrderItem 

122 rows_title = "Order Items" 

123 rows_sort_defaults = "sequence" 

124 rows_viewable = True 

125 

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 

137 

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 

154 

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 

170 

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

175 

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

177 """ """ 

178 g = grid 

179 super().configure_grid(g) 

180 

181 # store_id 

182 if not self.order_handler.expose_store_id(): 

183 g.remove("store_id") 

184 

185 # order_id 

186 g.set_link("order_id") 

187 

188 # customer_id 

189 g.set_link("customer_id") 

190 

191 # customer_name 

192 g.set_link("customer_name") 

193 

194 # total_price 

195 g.set_renderer("total_price", g.render_currency) 

196 

197 def create(self): 

198 """ 

199 Instead of the typical "create" view, this displays a "wizard" 

200 of sorts. 

201 

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. 

206 

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. 

212 

213 See also these methods which may be called from this one, 

214 based on user actions: 

215 

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 

232 

233 context = self.get_context_customer(batch) 

234 

235 if self.request.method == "POST": 

236 

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) 

245 

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) 

272 

273 return self.json_response({"error": "unknown form action"}) 

274 

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 ) 

292 

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 ] 

307 

308 # set default so things just work 

309 if not batch.store_id: 

310 batch.store_id = self.batch_handler.get_default_store_id() 

311 

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 } 

324 

325 return self.render_to_response("create", context) 

326 

327 def get_current_batch(self): 

328 """ 

329 Returns the current batch for the current user. 

330 

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. 

334 

335 :returns: 

336 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` 

337 instance 

338 """ 

339 model = self.app.model 

340 session = self.Session() 

341 

342 user = self.request.user 

343 if not user: 

344 raise self.forbidden() 

345 

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 ) 

357 

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

363 

364 return batch 

365 

366 def customer_autocomplete(self): 

367 """ 

368 AJAX view for customer autocomplete, when entering new order. 

369 

370 This invokes one of the following on the 

371 :attr:`batch_handler`: 

372 

373 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_customers_external()` 

374 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_customers_local()` 

375 

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 [] 

383 

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 ) 

392 

393 def product_autocomplete(self): 

394 """ 

395 AJAX view for product autocomplete, when entering new order. 

396 

397 This invokes one of the following on the 

398 :attr:`batch_handler`: 

399 

400 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_products_external()` 

401 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_products_local()` 

402 

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 [] 

410 

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 ) 

419 

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 

432 

433 def get_dept_item_discounts(self): 

434 """ 

435 Returns the list of per-department default item discount settings. 

436 

437 Each entry in the list will look like:: 

438 

439 { 

440 'department_id': '42', 

441 'department_name': 'Grocery', 

442 'default_item_discount': 10, 

443 } 

444 

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 ) 

452 

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 

481 

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. 

487 

488 This is a "batch action" method which may be called from 

489 :meth:`create()`. See also: 

490 

491 * :meth:`cancel_order()` 

492 * :meth:`submit_order()` 

493 """ 

494 session = self.Session() 

495 

496 # drop current batch 

497 self.batch_handler.do_delete(batch, self.request.user) 

498 session.flush() 

499 

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) 

504 

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. 

509 

510 This is a "batch action" method which may be called from 

511 :meth:`create()`. See also: 

512 

513 * :meth:`start_over()` 

514 * :meth:`submit_order()` 

515 """ 

516 session = self.Session() 

517 

518 self.batch_handler.do_delete(batch, self.request.user) 

519 session.flush() 

520 

521 # set flash msg just to be more obvious 

522 self.request.session.flash("New order has been deleted.") 

523 

524 # send user back to orders list, w/ no new batch generated 

525 url = self.get_index_url() 

526 return self.redirect(url) 

527 

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. 

533 

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

540 

541 batch.store_id = store_id 

542 return self.get_context_customer(batch) 

543 

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 } 

554 

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 

563 

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 ) 

576 

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 

585 

586 return context 

587 

588 def assign_customer(self, batch, data): 

589 """ 

590 Assign the true customer account for a batch. 

591 

592 This calls 

593 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()` 

594 for the heavy lifting. 

595 

596 This is a "batch action" method which may be called from 

597 :meth:`create()`. See also: 

598 

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

605 

606 self.batch_handler.set_customer(batch, customer_id) 

607 return self.get_context_customer(batch) 

608 

609 def unassign_customer(self, batch, data): # pylint: disable=unused-argument 

610 """ 

611 Clear the customer info for a batch. 

612 

613 This calls 

614 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()` 

615 for the heavy lifting. 

616 

617 This is a "batch action" method which may be called from 

618 :meth:`create()`. See also: 

619 

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) 

625 

626 def set_pending_customer(self, batch, data): 

627 """ 

628 This will set/update the batch pending customer info. 

629 

630 This calls 

631 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()` 

632 for the heavy lifting. 

633 

634 This is a "batch action" method which may be called from 

635 :meth:`create()`. See also: 

636 

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) 

642 

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. 

648 

649 Depending on config, this calls one of the following to get 

650 its primary data: 

651 

652 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_local()` 

653 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()` 

654 

655 It then may supplement the data with additional fields. 

656 

657 This is a "batch action" method which may be called from 

658 :meth:`create()`. 

659 

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

665 

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) 

672 

673 if "error" in data: 

674 return data 

675 

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 ) 

680 

681 if "unit_price_reg" in data and "unit_price_quoted" not in data: 

682 data["unit_price_quoted"] = data["unit_price_reg"] 

683 

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 ) 

688 

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 ) 

697 

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 ) 

702 

703 decimal_fields = [ 

704 "case_size", 

705 "unit_price_reg", 

706 "unit_price_quoted", 

707 "case_price_quoted", 

708 "default_item_discount", 

709 ] 

710 

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) 

716 

717 return data 

718 

719 def get_past_products(self, batch, data): # pylint: disable=unused-argument 

720 """ 

721 Fetch past products for convenient re-ordering. 

722 

723 This essentially calls 

724 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_past_products()` 

725 on the :attr:`batch_handler` and returns the result. 

726 

727 This is a "batch action" method which may be called from 

728 :meth:`create()`. 

729 

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) 

734 

735 def add_item(self, batch, data): 

736 """ 

737 This adds a row to the user's current new order batch. 

738 

739 This is a "batch action" method which may be called from 

740 :meth:`create()`. See also: 

741 

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 ) 

751 

752 return {"batch": self.normalize_batch(batch), "row": self.normalize_row(row)} 

753 

754 def update_item(self, batch, data): 

755 """ 

756 This updates a row in the user's current new order batch. 

757 

758 This is a "batch action" method which may be called from 

759 :meth:`create()`. See also: 

760 

761 * :meth:`add_item()` 

762 * :meth:`delete_item()` 

763 """ 

764 model = self.app.model 

765 session = self.Session() 

766 

767 uuid = data.get("uuid") 

768 if not uuid: 

769 return {"error": "Must specify row UUID"} 

770 

771 row = session.get(model.NewOrderBatchRow, uuid) 

772 if not row: 

773 return {"error": "Row not found"} 

774 

775 if row.batch is not batch: 

776 return {"error": "Row is for wrong batch"} 

777 

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 ) 

784 

785 return {"batch": self.normalize_batch(batch), "row": self.normalize_row(row)} 

786 

787 def delete_item(self, batch, data): 

788 """ 

789 This deletes a row from the user's current new order batch. 

790 

791 This is a "batch action" method which may be called from 

792 :meth:`create()`. See also: 

793 

794 * :meth:`add_item()` 

795 * :meth:`update_item()` 

796 """ 

797 model = self.app.model 

798 session = self.app.get_session(batch) 

799 

800 uuid = data.get("uuid") 

801 if not uuid: 

802 return {"error": "Must specify a row UUID"} 

803 

804 row = session.get(model.NewOrderBatchRow, uuid) 

805 if not row: 

806 return {"error": "Row not found"} 

807 

808 if row.batch is not batch: 

809 return {"error": "Row is for wrong batch"} 

810 

811 self.batch_handler.do_remove_row(row) 

812 return {"batch": self.normalize_batch(batch)} 

813 

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. 

818 

819 This is a "batch action" method which may be called from 

820 :meth:`create()`. See also: 

821 

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} 

829 

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

835 

836 return { 

837 "next_url": self.get_action_url("view", order), 

838 } 

839 

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 } 

849 

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 } 

896 

897 use_local = self.batch_handler.use_local_products() 

898 

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 

905 

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 

914 

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 ) 

920 

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) 

929 

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 } 

956 

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 ) 

961 

962 return data 

963 

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

968 

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

970 """ """ 

971 f = form 

972 super().configure_form(f) 

973 order = f.model_instance 

974 

975 # store_id 

976 if not self.order_handler.expose_store_id(): 

977 f.remove("store_id") 

978 

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

984 

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

990 

991 # total_price 

992 f.set_node("total_price", WuttaMoney(self.request)) 

993 

994 # created_by 

995 f.set_node("created_by", UserRef(self.request)) 

996 f.set_readonly("created_by") 

997 

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

1004 

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 ) 

1018 

1019 return buttons 

1020 

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) 

1027 

1028 def get_row_parent(self, row): # pylint: disable=empty-docstring 

1029 """ """ 

1030 item = row 

1031 return item.order 

1032 

1033 def configure_row_grid(self, grid): # pylint: disable=empty-docstring 

1034 """ """ 

1035 g = grid 

1036 super().configure_row_grid(g) 

1037 # enum = self.app.enum 

1038 

1039 # sequence 

1040 g.set_label("sequence", "Seq.", column_only=True) 

1041 g.set_link("sequence") 

1042 

1043 # product_scancode 

1044 g.set_link("product_scancode") 

1045 

1046 # product_brand 

1047 g.set_link("product_brand") 

1048 

1049 # product_description 

1050 g.set_link("product_description") 

1051 

1052 # product_size 

1053 g.set_link("product_size") 

1054 

1055 # TODO 

1056 # order_uom 

1057 # g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM) 

1058 

1059 # discount_percent 

1060 g.set_renderer("discount_percent", "percent") 

1061 g.set_label("discount_percent", "Disc. %", column_only=True) 

1062 

1063 # total_price 

1064 g.set_renderer("total_price", g.render_currency) 

1065 

1066 # status_code 

1067 g.set_renderer("status_code", self.render_status_code) 

1068 

1069 # TODO: upstream should set this automatically 

1070 g.row_class = self.row_grid_row_class 

1071 

1072 def row_grid_row_class( # pylint: disable=unused-argument,empty-docstring 

1073 self, item, data, i 

1074 ): 

1075 """ """ 

1076 variant = self.order_handler.item_status_to_variant(item.status_code) 

1077 if variant: 

1078 return f"has-background-{variant}" 

1079 return None 

1080 

1081 def render_status_code( # pylint: disable=unused-argument,empty-docstring 

1082 self, item, key, value 

1083 ): 

1084 """ """ 

1085 enum = self.app.enum 

1086 return enum.ORDER_ITEM_STATUS[value] 

1087 

1088 def get_row_action_url_view(self, row, i): # pylint: disable=empty-docstring 

1089 """ """ 

1090 item = row 

1091 return self.request.route_url("order_items.view", uuid=item.uuid) 

1092 

1093 def configure_get_simple_settings(self): # pylint: disable=empty-docstring 

1094 """ """ 

1095 settings = [ 

1096 # stores 

1097 {"name": "sideshow.orders.expose_store_id", "type": bool}, 

1098 {"name": "sideshow.orders.default_store_id"}, 

1099 # customers 

1100 { 

1101 "name": "sideshow.orders.use_local_customers", 

1102 # nb. this is really a bool but we present as string in config UI 

1103 #'type': bool, 

1104 "default": "true", 

1105 }, 

1106 # products 

1107 { 

1108 "name": "sideshow.orders.use_local_products", 

1109 # nb. this is really a bool but we present as string in config UI 

1110 #'type': bool, 

1111 "default": "true", 

1112 }, 

1113 { 

1114 "name": "sideshow.orders.allow_unknown_products", 

1115 "type": bool, 

1116 "default": True, 

1117 }, 

1118 # pricing 

1119 {"name": "sideshow.orders.allow_item_discounts", "type": bool}, 

1120 {"name": "sideshow.orders.allow_item_discounts_if_on_sale", "type": bool}, 

1121 {"name": "sideshow.orders.default_item_discount", "type": float}, 

1122 # batches 

1123 {"name": "wutta.batch.neworder.handler.spec"}, 

1124 ] 

1125 

1126 # required fields for new product entry 

1127 for field in self.PENDING_PRODUCT_ENTRY_FIELDS: 

1128 setting = { 

1129 "name": f"sideshow.orders.unknown_product.fields.{field}.required", 

1130 "type": bool, 

1131 } 

1132 if field == "description": 

1133 setting["default"] = True 

1134 settings.append(setting) 

1135 

1136 return settings 

1137 

1138 def configure_get_context( # pylint: disable=empty-docstring,arguments-differ 

1139 self, **kwargs 

1140 ): 

1141 """ """ 

1142 context = super().configure_get_context(**kwargs) 

1143 

1144 context["pending_product_fields"] = self.PENDING_PRODUCT_ENTRY_FIELDS 

1145 

1146 handlers = self.app.get_batch_handler_specs("neworder") 

1147 handlers = [{"spec": spec} for spec in handlers] 

1148 context["batch_handlers"] = handlers 

1149 

1150 context["dept_item_discounts"] = self.get_dept_item_discounts() 

1151 

1152 return context 

1153 

1154 def configure_gather_settings( 

1155 self, data, simple_settings=None 

1156 ): # pylint: disable=empty-docstring 

1157 """ """ 

1158 settings = super().configure_gather_settings( 

1159 data, simple_settings=simple_settings 

1160 ) 

1161 

1162 for dept in json.loads(data["dept_item_discounts"]): 

1163 deptid = dept["department_id"] 

1164 settings.append( 

1165 { 

1166 "name": f"sideshow.orders.departments.{deptid}.name", 

1167 "value": dept["department_name"], 

1168 } 

1169 ) 

1170 settings.append( 

1171 { 

1172 "name": f"sideshow.orders.departments.{deptid}.default_item_discount", 

1173 "value": dept["default_item_discount"], 

1174 } 

1175 ) 

1176 

1177 return settings 

1178 

1179 def configure_remove_settings( # pylint: disable=empty-docstring,arguments-differ 

1180 self, **kwargs 

1181 ): 

1182 """ """ 

1183 model = self.app.model 

1184 session = self.Session() 

1185 

1186 super().configure_remove_settings(**kwargs) 

1187 

1188 to_delete = ( 

1189 session.query(model.Setting) 

1190 .filter( 

1191 sa.or_( 

1192 model.Setting.name.like("sideshow.orders.departments.%.name"), 

1193 model.Setting.name.like( 

1194 "sideshow.orders.departments.%.default_item_discount" 

1195 ), 

1196 ) 

1197 ) 

1198 .all() 

1199 ) 

1200 for setting in to_delete: 

1201 self.app.delete_setting(session, setting.name) 

1202 

1203 @classmethod 

1204 def defaults(cls, config): 

1205 cls._order_defaults(config) 

1206 cls._defaults(config) 

1207 

1208 @classmethod 

1209 def _order_defaults(cls, config): 

1210 route_prefix = cls.get_route_prefix() 

1211 permission_prefix = cls.get_permission_prefix() 

1212 url_prefix = cls.get_url_prefix() 

1213 model_title = cls.get_model_title() 

1214 model_title_plural = cls.get_model_title_plural() 

1215 

1216 # fix perm group 

1217 config.add_wutta_permission_group( 

1218 permission_prefix, model_title_plural, overwrite=False 

1219 ) 

1220 

1221 # extra perm required to create order with unknown/pending product 

1222 config.add_wutta_permission( 

1223 permission_prefix, 

1224 f"{permission_prefix}.create_unknown_product", 

1225 f"Create new {model_title} for unknown/pending product", 

1226 ) 

1227 

1228 # customer autocomplete 

1229 config.add_route( 

1230 f"{route_prefix}.customer_autocomplete", 

1231 f"{url_prefix}/customer-autocomplete", 

1232 request_method="GET", 

1233 ) 

1234 config.add_view( 

1235 cls, 

1236 attr="customer_autocomplete", 

1237 route_name=f"{route_prefix}.customer_autocomplete", 

1238 renderer="json", 

1239 permission=f"{permission_prefix}.list", 

1240 ) 

1241 

1242 # product autocomplete 

1243 config.add_route( 

1244 f"{route_prefix}.product_autocomplete", 

1245 f"{url_prefix}/product-autocomplete", 

1246 request_method="GET", 

1247 ) 

1248 config.add_view( 

1249 cls, 

1250 attr="product_autocomplete", 

1251 route_name=f"{route_prefix}.product_autocomplete", 

1252 renderer="json", 

1253 permission=f"{permission_prefix}.list", 

1254 ) 

1255 

1256 

1257class OrderItemView(MasterView): # pylint: disable=abstract-method 

1258 """ 

1259 Master view for :class:`~sideshow.db.model.orders.OrderItem`; 

1260 route prefix is ``order_items``. 

1261 

1262 Notable URLs provided by this class: 

1263 

1264 * ``/order-items/`` 

1265 * ``/order-items/XXX`` 

1266 

1267 This class serves both as a proper master view (for "all" order 

1268 items) as well as a base class for other "workflow" master views, 

1269 each of which auto-filters by order item status: 

1270 

1271 * :class:`PlacementView` 

1272 * :class:`ReceivingView` 

1273 * :class:`ContactView` 

1274 * :class:`DeliveryView` 

1275 

1276 Note that this does not expose create, edit or delete. The user 

1277 must perform various other workflow actions to modify the item. 

1278 

1279 .. attribute:: order_handler 

1280 

1281 Reference to the :term:`order handler` as returned by 

1282 :meth:`get_order_handler()`. 

1283 """ 

1284 

1285 model_class = OrderItem 

1286 model_title = "Order Item (All)" 

1287 model_title_plural = "Order Items (All)" 

1288 route_prefix = "order_items" 

1289 url_prefix = "/order-items" 

1290 creatable = False 

1291 editable = False 

1292 deletable = False 

1293 

1294 labels = { 

1295 "order_id": "Order ID", 

1296 "store_id": "Store ID", 

1297 "product_id": "Product ID", 

1298 "product_scancode": "Scancode", 

1299 "product_brand": "Brand", 

1300 "product_description": "Description", 

1301 "product_size": "Size", 

1302 "product_weighed": "Sold by Weight", 

1303 "department_id": "Department ID", 

1304 "order_uom": "Order UOM", 

1305 "status_code": "Status", 

1306 } 

1307 

1308 grid_columns = [ 

1309 "order_id", 

1310 "store_id", 

1311 "customer_name", 

1312 # 'sequence', 

1313 "product_scancode", 

1314 "product_brand", 

1315 "product_description", 

1316 "product_size", 

1317 "department_name", 

1318 "special_order", 

1319 "order_qty", 

1320 "order_uom", 

1321 "total_price", 

1322 "status_code", 

1323 ] 

1324 

1325 sort_defaults = ("order_id", "desc") 

1326 

1327 # pylint: disable=duplicate-code 

1328 form_fields = [ 

1329 "order", 

1330 # 'customer_name', 

1331 "sequence", 

1332 "product_id", 

1333 "local_product", 

1334 "pending_product", 

1335 "product_scancode", 

1336 "product_brand", 

1337 "product_description", 

1338 "product_size", 

1339 "product_weighed", 

1340 "department_id", 

1341 "department_name", 

1342 "special_order", 

1343 "case_size", 

1344 "unit_cost", 

1345 "unit_price_reg", 

1346 "unit_price_sale", 

1347 "sale_ends", 

1348 "unit_price_quoted", 

1349 "case_price_quoted", 

1350 "order_qty", 

1351 "order_uom", 

1352 "discount_percent", 

1353 "total_price", 

1354 "status_code", 

1355 "paid_amount", 

1356 "payment_transaction_number", 

1357 ] 

1358 # pylint: enable=duplicate-code 

1359 

1360 def __init__(self, request, context=None): 

1361 super().__init__(request, context=context) 

1362 self.order_handler = self.app.get_order_handler() 

1363 

1364 def get_fallback_templates(self, template): # pylint: disable=empty-docstring 

1365 """ """ 

1366 templates = super().get_fallback_templates(template) 

1367 templates.insert(0, f"/order-items/{template}.mako") 

1368 return templates 

1369 

1370 def get_query(self, session=None): # pylint: disable=empty-docstring 

1371 """ """ 

1372 query = super().get_query(session=session) 

1373 model = self.app.model 

1374 return query.join(model.Order) 

1375 

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

1377 """ """ 

1378 g = grid 

1379 super().configure_grid(g) 

1380 model = self.app.model 

1381 # enum = self.app.enum 

1382 

1383 # store_id 

1384 if not self.order_handler.expose_store_id(): 

1385 g.remove("store_id") 

1386 

1387 # order_id 

1388 g.set_sorter("order_id", model.Order.order_id) 

1389 g.set_renderer("order_id", self.render_order_attr) 

1390 g.set_link("order_id") 

1391 

1392 # store_id 

1393 g.set_sorter("store_id", model.Order.store_id) 

1394 g.set_renderer("store_id", self.render_order_attr) 

1395 

1396 # customer_name 

1397 g.set_label("customer_name", "Customer", column_only=True) 

1398 g.set_renderer("customer_name", self.render_order_attr) 

1399 g.set_sorter("customer_name", model.Order.customer_name) 

1400 g.set_filter("customer_name", model.Order.customer_name) 

1401 

1402 # # sequence 

1403 # g.set_label('sequence', "Seq.", column_only=True) 

1404 

1405 # product_scancode 

1406 g.set_link("product_scancode") 

1407 

1408 # product_brand 

1409 g.set_link("product_brand") 

1410 

1411 # product_description 

1412 g.set_link("product_description") 

1413 

1414 # product_size 

1415 g.set_link("product_size") 

1416 

1417 # order_uom 

1418 # TODO 

1419 # g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM) 

1420 

1421 # total_price 

1422 g.set_renderer("total_price", g.render_currency) 

1423 

1424 # status_code 

1425 g.set_renderer("status_code", self.render_status_code) 

1426 

1427 def render_order_attr( # pylint: disable=unused-argument,empty-docstring 

1428 self, item, key, value 

1429 ): 

1430 """ """ 

1431 order = item.order 

1432 return getattr(order, key) 

1433 

1434 def render_status_code( # pylint: disable=unused-argument,empty-docstring 

1435 self, item, key, value 

1436 ): 

1437 """ """ 

1438 enum = self.app.enum 

1439 return enum.ORDER_ITEM_STATUS[value] 

1440 

1441 def grid_row_class( # pylint: disable=unused-argument,empty-docstring 

1442 self, item, data, i 

1443 ): 

1444 """ """ 

1445 variant = self.order_handler.item_status_to_variant(item.status_code) 

1446 if variant: 

1447 return f"has-background-{variant}" 

1448 return None 

1449 

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

1451 """ """ 

1452 f = form 

1453 super().configure_form(f) 

1454 enum = self.app.enum 

1455 item = f.model_instance 

1456 

1457 # order 

1458 f.set_node("order", OrderRef(self.request)) 

1459 

1460 # local_product 

1461 f.set_node("local_product", LocalProductRef(self.request)) 

1462 

1463 # pending_product 

1464 if item.product_id or item.local_product: 

1465 f.remove("pending_product") 

1466 else: 

1467 f.set_node("pending_product", PendingProductRef(self.request)) 

1468 

1469 # order_qty 

1470 f.set_node("order_qty", WuttaQuantity(self.request)) 

1471 

1472 # order_uom 

1473 f.set_node("order_uom", WuttaDictEnum(self.request, enum.ORDER_UOM)) 

1474 

1475 # case_size 

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

1477 

1478 # unit_cost 

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

1480 

1481 # unit_price_reg 

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

1483 

1484 # unit_price_quoted 

1485 f.set_node("unit_price_quoted", WuttaMoney(self.request)) 

1486 

1487 # case_price_quoted 

1488 f.set_node("case_price_quoted", WuttaMoney(self.request)) 

1489 

1490 # total_price 

1491 f.set_node("total_price", WuttaMoney(self.request)) 

1492 

1493 # status 

1494 f.set_node("status_code", WuttaDictEnum(self.request, enum.ORDER_ITEM_STATUS)) 

1495 

1496 # paid_amount 

1497 f.set_node("paid_amount", WuttaMoney(self.request)) 

1498 

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

1500 """ """ 

1501 if self.viewing: 

1502 model = self.app.model 

1503 enum = self.app.enum 

1504 route_prefix = self.get_route_prefix() 

1505 item = context["instance"] 

1506 form = context["form"] 

1507 

1508 context["expose_store_id"] = self.order_handler.expose_store_id() 

1509 

1510 context["item"] = item 

1511 context["order"] = item.order 

1512 context["order_qty_uom_text"] = self.order_handler.get_order_qty_uom_text( 

1513 item.order_qty, item.order_uom, case_size=item.case_size, html=True 

1514 ) 

1515 context["item_status_variant"] = self.order_handler.item_status_to_variant( 

1516 item.status_code 

1517 ) 

1518 

1519 grid = self.make_grid( 

1520 key=f"{route_prefix}.view.events", 

1521 model_class=model.OrderItemEvent, 

1522 data=item.events, 

1523 columns=[ 

1524 "occurred", 

1525 "actor", 

1526 "type_code", 

1527 "note", 

1528 ], 

1529 labels={ 

1530 "occurred": "Date/Time", 

1531 "actor": "User", 

1532 "type_code": "Event Type", 

1533 }, 

1534 ) 

1535 grid.set_renderer("type_code", lambda e, k, v: enum.ORDER_ITEM_EVENT[v]) 

1536 grid.set_renderer("note", self.render_event_note) 

1537 if self.request.has_perm("users.view"): 

1538 grid.set_renderer( 

1539 "actor", 

1540 lambda e, k, v: tags.link_to( 

1541 e.actor, self.request.route_url("users.view", uuid=e.actor.uuid) 

1542 ), 

1543 ) 

1544 form.add_grid_vue_context(grid) 

1545 context["events_grid"] = grid 

1546 

1547 return context 

1548 

1549 def render_event_note( # pylint: disable=unused-argument,empty-docstring 

1550 self, event, key, value 

1551 ): 

1552 """ """ 

1553 enum = self.app.enum 

1554 if event.type_code == enum.ORDER_ITEM_EVENT_NOTE_ADDED: 

1555 return HTML.tag( 

1556 "span", 

1557 class_="has-background-info-light", 

1558 style="padding: 0.25rem 0.5rem;", 

1559 c=[value], 

1560 ) 

1561 return value 

1562 

1563 def get_xref_buttons(self, obj): # pylint: disable=empty-docstring 

1564 """ """ 

1565 item = obj 

1566 buttons = super().get_xref_buttons(item) 

1567 

1568 if self.request.has_perm("orders.view"): 

1569 url = self.request.route_url("orders.view", uuid=item.order_uuid) 

1570 buttons.append( 

1571 self.make_button( 

1572 "View the Order", url=url, primary=True, icon_left="eye" 

1573 ) 

1574 ) 

1575 

1576 return buttons 

1577 

1578 def add_note(self): 

1579 """ 

1580 View which adds a note to an order item. This is POST-only; 

1581 will redirect back to the item view. 

1582 """ 

1583 enum = self.app.enum 

1584 item = self.get_instance() 

1585 

1586 item.add_event( 

1587 enum.ORDER_ITEM_EVENT_NOTE_ADDED, 

1588 self.request.user, 

1589 note=self.request.POST["note"], 

1590 ) 

1591 

1592 return self.redirect(self.get_action_url("view", item)) 

1593 

1594 def change_status(self): 

1595 """ 

1596 View which changes status for an order item. This is 

1597 POST-only; will redirect back to the item view. 

1598 """ 

1599 enum = self.app.enum 

1600 main_item = self.get_instance() 

1601 redirect = self.redirect(self.get_action_url("view", main_item)) 

1602 

1603 extra_note = self.request.POST.get("note") 

1604 

1605 # validate new status 

1606 new_status_code = int(self.request.POST["new_status"]) 

1607 if new_status_code not in enum.ORDER_ITEM_STATUS: 

1608 self.request.session.flash("Invalid status code", "error") 

1609 return redirect 

1610 new_status_text = enum.ORDER_ITEM_STATUS[new_status_code] 

1611 

1612 # locate all items to which new status will be applied 

1613 items = [main_item] 

1614 # uuids = self.request.POST.get('uuids') 

1615 # if uuids: 

1616 # for uuid in uuids.split(','): 

1617 # item = Session.get(model.OrderItem, uuid) 

1618 # if item: 

1619 # items.append(item) 

1620 

1621 # update item(s) 

1622 for item in items: 

1623 if item.status_code != new_status_code: 

1624 

1625 # event: change status 

1626 note = ( 

1627 f'status changed from "{enum.ORDER_ITEM_STATUS[item.status_code]}" ' 

1628 f'to "{new_status_text}"' 

1629 ) 

1630 item.add_event( 

1631 enum.ORDER_ITEM_EVENT_STATUS_CHANGE, self.request.user, note=note 

1632 ) 

1633 

1634 # event: add note 

1635 if extra_note: 

1636 item.add_event( 

1637 enum.ORDER_ITEM_EVENT_NOTE_ADDED, 

1638 self.request.user, 

1639 note=extra_note, 

1640 ) 

1641 

1642 # new status 

1643 item.status_code = new_status_code 

1644 

1645 self.request.session.flash(f"Status has been updated to: {new_status_text}") 

1646 return redirect 

1647 

1648 def get_order_items(self, uuids): 

1649 """ 

1650 This method provides common logic to fetch a list of order 

1651 items based on a list of UUID keys. It is used by various 

1652 workflow action methods. 

1653 

1654 Note that if no order items are found, this will set a flash 

1655 warning message and raise a redirect back to the index page. 

1656 

1657 :param uuids: List (or comma-delimited string) of UUID keys. 

1658 

1659 :returns: List of :class:`~sideshow.db.model.orders.OrderItem` 

1660 records. 

1661 """ 

1662 model = self.app.model 

1663 session = self.Session() 

1664 

1665 if uuids is None: 

1666 uuids = [] 

1667 elif isinstance(uuids, str): 

1668 uuids = uuids.split(",") 

1669 

1670 items = [] 

1671 for uuid in uuids: 

1672 if isinstance(uuid, str): 

1673 uuid = uuid.strip() 

1674 if uuid: 

1675 try: 

1676 item = session.get(model.OrderItem, uuid) 

1677 except sa.exc.StatementError: 

1678 pass # nb. invalid UUID 

1679 else: 

1680 if item: 

1681 items.append(item) 

1682 

1683 if not items: 

1684 self.request.session.flash("Must specify valid order item(s).", "warning") 

1685 raise self.redirect(self.get_index_url()) 

1686 

1687 return items 

1688 

1689 @classmethod 

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

1691 """ """ 

1692 cls._order_item_defaults(config) 

1693 cls._defaults(config) 

1694 

1695 @classmethod 

1696 def _order_item_defaults(cls, config): 

1697 """ """ 

1698 route_prefix = cls.get_route_prefix() 

1699 permission_prefix = cls.get_permission_prefix() 

1700 instance_url_prefix = cls.get_instance_url_prefix() 

1701 model_title = cls.get_model_title() 

1702 model_title_plural = cls.get_model_title_plural() 

1703 

1704 # fix perm group 

1705 config.add_wutta_permission_group( 

1706 permission_prefix, model_title_plural, overwrite=False 

1707 ) 

1708 

1709 # add note 

1710 config.add_route( 

1711 f"{route_prefix}.add_note", 

1712 f"{instance_url_prefix}/add_note", 

1713 request_method="POST", 

1714 ) 

1715 config.add_view( 

1716 cls, 

1717 attr="add_note", 

1718 route_name=f"{route_prefix}.add_note", 

1719 renderer="json", 

1720 permission=f"{permission_prefix}.add_note", 

1721 ) 

1722 config.add_wutta_permission( 

1723 permission_prefix, 

1724 f"{permission_prefix}.add_note", 

1725 f"Add note for {model_title}", 

1726 ) 

1727 

1728 # change status 

1729 config.add_route( 

1730 f"{route_prefix}.change_status", 

1731 f"{instance_url_prefix}/change-status", 

1732 request_method="POST", 

1733 ) 

1734 config.add_view( 

1735 cls, 

1736 attr="change_status", 

1737 route_name=f"{route_prefix}.change_status", 

1738 renderer="json", 

1739 permission=f"{permission_prefix}.change_status", 

1740 ) 

1741 config.add_wutta_permission( 

1742 permission_prefix, 

1743 f"{permission_prefix}.change_status", 

1744 f"Change status for {model_title}", 

1745 ) 

1746 

1747 

1748class PlacementView(OrderItemView): # pylint: disable=abstract-method 

1749 """ 

1750 Master view for the "placement" phase of 

1751 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is 

1752 ``placement``. This is a subclass of :class:`OrderItemView`. 

1753 

1754 This class auto-filters so only order items with the following 

1755 status codes are shown: 

1756 

1757 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY` 

1758 

1759 Notable URLs provided by this class: 

1760 

1761 * ``/placement/`` 

1762 * ``/placement/XXX`` 

1763 """ 

1764 

1765 model_title = "Order Item (Placement)" 

1766 model_title_plural = "Order Items (Placement)" 

1767 route_prefix = "order_items_placement" 

1768 url_prefix = "/placement" 

1769 

1770 grid_columns = [ 

1771 "order_id", 

1772 "store_id", 

1773 "customer_name", 

1774 "product_brand", 

1775 "product_description", 

1776 "product_size", 

1777 "department_name", 

1778 "special_order", 

1779 "vendor_name", 

1780 "vendor_item_code", 

1781 "order_qty", 

1782 "order_uom", 

1783 "total_price", 

1784 ] 

1785 

1786 filter_defaults = { 

1787 "vendor_name": {"active": True}, 

1788 } 

1789 

1790 def get_query(self, session=None): # pylint: disable=empty-docstring 

1791 """ """ 

1792 query = super().get_query(session=session) 

1793 model = self.app.model 

1794 enum = self.app.enum 

1795 return query.filter(model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_READY) 

1796 

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

1798 """ """ 

1799 g = grid 

1800 super().configure_grid(g) 

1801 

1802 # checkable 

1803 if self.has_perm("process_placement"): 

1804 g.checkable = True 

1805 

1806 # tool button: Order Placed 

1807 if self.has_perm("process_placement"): 

1808 button = self.make_button( 

1809 "Order Placed", 

1810 primary=True, 

1811 icon_left="arrow-circle-right", 

1812 **{ 

1813 "@click": "$emit('process-placement', checkedRows)", 

1814 ":disabled": "!checkedRows.length", 

1815 }, 

1816 ) 

1817 g.add_tool(button, key="process_placement") 

1818 

1819 def process_placement(self): 

1820 """ 

1821 View to process the "placement" step for some order item(s). 

1822 

1823 This requires a POST request with data: 

1824 

1825 :param item_uuids: Comma-delimited list of 

1826 :class:`~sideshow.db.model.orders.OrderItem` UUID keys. 

1827 

1828 :param vendor_name: Optional name of vendor. 

1829 

1830 :param po_number: Optional PO number. 

1831 

1832 :param note: Optional note text from the user. 

1833 

1834 This invokes 

1835 :meth:`~sideshow.orders.OrderHandler.process_placement()` on 

1836 the :attr:`~OrderItemView.order_handler`, then redirects user 

1837 back to the index page. 

1838 """ 

1839 items = self.get_order_items(self.request.POST.get("item_uuids", "")) 

1840 vendor_name = self.request.POST.get("vendor_name", "").strip() or None 

1841 po_number = self.request.POST.get("po_number", "").strip() or None 

1842 note = self.request.POST.get("note", "").strip() or None 

1843 

1844 self.order_handler.process_placement( 

1845 items, 

1846 self.request.user, 

1847 vendor_name=vendor_name, 

1848 po_number=po_number, 

1849 note=note, 

1850 ) 

1851 

1852 self.request.session.flash(f"{len(items)} Order Items were marked as placed") 

1853 return self.redirect(self.get_index_url()) 

1854 

1855 @classmethod 

1856 def defaults(cls, config): 

1857 cls._order_item_defaults(config) 

1858 cls._placement_defaults(config) 

1859 cls._defaults(config) 

1860 

1861 @classmethod 

1862 def _placement_defaults(cls, config): 

1863 route_prefix = cls.get_route_prefix() 

1864 permission_prefix = cls.get_permission_prefix() 

1865 url_prefix = cls.get_url_prefix() 

1866 model_title_plural = cls.get_model_title_plural() 

1867 

1868 # process placement 

1869 config.add_wutta_permission( 

1870 permission_prefix, 

1871 f"{permission_prefix}.process_placement", 

1872 f"Process placement for {model_title_plural}", 

1873 ) 

1874 config.add_route( 

1875 f"{route_prefix}.process_placement", 

1876 f"{url_prefix}/process-placement", 

1877 request_method="POST", 

1878 ) 

1879 config.add_view( 

1880 cls, 

1881 attr="process_placement", 

1882 route_name=f"{route_prefix}.process_placement", 

1883 permission=f"{permission_prefix}.process_placement", 

1884 ) 

1885 

1886 

1887class ReceivingView(OrderItemView): # pylint: disable=abstract-method 

1888 """ 

1889 Master view for the "receiving" phase of 

1890 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is 

1891 ``receiving``. This is a subclass of :class:`OrderItemView`. 

1892 

1893 This class auto-filters so only order items with the following 

1894 status codes are shown: 

1895 

1896 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_PLACED` 

1897 

1898 Notable URLs provided by this class: 

1899 

1900 * ``/receiving/`` 

1901 * ``/receiving/XXX`` 

1902 """ 

1903 

1904 model_title = "Order Item (Receiving)" 

1905 model_title_plural = "Order Items (Receiving)" 

1906 route_prefix = "order_items_receiving" 

1907 url_prefix = "/receiving" 

1908 

1909 grid_columns = [ 

1910 "order_id", 

1911 "store_id", 

1912 "customer_name", 

1913 "product_brand", 

1914 "product_description", 

1915 "product_size", 

1916 "department_name", 

1917 "special_order", 

1918 "vendor_name", 

1919 "vendor_item_code", 

1920 "order_qty", 

1921 "order_uom", 

1922 "total_price", 

1923 ] 

1924 

1925 filter_defaults = { 

1926 "vendor_name": {"active": True}, 

1927 } 

1928 

1929 def get_query(self, session=None): # pylint: disable=empty-docstring 

1930 """ """ 

1931 query = super().get_query(session=session) 

1932 model = self.app.model 

1933 enum = self.app.enum 

1934 return query.filter( 

1935 model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_PLACED 

1936 ) 

1937 

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

1939 """ """ 

1940 g = grid 

1941 super().configure_grid(g) 

1942 

1943 # checkable 

1944 if self.has_any_perm("process_receiving", "process_reorder"): 

1945 g.checkable = True 

1946 

1947 # tool button: Received 

1948 if self.has_perm("process_receiving"): 

1949 button = self.make_button( 

1950 "Received", 

1951 primary=True, 

1952 icon_left="arrow-circle-right", 

1953 **{ 

1954 "@click": "$emit('process-receiving', checkedRows)", 

1955 ":disabled": "!checkedRows.length", 

1956 }, 

1957 ) 

1958 g.add_tool(button, key="process_receiving") 

1959 

1960 # tool button: Re-Order 

1961 if self.has_perm("process_reorder"): 

1962 button = self.make_button( 

1963 "Re-Order", 

1964 icon_left="redo", 

1965 **{ 

1966 "@click": "$emit('process-reorder', checkedRows)", 

1967 ":disabled": "!checkedRows.length", 

1968 }, 

1969 ) 

1970 g.add_tool(button, key="process_reorder") 

1971 

1972 def process_receiving(self): 

1973 """ 

1974 View to process the "receiving" step for some order item(s). 

1975 

1976 This requires a POST request with data: 

1977 

1978 :param item_uuids: Comma-delimited list of 

1979 :class:`~sideshow.db.model.orders.OrderItem` UUID keys. 

1980 

1981 :param vendor_name: Optional name of vendor. 

1982 

1983 :param invoice_number: Optional invoice number. 

1984 

1985 :param po_number: Optional PO number. 

1986 

1987 :param note: Optional note text from the user. 

1988 

1989 This invokes 

1990 :meth:`~sideshow.orders.OrderHandler.process_receiving()` on 

1991 the :attr:`~OrderItemView.order_handler`, then redirects user 

1992 back to the index page. 

1993 """ 

1994 items = self.get_order_items(self.request.POST.get("item_uuids", "")) 

1995 vendor_name = self.request.POST.get("vendor_name", "").strip() or None 

1996 invoice_number = self.request.POST.get("invoice_number", "").strip() or None 

1997 po_number = self.request.POST.get("po_number", "").strip() or None 

1998 note = self.request.POST.get("note", "").strip() or None 

1999 

2000 self.order_handler.process_receiving( 

2001 items, 

2002 self.request.user, 

2003 vendor_name=vendor_name, 

2004 invoice_number=invoice_number, 

2005 po_number=po_number, 

2006 note=note, 

2007 ) 

2008 

2009 self.request.session.flash(f"{len(items)} Order Items were marked as received") 

2010 return self.redirect(self.get_index_url()) 

2011 

2012 def process_reorder(self): 

2013 """ 

2014 View to process the "reorder" step for some order item(s). 

2015 

2016 This requires a POST request with data: 

2017 

2018 :param item_uuids: Comma-delimited list of 

2019 :class:`~sideshow.db.model.orders.OrderItem` UUID keys. 

2020 

2021 :param note: Optional note text from the user. 

2022 

2023 This invokes 

2024 :meth:`~sideshow.orders.OrderHandler.process_reorder()` on the 

2025 :attr:`~OrderItemView.order_handler`, then redirects user back 

2026 to the index page. 

2027 """ 

2028 items = self.get_order_items(self.request.POST.get("item_uuids", "")) 

2029 note = self.request.POST.get("note", "").strip() or None 

2030 

2031 self.order_handler.process_reorder(items, self.request.user, note=note) 

2032 

2033 self.request.session.flash( 

2034 f"{len(items)} Order Items were marked as ready for placement" 

2035 ) 

2036 return self.redirect(self.get_index_url()) 

2037 

2038 @classmethod 

2039 def defaults(cls, config): 

2040 cls._order_item_defaults(config) 

2041 cls._receiving_defaults(config) 

2042 cls._defaults(config) 

2043 

2044 @classmethod 

2045 def _receiving_defaults(cls, config): 

2046 route_prefix = cls.get_route_prefix() 

2047 permission_prefix = cls.get_permission_prefix() 

2048 url_prefix = cls.get_url_prefix() 

2049 model_title_plural = cls.get_model_title_plural() 

2050 

2051 # process receiving 

2052 config.add_wutta_permission( 

2053 permission_prefix, 

2054 f"{permission_prefix}.process_receiving", 

2055 f"Process receiving for {model_title_plural}", 

2056 ) 

2057 config.add_route( 

2058 f"{route_prefix}.process_receiving", 

2059 f"{url_prefix}/process-receiving", 

2060 request_method="POST", 

2061 ) 

2062 config.add_view( 

2063 cls, 

2064 attr="process_receiving", 

2065 route_name=f"{route_prefix}.process_receiving", 

2066 permission=f"{permission_prefix}.process_receiving", 

2067 ) 

2068 

2069 # process reorder 

2070 config.add_wutta_permission( 

2071 permission_prefix, 

2072 f"{permission_prefix}.process_reorder", 

2073 f"Process re-order for {model_title_plural}", 

2074 ) 

2075 config.add_route( 

2076 f"{route_prefix}.process_reorder", 

2077 f"{url_prefix}/process-reorder", 

2078 request_method="POST", 

2079 ) 

2080 config.add_view( 

2081 cls, 

2082 attr="process_reorder", 

2083 route_name=f"{route_prefix}.process_reorder", 

2084 permission=f"{permission_prefix}.process_reorder", 

2085 ) 

2086 

2087 

2088class ContactView(OrderItemView): # pylint: disable=abstract-method 

2089 """ 

2090 Master view for the "contact" phase of 

2091 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is 

2092 ``contact``. This is a subclass of :class:`OrderItemView`. 

2093 

2094 This class auto-filters so only order items with the following 

2095 status codes are shown: 

2096 

2097 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED` 

2098 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACT_FAILED` 

2099 

2100 Notable URLs provided by this class: 

2101 

2102 * ``/contact/`` 

2103 * ``/contact/XXX`` 

2104 """ 

2105 

2106 model_title = "Order Item (Contact)" 

2107 model_title_plural = "Order Items (Contact)" 

2108 route_prefix = "order_items_contact" 

2109 url_prefix = "/contact" 

2110 

2111 def get_query(self, session=None): # pylint: disable=empty-docstring 

2112 """ """ 

2113 query = super().get_query(session=session) 

2114 model = self.app.model 

2115 enum = self.app.enum 

2116 return query.filter( 

2117 model.OrderItem.status_code.in_( 

2118 (enum.ORDER_ITEM_STATUS_RECEIVED, enum.ORDER_ITEM_STATUS_CONTACT_FAILED) 

2119 ) 

2120 ) 

2121 

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

2123 """ """ 

2124 g = grid 

2125 super().configure_grid(g) 

2126 

2127 # checkable 

2128 if self.has_perm("process_contact"): 

2129 g.checkable = True 

2130 

2131 # tool button: Contact Success 

2132 if self.has_perm("process_contact"): 

2133 button = self.make_button( 

2134 "Contact Success", 

2135 primary=True, 

2136 icon_left="phone", 

2137 **{ 

2138 "@click": "$emit('process-contact-success', checkedRows)", 

2139 ":disabled": "!checkedRows.length", 

2140 }, 

2141 ) 

2142 g.add_tool(button, key="process_contact_success") 

2143 

2144 # tool button: Contact Failure 

2145 if self.has_perm("process_contact"): 

2146 button = self.make_button( 

2147 "Contact Failure", 

2148 variant="is-warning", 

2149 icon_left="phone", 

2150 **{ 

2151 "@click": "$emit('process-contact-failure', checkedRows)", 

2152 ":disabled": "!checkedRows.length", 

2153 }, 

2154 ) 

2155 g.add_tool(button, key="process_contact_failure") 

2156 

2157 def process_contact_success(self): 

2158 """ 

2159 View to process the "contact success" step for some order 

2160 item(s). 

2161 

2162 This requires a POST request with data: 

2163 

2164 :param item_uuids: Comma-delimited list of 

2165 :class:`~sideshow.db.model.orders.OrderItem` UUID keys. 

2166 

2167 :param note: Optional note text from the user. 

2168 

2169 This invokes 

2170 :meth:`~sideshow.orders.OrderHandler.process_contact_success()` 

2171 on the :attr:`~OrderItemView.order_handler`, then redirects 

2172 user back to the index page. 

2173 """ 

2174 items = self.get_order_items(self.request.POST.get("item_uuids", "")) 

2175 note = self.request.POST.get("note", "").strip() or None 

2176 

2177 self.order_handler.process_contact_success(items, self.request.user, note=note) 

2178 

2179 self.request.session.flash(f"{len(items)} Order Items were marked as contacted") 

2180 return self.redirect(self.get_index_url()) 

2181 

2182 def process_contact_failure(self): 

2183 """ 

2184 View to process the "contact failure" step for some order 

2185 item(s). 

2186 

2187 This requires a POST request with data: 

2188 

2189 :param item_uuids: Comma-delimited list of 

2190 :class:`~sideshow.db.model.orders.OrderItem` UUID keys. 

2191 

2192 :param note: Optional note text from the user. 

2193 

2194 This invokes 

2195 :meth:`~sideshow.orders.OrderHandler.process_contact_failure()` 

2196 on the :attr:`~OrderItemView.order_handler`, then redirects 

2197 user back to the index page. 

2198 """ 

2199 items = self.get_order_items(self.request.POST.get("item_uuids", "")) 

2200 note = self.request.POST.get("note", "").strip() or None 

2201 

2202 self.order_handler.process_contact_failure(items, self.request.user, note=note) 

2203 

2204 self.request.session.flash( 

2205 f"{len(items)} Order Items were marked as contact failed" 

2206 ) 

2207 return self.redirect(self.get_index_url()) 

2208 

2209 @classmethod 

2210 def defaults(cls, config): 

2211 cls._order_item_defaults(config) 

2212 cls._contact_defaults(config) 

2213 cls._defaults(config) 

2214 

2215 @classmethod 

2216 def _contact_defaults(cls, config): 

2217 route_prefix = cls.get_route_prefix() 

2218 permission_prefix = cls.get_permission_prefix() 

2219 url_prefix = cls.get_url_prefix() 

2220 model_title_plural = cls.get_model_title_plural() 

2221 

2222 # common perm for processing contact success + failure 

2223 config.add_wutta_permission( 

2224 permission_prefix, 

2225 f"{permission_prefix}.process_contact", 

2226 f"Process contact success/failure for {model_title_plural}", 

2227 ) 

2228 

2229 # process contact success 

2230 config.add_route( 

2231 f"{route_prefix}.process_contact_success", 

2232 f"{url_prefix}/process-contact-success", 

2233 request_method="POST", 

2234 ) 

2235 config.add_view( 

2236 cls, 

2237 attr="process_contact_success", 

2238 route_name=f"{route_prefix}.process_contact_success", 

2239 permission=f"{permission_prefix}.process_contact", 

2240 ) 

2241 

2242 # process contact failure 

2243 config.add_route( 

2244 f"{route_prefix}.process_contact_failure", 

2245 f"{url_prefix}/process-contact-failure", 

2246 request_method="POST", 

2247 ) 

2248 config.add_view( 

2249 cls, 

2250 attr="process_contact_failure", 

2251 route_name=f"{route_prefix}.process_contact_failure", 

2252 permission=f"{permission_prefix}.process_contact", 

2253 ) 

2254 

2255 

2256class DeliveryView(OrderItemView): # pylint: disable=abstract-method 

2257 """ 

2258 Master view for the "delivery" phase of 

2259 :class:`~sideshow.db.model.orders.OrderItem`; route prefix is 

2260 ``delivery``. This is a subclass of :class:`OrderItemView`. 

2261 

2262 This class auto-filters so only order items with the following 

2263 status codes are shown: 

2264 

2265 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED` 

2266 * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACTED` 

2267 

2268 Notable URLs provided by this class: 

2269 

2270 * ``/delivery/`` 

2271 * ``/delivery/XXX`` 

2272 """ 

2273 

2274 model_title = "Order Item (Delivery)" 

2275 model_title_plural = "Order Items (Delivery)" 

2276 route_prefix = "order_items_delivery" 

2277 url_prefix = "/delivery" 

2278 

2279 def get_query(self, session=None): # pylint: disable=empty-docstring 

2280 """ """ 

2281 query = super().get_query(session=session) 

2282 model = self.app.model 

2283 enum = self.app.enum 

2284 return query.filter( 

2285 model.OrderItem.status_code.in_( 

2286 (enum.ORDER_ITEM_STATUS_RECEIVED, enum.ORDER_ITEM_STATUS_CONTACTED) 

2287 ) 

2288 ) 

2289 

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

2291 """ """ 

2292 g = grid 

2293 super().configure_grid(g) 

2294 

2295 # checkable 

2296 if self.has_any_perm("process_delivery", "process_restock"): 

2297 g.checkable = True 

2298 

2299 # tool button: Delivered 

2300 if self.has_perm("process_delivery"): 

2301 button = self.make_button( 

2302 "Delivered", 

2303 primary=True, 

2304 icon_left="check", 

2305 **{ 

2306 "@click": "$emit('process-delivery', checkedRows)", 

2307 ":disabled": "!checkedRows.length", 

2308 }, 

2309 ) 

2310 g.add_tool(button, key="process_delivery") 

2311 

2312 # tool button: Restocked 

2313 if self.has_perm("process_restock"): 

2314 button = self.make_button( 

2315 "Restocked", 

2316 icon_left="redo", 

2317 **{ 

2318 "@click": "$emit('process-restock', checkedRows)", 

2319 ":disabled": "!checkedRows.length", 

2320 }, 

2321 ) 

2322 g.add_tool(button, key="process_restock") 

2323 

2324 def process_delivery(self): 

2325 """ 

2326 View to process the "delivery" step for some order item(s). 

2327 

2328 This requires a POST request with data: 

2329 

2330 :param item_uuids: Comma-delimited list of 

2331 :class:`~sideshow.db.model.orders.OrderItem` UUID keys. 

2332 

2333 :param note: Optional note text from the user. 

2334 

2335 This invokes 

2336 :meth:`~sideshow.orders.OrderHandler.process_delivery()` on 

2337 the :attr:`~OrderItemView.order_handler`, then redirects user 

2338 back to the index page. 

2339 """ 

2340 items = self.get_order_items(self.request.POST.get("item_uuids", "")) 

2341 note = self.request.POST.get("note", "").strip() or None 

2342 

2343 self.order_handler.process_delivery(items, self.request.user, note=note) 

2344 

2345 self.request.session.flash(f"{len(items)} Order Items were marked as delivered") 

2346 return self.redirect(self.get_index_url()) 

2347 

2348 def process_restock(self): 

2349 """ 

2350 View to process the "restock" step for some order item(s). 

2351 

2352 This requires a POST request with data: 

2353 

2354 :param item_uuids: Comma-delimited list of 

2355 :class:`~sideshow.db.model.orders.OrderItem` UUID keys. 

2356 

2357 :param note: Optional note text from the user. 

2358 

2359 This invokes 

2360 :meth:`~sideshow.orders.OrderHandler.process_restock()` on the 

2361 :attr:`~OrderItemView.order_handler`, then redirects user back 

2362 to the index page. 

2363 """ 

2364 items = self.get_order_items(self.request.POST.get("item_uuids", "")) 

2365 note = self.request.POST.get("note", "").strip() or None 

2366 

2367 self.order_handler.process_restock(items, self.request.user, note=note) 

2368 

2369 self.request.session.flash(f"{len(items)} Order Items were marked as restocked") 

2370 return self.redirect(self.get_index_url()) 

2371 

2372 @classmethod 

2373 def defaults(cls, config): 

2374 cls._order_item_defaults(config) 

2375 cls._delivery_defaults(config) 

2376 cls._defaults(config) 

2377 

2378 @classmethod 

2379 def _delivery_defaults(cls, config): 

2380 route_prefix = cls.get_route_prefix() 

2381 permission_prefix = cls.get_permission_prefix() 

2382 url_prefix = cls.get_url_prefix() 

2383 model_title_plural = cls.get_model_title_plural() 

2384 

2385 # process delivery 

2386 config.add_wutta_permission( 

2387 permission_prefix, 

2388 f"{permission_prefix}.process_delivery", 

2389 f"Process delivery for {model_title_plural}", 

2390 ) 

2391 config.add_route( 

2392 f"{route_prefix}.process_delivery", 

2393 f"{url_prefix}/process-delivery", 

2394 request_method="POST", 

2395 ) 

2396 config.add_view( 

2397 cls, 

2398 attr="process_delivery", 

2399 route_name=f"{route_prefix}.process_delivery", 

2400 permission=f"{permission_prefix}.process_delivery", 

2401 ) 

2402 

2403 # process restock 

2404 config.add_wutta_permission( 

2405 permission_prefix, 

2406 f"{permission_prefix}.process_restock", 

2407 f"Process restock for {model_title_plural}", 

2408 ) 

2409 config.add_route( 

2410 f"{route_prefix}.process_restock", 

2411 f"{url_prefix}/process-restock", 

2412 request_method="POST", 

2413 ) 

2414 config.add_view( 

2415 cls, 

2416 attr="process_restock", 

2417 route_name=f"{route_prefix}.process_restock", 

2418 permission=f"{permission_prefix}.process_restock", 

2419 ) 

2420 

2421 

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

2423 base = globals() 

2424 

2425 OrderView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name 

2426 "OrderView", base["OrderView"] 

2427 ) 

2428 OrderView.defaults(config) 

2429 

2430 OrderItemView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name 

2431 "OrderItemView", base["OrderItemView"] 

2432 ) 

2433 OrderItemView.defaults(config) 

2434 

2435 PlacementView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name 

2436 "PlacementView", base["PlacementView"] 

2437 ) 

2438 PlacementView.defaults(config) 

2439 

2440 ReceivingView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name 

2441 "ReceivingView", base["ReceivingView"] 

2442 ) 

2443 ReceivingView.defaults(config) 

2444 

2445 ContactView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name 

2446 "ContactView", base["ContactView"] 

2447 ) 

2448 ContactView.defaults(config) 

2449 

2450 DeliveryView = kwargs.get( # pylint: disable=redefined-outer-name,invalid-name 

2451 "DeliveryView", base["DeliveryView"] 

2452 ) 

2453 DeliveryView.defaults(config) 

2454 

2455 

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

2457 defaults(config)