Coverage for src / sideshow / web / views / orders.py: 100%

778 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-15 17:03 -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 # raise NotImplementedError 

1031 item = row 

1032 return item.order 

1033 

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

1035 """ """ 

1036 g = grid 

1037 super().configure_row_grid(g) 

1038 # enum = self.app.enum 

1039 

1040 # sequence 

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

1042 g.set_link("sequence") 

1043 

1044 # product_scancode 

1045 g.set_link("product_scancode") 

1046 

1047 # product_brand 

1048 g.set_link("product_brand") 

1049 

1050 # product_description 

1051 g.set_link("product_description") 

1052 

1053 # product_size 

1054 g.set_link("product_size") 

1055 

1056 # TODO 

1057 # order_uom 

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

1059 

1060 # discount_percent 

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

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

1063 

1064 # total_price 

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

1066 

1067 # status_code 

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

1069 

1070 # TODO: upstream should set this automatically 

1071 g.row_class = self.row_grid_row_class 

1072 

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

1074 self, item, data, i 

1075 ): 

1076 """ """ 

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

1078 if variant: 

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

1080 return None 

1081 

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

1083 self, item, key, value 

1084 ): 

1085 """ """ 

1086 enum = self.app.enum 

1087 return enum.ORDER_ITEM_STATUS[value] 

1088 

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

1090 """ """ 

1091 item = row 

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

1093 

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

1095 """ """ 

1096 settings = [ 

1097 # stores 

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

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

1100 # customers 

1101 { 

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

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

1104 #'type': bool, 

1105 "default": "true", 

1106 }, 

1107 # products 

1108 { 

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

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

1111 #'type': bool, 

1112 "default": "true", 

1113 }, 

1114 { 

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

1116 "type": bool, 

1117 "default": True, 

1118 }, 

1119 # pricing 

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

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

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

1123 # batches 

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

1125 ] 

1126 

1127 # required fields for new product entry 

1128 for field in self.PENDING_PRODUCT_ENTRY_FIELDS: 

1129 setting = { 

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

1131 "type": bool, 

1132 } 

1133 if field == "description": 

1134 setting["default"] = True 

1135 settings.append(setting) 

1136 

1137 return settings 

1138 

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

1140 self, **kwargs 

1141 ): 

1142 """ """ 

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

1144 

1145 context["pending_product_fields"] = self.PENDING_PRODUCT_ENTRY_FIELDS 

1146 

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

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

1149 context["batch_handlers"] = handlers 

1150 

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

1152 

1153 return context 

1154 

1155 def configure_gather_settings( 

1156 self, data, simple_settings=None 

1157 ): # pylint: disable=empty-docstring 

1158 """ """ 

1159 settings = super().configure_gather_settings( 

1160 data, simple_settings=simple_settings 

1161 ) 

1162 

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

1164 deptid = dept["department_id"] 

1165 settings.append( 

1166 { 

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

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

1169 } 

1170 ) 

1171 settings.append( 

1172 { 

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

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

1175 } 

1176 ) 

1177 

1178 return settings 

1179 

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

1181 self, **kwargs 

1182 ): 

1183 """ """ 

1184 model = self.app.model 

1185 session = self.Session() 

1186 

1187 super().configure_remove_settings(**kwargs) 

1188 

1189 to_delete = ( 

1190 session.query(model.Setting) 

1191 .filter( 

1192 sa.or_( 

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

1194 model.Setting.name.like( 

1195 "sideshow.orders.departments.%.default_item_discount" 

1196 ), 

1197 ) 

1198 ) 

1199 .all() 

1200 ) 

1201 for setting in to_delete: 

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

1203 

1204 @classmethod 

1205 def defaults(cls, config): 

1206 cls._order_defaults(config) 

1207 cls._defaults(config) 

1208 

1209 @classmethod 

1210 def _order_defaults(cls, config): 

1211 route_prefix = cls.get_route_prefix() 

1212 permission_prefix = cls.get_permission_prefix() 

1213 url_prefix = cls.get_url_prefix() 

1214 model_title = cls.get_model_title() 

1215 model_title_plural = cls.get_model_title_plural() 

1216 

1217 # fix perm group 

1218 config.add_wutta_permission_group( 

1219 permission_prefix, model_title_plural, overwrite=False 

1220 ) 

1221 

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

1223 config.add_wutta_permission( 

1224 permission_prefix, 

1225 f"{permission_prefix}.create_unknown_product", 

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

1227 ) 

1228 

1229 # customer autocomplete 

1230 config.add_route( 

1231 f"{route_prefix}.customer_autocomplete", 

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

1233 request_method="GET", 

1234 ) 

1235 config.add_view( 

1236 cls, 

1237 attr="customer_autocomplete", 

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

1239 renderer="json", 

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

1241 ) 

1242 

1243 # product autocomplete 

1244 config.add_route( 

1245 f"{route_prefix}.product_autocomplete", 

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

1247 request_method="GET", 

1248 ) 

1249 config.add_view( 

1250 cls, 

1251 attr="product_autocomplete", 

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

1253 renderer="json", 

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

1255 ) 

1256 

1257 

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

1259 """ 

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

1261 route prefix is ``order_items``. 

1262 

1263 Notable URLs provided by this class: 

1264 

1265 * ``/order-items/`` 

1266 * ``/order-items/XXX`` 

1267 

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

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

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

1271 

1272 * :class:`PlacementView` 

1273 * :class:`ReceivingView` 

1274 * :class:`ContactView` 

1275 * :class:`DeliveryView` 

1276 

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

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

1279 

1280 .. attribute:: order_handler 

1281 

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

1283 :meth:`get_order_handler()`. 

1284 """ 

1285 

1286 model_class = OrderItem 

1287 model_title = "Order Item (All)" 

1288 model_title_plural = "Order Items (All)" 

1289 route_prefix = "order_items" 

1290 url_prefix = "/order-items" 

1291 creatable = False 

1292 editable = False 

1293 deletable = False 

1294 

1295 labels = { 

1296 "order_id": "Order ID", 

1297 "store_id": "Store ID", 

1298 "product_id": "Product ID", 

1299 "product_scancode": "Scancode", 

1300 "product_brand": "Brand", 

1301 "product_description": "Description", 

1302 "product_size": "Size", 

1303 "product_weighed": "Sold by Weight", 

1304 "department_id": "Department ID", 

1305 "order_uom": "Order UOM", 

1306 "status_code": "Status", 

1307 } 

1308 

1309 grid_columns = [ 

1310 "order_id", 

1311 "store_id", 

1312 "customer_name", 

1313 # 'sequence', 

1314 "product_scancode", 

1315 "product_brand", 

1316 "product_description", 

1317 "product_size", 

1318 "department_name", 

1319 "special_order", 

1320 "order_qty", 

1321 "order_uom", 

1322 "total_price", 

1323 "status_code", 

1324 ] 

1325 

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

1327 

1328 # pylint: disable=duplicate-code 

1329 form_fields = [ 

1330 "order", 

1331 # 'customer_name', 

1332 "sequence", 

1333 "product_id", 

1334 "local_product", 

1335 "pending_product", 

1336 "product_scancode", 

1337 "product_brand", 

1338 "product_description", 

1339 "product_size", 

1340 "product_weighed", 

1341 "department_id", 

1342 "department_name", 

1343 "special_order", 

1344 "case_size", 

1345 "unit_cost", 

1346 "unit_price_reg", 

1347 "unit_price_sale", 

1348 "sale_ends", 

1349 "unit_price_quoted", 

1350 "case_price_quoted", 

1351 "order_qty", 

1352 "order_uom", 

1353 "discount_percent", 

1354 "total_price", 

1355 "status_code", 

1356 "paid_amount", 

1357 "payment_transaction_number", 

1358 ] 

1359 # pylint: enable=duplicate-code 

1360 

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

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

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

1364 

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

1366 """ """ 

1367 templates = super().get_fallback_templates(template) 

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

1369 return templates 

1370 

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

1372 """ """ 

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

1374 model = self.app.model 

1375 return query.join(model.Order) 

1376 

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

1378 """ """ 

1379 g = grid 

1380 super().configure_grid(g) 

1381 model = self.app.model 

1382 # enum = self.app.enum 

1383 

1384 # store_id 

1385 if not self.order_handler.expose_store_id(): 

1386 g.remove("store_id") 

1387 

1388 # order_id 

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

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

1391 g.set_link("order_id") 

1392 

1393 # store_id 

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

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

1396 

1397 # customer_name 

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

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

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

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

1402 

1403 # # sequence 

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

1405 

1406 # product_scancode 

1407 g.set_link("product_scancode") 

1408 

1409 # product_brand 

1410 g.set_link("product_brand") 

1411 

1412 # product_description 

1413 g.set_link("product_description") 

1414 

1415 # product_size 

1416 g.set_link("product_size") 

1417 

1418 # order_uom 

1419 # TODO 

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

1421 

1422 # total_price 

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

1424 

1425 # status_code 

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

1427 

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

1429 self, item, key, value 

1430 ): 

1431 """ """ 

1432 order = item.order 

1433 return getattr(order, key) 

1434 

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

1436 self, item, key, value 

1437 ): 

1438 """ """ 

1439 enum = self.app.enum 

1440 return enum.ORDER_ITEM_STATUS[value] 

1441 

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

1443 self, item, data, i 

1444 ): 

1445 """ """ 

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

1447 if variant: 

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

1449 return None 

1450 

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

1452 """ """ 

1453 f = form 

1454 super().configure_form(f) 

1455 enum = self.app.enum 

1456 item = f.model_instance 

1457 

1458 # order 

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

1460 

1461 # local_product 

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

1463 

1464 # pending_product 

1465 if item.product_id or item.local_product: 

1466 f.remove("pending_product") 

1467 else: 

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

1469 

1470 # order_qty 

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

1472 

1473 # order_uom 

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

1475 

1476 # case_size 

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

1478 

1479 # unit_cost 

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

1481 

1482 # unit_price_reg 

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

1484 

1485 # unit_price_quoted 

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

1487 

1488 # case_price_quoted 

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

1490 

1491 # total_price 

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

1493 

1494 # status 

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

1496 

1497 # paid_amount 

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

1499 

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

1501 """ """ 

1502 if self.viewing: 

1503 model = self.app.model 

1504 enum = self.app.enum 

1505 route_prefix = self.get_route_prefix() 

1506 item = context["instance"] 

1507 form = context["form"] 

1508 

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

1510 

1511 context["item"] = item 

1512 context["order"] = item.order 

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

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

1515 ) 

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

1517 item.status_code 

1518 ) 

1519 

1520 grid = self.make_grid( 

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

1522 model_class=model.OrderItemEvent, 

1523 data=item.events, 

1524 columns=[ 

1525 "occurred", 

1526 "actor", 

1527 "type_code", 

1528 "note", 

1529 ], 

1530 labels={ 

1531 "occurred": "Date/Time", 

1532 "actor": "User", 

1533 "type_code": "Event Type", 

1534 }, 

1535 ) 

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

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

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

1539 grid.set_renderer( 

1540 "actor", 

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

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

1543 ), 

1544 ) 

1545 form.add_grid_vue_context(grid) 

1546 context["events_grid"] = grid 

1547 

1548 return context 

1549 

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

1551 self, event, key, value 

1552 ): 

1553 """ """ 

1554 enum = self.app.enum 

1555 if event.type_code == enum.ORDER_ITEM_EVENT_NOTE_ADDED: 

1556 return HTML.tag( 

1557 "span", 

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

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

1560 c=[value], 

1561 ) 

1562 return value 

1563 

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

1565 """ """ 

1566 item = obj 

1567 buttons = super().get_xref_buttons(item) 

1568 

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

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

1571 buttons.append( 

1572 self.make_button( 

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

1574 ) 

1575 ) 

1576 

1577 return buttons 

1578 

1579 def add_note(self): 

1580 """ 

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

1582 will redirect back to the item view. 

1583 """ 

1584 enum = self.app.enum 

1585 item = self.get_instance() 

1586 

1587 item.add_event( 

1588 enum.ORDER_ITEM_EVENT_NOTE_ADDED, 

1589 self.request.user, 

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

1591 ) 

1592 

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

1594 

1595 def change_status(self): 

1596 """ 

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

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

1599 """ 

1600 enum = self.app.enum 

1601 main_item = self.get_instance() 

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

1603 

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

1605 

1606 # validate new status 

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

1608 if new_status_code not in enum.ORDER_ITEM_STATUS: 

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

1610 return redirect 

1611 new_status_text = enum.ORDER_ITEM_STATUS[new_status_code] 

1612 

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

1614 items = [main_item] 

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

1616 # if uuids: 

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

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

1619 # if item: 

1620 # items.append(item) 

1621 

1622 # update item(s) 

1623 for item in items: 

1624 if item.status_code != new_status_code: 

1625 

1626 # event: change status 

1627 note = ( 

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

1629 f'to "{new_status_text}"' 

1630 ) 

1631 item.add_event( 

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

1633 ) 

1634 

1635 # event: add note 

1636 if extra_note: 

1637 item.add_event( 

1638 enum.ORDER_ITEM_EVENT_NOTE_ADDED, 

1639 self.request.user, 

1640 note=extra_note, 

1641 ) 

1642 

1643 # new status 

1644 item.status_code = new_status_code 

1645 

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

1647 return redirect 

1648 

1649 def get_order_items(self, uuids): 

1650 """ 

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

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

1653 workflow action methods. 

1654 

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

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

1657 

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

1659 

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

1661 records. 

1662 """ 

1663 model = self.app.model 

1664 session = self.Session() 

1665 

1666 if uuids is None: 

1667 uuids = [] 

1668 elif isinstance(uuids, str): 

1669 uuids = uuids.split(",") 

1670 

1671 items = [] 

1672 for uuid in uuids: 

1673 if isinstance(uuid, str): 

1674 uuid = uuid.strip() 

1675 if uuid: 

1676 try: 

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

1678 except sa.exc.StatementError: 

1679 pass # nb. invalid UUID 

1680 else: 

1681 if item: 

1682 items.append(item) 

1683 

1684 if not items: 

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

1686 raise self.redirect(self.get_index_url()) 

1687 

1688 return items 

1689 

1690 @classmethod 

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

1692 """ """ 

1693 cls._order_item_defaults(config) 

1694 cls._defaults(config) 

1695 

1696 @classmethod 

1697 def _order_item_defaults(cls, config): 

1698 """ """ 

1699 route_prefix = cls.get_route_prefix() 

1700 permission_prefix = cls.get_permission_prefix() 

1701 instance_url_prefix = cls.get_instance_url_prefix() 

1702 model_title = cls.get_model_title() 

1703 model_title_plural = cls.get_model_title_plural() 

1704 

1705 # fix perm group 

1706 config.add_wutta_permission_group( 

1707 permission_prefix, model_title_plural, overwrite=False 

1708 ) 

1709 

1710 # add note 

1711 config.add_route( 

1712 f"{route_prefix}.add_note", 

1713 f"{instance_url_prefix}/add_note", 

1714 request_method="POST", 

1715 ) 

1716 config.add_view( 

1717 cls, 

1718 attr="add_note", 

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

1720 renderer="json", 

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

1722 ) 

1723 config.add_wutta_permission( 

1724 permission_prefix, 

1725 f"{permission_prefix}.add_note", 

1726 f"Add note for {model_title}", 

1727 ) 

1728 

1729 # change status 

1730 config.add_route( 

1731 f"{route_prefix}.change_status", 

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

1733 request_method="POST", 

1734 ) 

1735 config.add_view( 

1736 cls, 

1737 attr="change_status", 

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

1739 renderer="json", 

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

1741 ) 

1742 config.add_wutta_permission( 

1743 permission_prefix, 

1744 f"{permission_prefix}.change_status", 

1745 f"Change status for {model_title}", 

1746 ) 

1747 

1748 

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

1750 """ 

1751 Master view for the "placement" phase of 

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

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

1754 

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

1756 status codes are shown: 

1757 

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

1759 

1760 Notable URLs provided by this class: 

1761 

1762 * ``/placement/`` 

1763 * ``/placement/XXX`` 

1764 """ 

1765 

1766 model_title = "Order Item (Placement)" 

1767 model_title_plural = "Order Items (Placement)" 

1768 route_prefix = "order_items_placement" 

1769 url_prefix = "/placement" 

1770 

1771 grid_columns = [ 

1772 "order_id", 

1773 "store_id", 

1774 "customer_name", 

1775 "product_brand", 

1776 "product_description", 

1777 "product_size", 

1778 "department_name", 

1779 "special_order", 

1780 "vendor_name", 

1781 "vendor_item_code", 

1782 "order_qty", 

1783 "order_uom", 

1784 "total_price", 

1785 ] 

1786 

1787 filter_defaults = { 

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

1789 } 

1790 

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

1792 """ """ 

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

1794 model = self.app.model 

1795 enum = self.app.enum 

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

1797 

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

1799 """ """ 

1800 g = grid 

1801 super().configure_grid(g) 

1802 

1803 # checkable 

1804 if self.has_perm("process_placement"): 

1805 g.checkable = True 

1806 

1807 # tool button: Order Placed 

1808 if self.has_perm("process_placement"): 

1809 button = self.make_button( 

1810 "Order Placed", 

1811 primary=True, 

1812 icon_left="arrow-circle-right", 

1813 **{ 

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

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

1816 }, 

1817 ) 

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

1819 

1820 def process_placement(self): 

1821 """ 

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

1823 

1824 This requires a POST request with data: 

1825 

1826 :param item_uuids: Comma-delimited list of 

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

1828 

1829 :param vendor_name: Optional name of vendor. 

1830 

1831 :param po_number: Optional PO number. 

1832 

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

1834 

1835 This invokes 

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

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

1838 back to the index page. 

1839 """ 

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

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

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

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

1844 

1845 self.order_handler.process_placement( 

1846 items, 

1847 self.request.user, 

1848 vendor_name=vendor_name, 

1849 po_number=po_number, 

1850 note=note, 

1851 ) 

1852 

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

1854 return self.redirect(self.get_index_url()) 

1855 

1856 @classmethod 

1857 def defaults(cls, config): 

1858 cls._order_item_defaults(config) 

1859 cls._placement_defaults(config) 

1860 cls._defaults(config) 

1861 

1862 @classmethod 

1863 def _placement_defaults(cls, config): 

1864 route_prefix = cls.get_route_prefix() 

1865 permission_prefix = cls.get_permission_prefix() 

1866 url_prefix = cls.get_url_prefix() 

1867 model_title_plural = cls.get_model_title_plural() 

1868 

1869 # process placement 

1870 config.add_wutta_permission( 

1871 permission_prefix, 

1872 f"{permission_prefix}.process_placement", 

1873 f"Process placement for {model_title_plural}", 

1874 ) 

1875 config.add_route( 

1876 f"{route_prefix}.process_placement", 

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

1878 request_method="POST", 

1879 ) 

1880 config.add_view( 

1881 cls, 

1882 attr="process_placement", 

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

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

1885 ) 

1886 

1887 

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

1889 """ 

1890 Master view for the "receiving" phase of 

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

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

1893 

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

1895 status codes are shown: 

1896 

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

1898 

1899 Notable URLs provided by this class: 

1900 

1901 * ``/receiving/`` 

1902 * ``/receiving/XXX`` 

1903 """ 

1904 

1905 model_title = "Order Item (Receiving)" 

1906 model_title_plural = "Order Items (Receiving)" 

1907 route_prefix = "order_items_receiving" 

1908 url_prefix = "/receiving" 

1909 

1910 grid_columns = [ 

1911 "order_id", 

1912 "store_id", 

1913 "customer_name", 

1914 "product_brand", 

1915 "product_description", 

1916 "product_size", 

1917 "department_name", 

1918 "special_order", 

1919 "vendor_name", 

1920 "vendor_item_code", 

1921 "order_qty", 

1922 "order_uom", 

1923 "total_price", 

1924 ] 

1925 

1926 filter_defaults = { 

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

1928 } 

1929 

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

1931 """ """ 

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

1933 model = self.app.model 

1934 enum = self.app.enum 

1935 return query.filter( 

1936 model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_PLACED 

1937 ) 

1938 

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

1940 """ """ 

1941 g = grid 

1942 super().configure_grid(g) 

1943 

1944 # checkable 

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

1946 g.checkable = True 

1947 

1948 # tool button: Received 

1949 if self.has_perm("process_receiving"): 

1950 button = self.make_button( 

1951 "Received", 

1952 primary=True, 

1953 icon_left="arrow-circle-right", 

1954 **{ 

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

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

1957 }, 

1958 ) 

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

1960 

1961 # tool button: Re-Order 

1962 if self.has_perm("process_reorder"): 

1963 button = self.make_button( 

1964 "Re-Order", 

1965 icon_left="redo", 

1966 **{ 

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

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

1969 }, 

1970 ) 

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

1972 

1973 def process_receiving(self): 

1974 """ 

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

1976 

1977 This requires a POST request with data: 

1978 

1979 :param item_uuids: Comma-delimited list of 

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

1981 

1982 :param vendor_name: Optional name of vendor. 

1983 

1984 :param invoice_number: Optional invoice number. 

1985 

1986 :param po_number: Optional PO number. 

1987 

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

1989 

1990 This invokes 

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

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

1993 back to the index page. 

1994 """ 

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

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

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

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

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

2000 

2001 self.order_handler.process_receiving( 

2002 items, 

2003 self.request.user, 

2004 vendor_name=vendor_name, 

2005 invoice_number=invoice_number, 

2006 po_number=po_number, 

2007 note=note, 

2008 ) 

2009 

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

2011 return self.redirect(self.get_index_url()) 

2012 

2013 def process_reorder(self): 

2014 """ 

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

2016 

2017 This requires a POST request with data: 

2018 

2019 :param item_uuids: Comma-delimited list of 

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

2021 

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

2023 

2024 This invokes 

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

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

2027 to the index page. 

2028 """ 

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

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

2031 

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

2033 

2034 self.request.session.flash( 

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

2036 ) 

2037 return self.redirect(self.get_index_url()) 

2038 

2039 @classmethod 

2040 def defaults(cls, config): 

2041 cls._order_item_defaults(config) 

2042 cls._receiving_defaults(config) 

2043 cls._defaults(config) 

2044 

2045 @classmethod 

2046 def _receiving_defaults(cls, config): 

2047 route_prefix = cls.get_route_prefix() 

2048 permission_prefix = cls.get_permission_prefix() 

2049 url_prefix = cls.get_url_prefix() 

2050 model_title_plural = cls.get_model_title_plural() 

2051 

2052 # process receiving 

2053 config.add_wutta_permission( 

2054 permission_prefix, 

2055 f"{permission_prefix}.process_receiving", 

2056 f"Process receiving for {model_title_plural}", 

2057 ) 

2058 config.add_route( 

2059 f"{route_prefix}.process_receiving", 

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

2061 request_method="POST", 

2062 ) 

2063 config.add_view( 

2064 cls, 

2065 attr="process_receiving", 

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

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

2068 ) 

2069 

2070 # process reorder 

2071 config.add_wutta_permission( 

2072 permission_prefix, 

2073 f"{permission_prefix}.process_reorder", 

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

2075 ) 

2076 config.add_route( 

2077 f"{route_prefix}.process_reorder", 

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

2079 request_method="POST", 

2080 ) 

2081 config.add_view( 

2082 cls, 

2083 attr="process_reorder", 

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

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

2086 ) 

2087 

2088 

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

2090 """ 

2091 Master view for the "contact" phase of 

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

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

2094 

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

2096 status codes are shown: 

2097 

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

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

2100 

2101 Notable URLs provided by this class: 

2102 

2103 * ``/contact/`` 

2104 * ``/contact/XXX`` 

2105 """ 

2106 

2107 model_title = "Order Item (Contact)" 

2108 model_title_plural = "Order Items (Contact)" 

2109 route_prefix = "order_items_contact" 

2110 url_prefix = "/contact" 

2111 

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

2113 """ """ 

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

2115 model = self.app.model 

2116 enum = self.app.enum 

2117 return query.filter( 

2118 model.OrderItem.status_code.in_( 

2119 (enum.ORDER_ITEM_STATUS_RECEIVED, enum.ORDER_ITEM_STATUS_CONTACT_FAILED) 

2120 ) 

2121 ) 

2122 

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

2124 """ """ 

2125 g = grid 

2126 super().configure_grid(g) 

2127 

2128 # checkable 

2129 if self.has_perm("process_contact"): 

2130 g.checkable = True 

2131 

2132 # tool button: Contact Success 

2133 if self.has_perm("process_contact"): 

2134 button = self.make_button( 

2135 "Contact Success", 

2136 primary=True, 

2137 icon_left="phone", 

2138 **{ 

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

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

2141 }, 

2142 ) 

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

2144 

2145 # tool button: Contact Failure 

2146 if self.has_perm("process_contact"): 

2147 button = self.make_button( 

2148 "Contact Failure", 

2149 variant="is-warning", 

2150 icon_left="phone", 

2151 **{ 

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

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

2154 }, 

2155 ) 

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

2157 

2158 def process_contact_success(self): 

2159 """ 

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

2161 item(s). 

2162 

2163 This requires a POST request with data: 

2164 

2165 :param item_uuids: Comma-delimited list of 

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

2167 

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

2169 

2170 This invokes 

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

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

2173 user back to the index page. 

2174 """ 

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

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

2177 

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

2179 

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

2181 return self.redirect(self.get_index_url()) 

2182 

2183 def process_contact_failure(self): 

2184 """ 

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

2186 item(s). 

2187 

2188 This requires a POST request with data: 

2189 

2190 :param item_uuids: Comma-delimited list of 

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

2192 

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

2194 

2195 This invokes 

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

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

2198 user back to the index page. 

2199 """ 

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

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

2202 

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

2204 

2205 self.request.session.flash( 

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

2207 ) 

2208 return self.redirect(self.get_index_url()) 

2209 

2210 @classmethod 

2211 def defaults(cls, config): 

2212 cls._order_item_defaults(config) 

2213 cls._contact_defaults(config) 

2214 cls._defaults(config) 

2215 

2216 @classmethod 

2217 def _contact_defaults(cls, config): 

2218 route_prefix = cls.get_route_prefix() 

2219 permission_prefix = cls.get_permission_prefix() 

2220 url_prefix = cls.get_url_prefix() 

2221 model_title_plural = cls.get_model_title_plural() 

2222 

2223 # common perm for processing contact success + failure 

2224 config.add_wutta_permission( 

2225 permission_prefix, 

2226 f"{permission_prefix}.process_contact", 

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

2228 ) 

2229 

2230 # process contact success 

2231 config.add_route( 

2232 f"{route_prefix}.process_contact_success", 

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

2234 request_method="POST", 

2235 ) 

2236 config.add_view( 

2237 cls, 

2238 attr="process_contact_success", 

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

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

2241 ) 

2242 

2243 # process contact failure 

2244 config.add_route( 

2245 f"{route_prefix}.process_contact_failure", 

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

2247 request_method="POST", 

2248 ) 

2249 config.add_view( 

2250 cls, 

2251 attr="process_contact_failure", 

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

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

2254 ) 

2255 

2256 

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

2258 """ 

2259 Master view for the "delivery" phase of 

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

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

2262 

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

2264 status codes are shown: 

2265 

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

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

2268 

2269 Notable URLs provided by this class: 

2270 

2271 * ``/delivery/`` 

2272 * ``/delivery/XXX`` 

2273 """ 

2274 

2275 model_title = "Order Item (Delivery)" 

2276 model_title_plural = "Order Items (Delivery)" 

2277 route_prefix = "order_items_delivery" 

2278 url_prefix = "/delivery" 

2279 

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

2281 """ """ 

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

2283 model = self.app.model 

2284 enum = self.app.enum 

2285 return query.filter( 

2286 model.OrderItem.status_code.in_( 

2287 (enum.ORDER_ITEM_STATUS_RECEIVED, enum.ORDER_ITEM_STATUS_CONTACTED) 

2288 ) 

2289 ) 

2290 

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

2292 """ """ 

2293 g = grid 

2294 super().configure_grid(g) 

2295 

2296 # checkable 

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

2298 g.checkable = True 

2299 

2300 # tool button: Delivered 

2301 if self.has_perm("process_delivery"): 

2302 button = self.make_button( 

2303 "Delivered", 

2304 primary=True, 

2305 icon_left="check", 

2306 **{ 

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

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

2309 }, 

2310 ) 

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

2312 

2313 # tool button: Restocked 

2314 if self.has_perm("process_restock"): 

2315 button = self.make_button( 

2316 "Restocked", 

2317 icon_left="redo", 

2318 **{ 

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

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

2321 }, 

2322 ) 

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

2324 

2325 def process_delivery(self): 

2326 """ 

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

2328 

2329 This requires a POST request with data: 

2330 

2331 :param item_uuids: Comma-delimited list of 

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

2333 

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

2335 

2336 This invokes 

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

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

2339 back to the index page. 

2340 """ 

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

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

2343 

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

2345 

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

2347 return self.redirect(self.get_index_url()) 

2348 

2349 def process_restock(self): 

2350 """ 

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

2352 

2353 This requires a POST request with data: 

2354 

2355 :param item_uuids: Comma-delimited list of 

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

2357 

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

2359 

2360 This invokes 

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

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

2363 to the index page. 

2364 """ 

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

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

2367 

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

2369 

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

2371 return self.redirect(self.get_index_url()) 

2372 

2373 @classmethod 

2374 def defaults(cls, config): 

2375 cls._order_item_defaults(config) 

2376 cls._delivery_defaults(config) 

2377 cls._defaults(config) 

2378 

2379 @classmethod 

2380 def _delivery_defaults(cls, config): 

2381 route_prefix = cls.get_route_prefix() 

2382 permission_prefix = cls.get_permission_prefix() 

2383 url_prefix = cls.get_url_prefix() 

2384 model_title_plural = cls.get_model_title_plural() 

2385 

2386 # process delivery 

2387 config.add_wutta_permission( 

2388 permission_prefix, 

2389 f"{permission_prefix}.process_delivery", 

2390 f"Process delivery for {model_title_plural}", 

2391 ) 

2392 config.add_route( 

2393 f"{route_prefix}.process_delivery", 

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

2395 request_method="POST", 

2396 ) 

2397 config.add_view( 

2398 cls, 

2399 attr="process_delivery", 

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

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

2402 ) 

2403 

2404 # process restock 

2405 config.add_wutta_permission( 

2406 permission_prefix, 

2407 f"{permission_prefix}.process_restock", 

2408 f"Process restock for {model_title_plural}", 

2409 ) 

2410 config.add_route( 

2411 f"{route_prefix}.process_restock", 

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

2413 request_method="POST", 

2414 ) 

2415 config.add_view( 

2416 cls, 

2417 attr="process_restock", 

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

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

2420 ) 

2421 

2422 

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

2424 base = globals() 

2425 

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

2427 "OrderView", base["OrderView"] 

2428 ) 

2429 OrderView.defaults(config) 

2430 

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

2432 "OrderItemView", base["OrderItemView"] 

2433 ) 

2434 OrderItemView.defaults(config) 

2435 

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

2437 "PlacementView", base["PlacementView"] 

2438 ) 

2439 PlacementView.defaults(config) 

2440 

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

2442 "ReceivingView", base["ReceivingView"] 

2443 ) 

2444 ReceivingView.defaults(config) 

2445 

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

2447 "ContactView", base["ContactView"] 

2448 ) 

2449 ContactView.defaults(config) 

2450 

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

2452 "DeliveryView", base["DeliveryView"] 

2453 ) 

2454 DeliveryView.defaults(config) 

2455 

2456 

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

2458 defaults(config)