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

713 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-26 13:16 -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 

27import decimal 

28import logging 

29 

30import colander 

31import sqlalchemy as sa 

32from sqlalchemy import orm 

33 

34from webhelpers2.html import tags, HTML 

35 

36from wuttaweb.views import MasterView 

37from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum 

38 

39from sideshow.db.model import Order, OrderItem 

40from sideshow.orders import OrderHandler 

41from sideshow.batch.neworder import NewOrderBatchHandler 

42from sideshow.web.forms.schema import (OrderRef, 

43 LocalCustomerRef, LocalProductRef, 

44 PendingCustomerRef, PendingProductRef) 

45 

46 

47log = logging.getLogger(__name__) 

48 

49 

50class OrderView(MasterView): 

51 """ 

52 Master view for :class:`~sideshow.db.model.orders.Order`; route 

53 prefix is ``orders``. 

54 

55 Notable URLs provided by this class: 

56 

57 * ``/orders/`` 

58 * ``/orders/new`` 

59 * ``/orders/XXX`` 

60 * ``/orders/XXX/delete`` 

61 

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

63 various other workflow actions to modify the order. 

64 

65 .. attribute:: order_handler 

66 

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

68 :meth:`get_order_handler()`. This gets set in the constructor. 

69 

70 .. attribute:: batch_handler 

71 

72 Reference to the :term:`new order batch` handler, as returned 

73 by :meth:`get_batch_handler()`. This gets set in the 

74 constructor. 

75 """ 

76 model_class = Order 

77 editable = False 

78 configurable = True 

79 

80 labels = { 

81 'order_id': "Order ID", 

82 'store_id': "Store ID", 

83 'customer_id': "Customer ID", 

84 } 

85 

86 grid_columns = [ 

87 'order_id', 

88 'store_id', 

89 'customer_id', 

90 'customer_name', 

91 'total_price', 

92 'created', 

93 'created_by', 

94 ] 

95 

96 sort_defaults = ('order_id', 'desc') 

97 

98 form_fields = [ 

99 'order_id', 

100 'store_id', 

101 'customer_id', 

102 'local_customer', 

103 'pending_customer', 

104 'customer_name', 

105 'phone_number', 

106 'email_address', 

107 'total_price', 

108 'created', 

109 'created_by', 

110 ] 

111 

112 has_rows = True 

113 row_model_class = OrderItem 

114 rows_title = "Order Items" 

115 rows_sort_defaults = 'sequence' 

116 rows_viewable = True 

117 

118 row_labels = { 

119 'product_scancode': "Scancode", 

120 'product_brand': "Brand", 

121 'product_description': "Description", 

122 'product_size': "Size", 

123 'department_name': "Department", 

124 'order_uom': "Order UOM", 

125 'status_code': "Status", 

126 } 

127 

128 row_grid_columns = [ 

129 'sequence', 

130 'product_scancode', 

131 'product_brand', 

132 'product_description', 

133 'product_size', 

134 'department_name', 

135 'special_order', 

136 'order_qty', 

137 'order_uom', 

138 'discount_percent', 

139 'total_price', 

140 'status_code', 

141 ] 

142 

143 PENDING_PRODUCT_ENTRY_FIELDS = [ 

144 'scancode', 

145 'brand_name', 

146 'description', 

147 'size', 

148 'department_name', 

149 'vendor_name', 

150 'vendor_item_code', 

151 'case_size', 

152 'unit_cost', 

153 'unit_price_reg', 

154 ] 

155 

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

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

158 self.order_handler = self.get_order_handler() 

159 

160 def get_order_handler(self): 

161 """ 

162 Returns the configured :term:`order handler`. 

163 

164 You normally would not need to call this, and can use 

165 :attr:`order_handler` instead. 

166 

167 :rtype: :class:`~sideshow.orders.OrderHandler` 

168 """ 

169 if hasattr(self, 'order_handler'): 

170 return self.order_handler 

171 return OrderHandler(self.config) 

172 

173 def get_batch_handler(self): 

174 """ 

175 Returns the configured :term:`handler` for :term:`new order 

176 batches <new order batch>`. 

177 

178 You normally would not need to call this, and can use 

179 :attr:`batch_handler` instead. 

180 

181 :returns: 

182 :class:`~sideshow.batch.neworder.NewOrderBatchHandler` 

183 instance. 

184 """ 

185 if hasattr(self, 'batch_handler'): 

186 return self.batch_handler 

187 return self.app.get_batch_handler('neworder') 

188 

189 def configure_grid(self, g): 

190 """ """ 

191 super().configure_grid(g) 

192 

193 # order_id 

194 g.set_link('order_id') 

195 

196 # customer_id 

197 g.set_link('customer_id') 

198 

199 # customer_name 

200 g.set_link('customer_name') 

201 

202 # total_price 

203 g.set_renderer('total_price', g.render_currency) 

204 

205 def create(self): 

206 """ 

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

208 of sorts. 

209 

210 Under the hood a 

211 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` is 

212 automatically created for the user when they first visit this 

213 page. They can select a customer, add items etc. 

214 

215 When user is finished assembling the order (i.e. populating 

216 the batch), they submit it. This of course executes the 

217 batch, which in turn creates a true 

218 :class:`~sideshow.db.model.orders.Order`, and user is 

219 redirected to the "view order" page. 

220 

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

222 based on user actions: 

223 

224 * :meth:`start_over()` 

225 * :meth:`cancel_order()` 

226 * :meth:`assign_customer()` 

227 * :meth:`unassign_customer()` 

228 * :meth:`set_pending_customer()` 

229 * :meth:`get_product_info()` 

230 * :meth:`add_item()` 

231 * :meth:`update_item()` 

232 * :meth:`delete_item()` 

233 * :meth:`submit_order()` 

234 """ 

235 enum = self.app.enum 

236 self.creating = True 

237 self.batch_handler = self.get_batch_handler() 

238 batch = self.get_current_batch() 

239 

240 context = self.get_context_customer(batch) 

241 

242 if self.request.method == 'POST': 

243 

244 # first we check for traditional form post 

245 action = self.request.POST.get('action') 

246 post_actions = [ 

247 'start_over', 

248 'cancel_order', 

249 ] 

250 if action in post_actions: 

251 return getattr(self, action)(batch) 

252 

253 # okay then, we'll assume newer JSON-style post params 

254 data = dict(self.request.json_body) 

255 action = data.pop('action') 

256 json_actions = [ 

257 'assign_customer', 

258 'unassign_customer', 

259 # 'update_phone_number', 

260 # 'update_email_address', 

261 'set_pending_customer', 

262 # 'get_customer_info', 

263 # # 'set_customer_data', 

264 'get_product_info', 

265 # 'get_past_items', 

266 'add_item', 

267 'update_item', 

268 'delete_item', 

269 'submit_order', 

270 ] 

271 if action in json_actions: 

272 try: 

273 result = getattr(self, action)(batch, data) 

274 except Exception as error: 

275 log.warning("error calling json action for order", exc_info=True) 

276 result = {'error': self.app.render_error(error)} 

277 return self.json_response(result) 

278 

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

280 

281 context.update({ 

282 'batch': batch, 

283 'normalized_batch': self.normalize_batch(batch), 

284 'order_items': [self.normalize_row(row) 

285 for row in batch.rows], 

286 'default_uom_choices': self.get_default_uom_choices(), 

287 'default_uom': None, # TODO? 

288 'allow_item_discounts': self.batch_handler.allow_item_discounts(), 

289 'allow_unknown_products': (self.batch_handler.allow_unknown_products() 

290 and self.has_perm('create_unknown_product')), 

291 'pending_product_required_fields': self.get_pending_product_required_fields(), 

292 }) 

293 

294 if context['allow_item_discounts']: 

295 context['allow_item_discounts_if_on_sale'] = self.batch_handler\ 

296 .allow_item_discounts_if_on_sale() 

297 # nb. render quantity so that '10.0' => '10' 

298 context['default_item_discount'] = self.app.render_quantity( 

299 self.batch_handler.get_default_item_discount()) 

300 

301 return self.render_to_response('create', context) 

302 

303 def get_current_batch(self): 

304 """ 

305 Returns the current batch for the current user. 

306 

307 This looks for a new order batch which was created by the 

308 user, but not yet executed. If none is found, a new batch is 

309 created. 

310 

311 :returns: 

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

313 instance 

314 """ 

315 model = self.app.model 

316 session = self.Session() 

317 

318 user = self.request.user 

319 if not user: 

320 raise self.forbidden() 

321 

322 try: 

323 # there should be at most *one* new batch per user 

324 batch = session.query(model.NewOrderBatch)\ 

325 .filter(model.NewOrderBatch.created_by == user)\ 

326 .filter(model.NewOrderBatch.executed == None)\ 

327 .one() 

328 

329 except orm.exc.NoResultFound: 

330 # no batch yet for this user, so make one 

331 batch = self.batch_handler.make_batch(session, created_by=user) 

332 session.add(batch) 

333 session.flush() 

334 

335 return batch 

336 

337 def customer_autocomplete(self): 

338 """ 

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

340 

341 This invokes one of the following on the 

342 :attr:`batch_handler`: 

343 

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

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

346 

347 :returns: List of search results; each should be a dict with 

348 ``value`` and ``label`` keys. 

349 """ 

350 session = self.Session() 

351 term = self.request.GET.get('term', '').strip() 

352 if not term: 

353 return [] 

354 

355 handler = self.get_batch_handler() 

356 if handler.use_local_customers(): 

357 return handler.autocomplete_customers_local(session, term, user=self.request.user) 

358 else: 

359 return handler.autocomplete_customers_external(session, term, user=self.request.user) 

360 

361 def product_autocomplete(self): 

362 """ 

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

364 

365 This invokes one of the following on the 

366 :attr:`batch_handler`: 

367 

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

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

370 

371 :returns: List of search results; each should be a dict with 

372 ``value`` and ``label`` keys. 

373 """ 

374 session = self.Session() 

375 term = self.request.GET.get('term', '').strip() 

376 if not term: 

377 return [] 

378 

379 handler = self.get_batch_handler() 

380 if handler.use_local_products(): 

381 return handler.autocomplete_products_local(session, term, user=self.request.user) 

382 else: 

383 return handler.autocomplete_products_external(session, term, user=self.request.user) 

384 

385 def get_pending_product_required_fields(self): 

386 """ """ 

387 required = [] 

388 for field in self.PENDING_PRODUCT_ENTRY_FIELDS: 

389 require = self.config.get_bool( 

390 f'sideshow.orders.unknown_product.fields.{field}.required') 

391 if require is None and field == 'description': 

392 require = True 

393 if require: 

394 required.append(field) 

395 return required 

396 

397 def start_over(self, batch): 

398 """ 

399 This will delete the user's current batch, then redirect user 

400 back to "Create Order" page, which in turn will auto-create a 

401 new batch for them. 

402 

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

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

405 

406 * :meth:`cancel_order()` 

407 * :meth:`submit_order()` 

408 """ 

409 # drop current batch 

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

411 self.Session.flush() 

412 

413 # send back to "create order" which makes new batch 

414 route_prefix = self.get_route_prefix() 

415 url = self.request.route_url(f'{route_prefix}.create') 

416 return self.redirect(url) 

417 

418 def cancel_order(self, batch): 

419 """ 

420 This will delete the user's current batch, then redirect user 

421 back to "List Orders" page. 

422 

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

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

425 

426 * :meth:`start_over()` 

427 * :meth:`submit_order()` 

428 """ 

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

430 self.Session.flush() 

431 

432 # set flash msg just to be more obvious 

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

434 

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

436 url = self.get_index_url() 

437 return self.redirect(url) 

438 

439 def get_context_customer(self, batch): 

440 """ """ 

441 context = { 

442 'customer_is_known': True, 

443 'customer_id': None, 

444 'customer_name': batch.customer_name, 

445 'phone_number': batch.phone_number, 

446 'email_address': batch.email_address, 

447 } 

448 

449 # customer_id 

450 use_local = self.batch_handler.use_local_customers() 

451 if use_local: 

452 local = batch.local_customer 

453 if local: 

454 context['customer_id'] = local.uuid.hex 

455 else: # use external 

456 context['customer_id'] = batch.customer_id 

457 

458 # pending customer 

459 pending = batch.pending_customer 

460 if pending: 

461 context.update({ 

462 'new_customer_first_name': pending.first_name, 

463 'new_customer_last_name': pending.last_name, 

464 'new_customer_full_name': pending.full_name, 

465 'new_customer_phone': pending.phone_number, 

466 'new_customer_email': pending.email_address, 

467 }) 

468 

469 # declare customer "not known" only if pending is in use 

470 if (pending 

471 and not batch.customer_id and not batch.local_customer 

472 and batch.customer_name): 

473 context['customer_is_known'] = False 

474 

475 return context 

476 

477 def assign_customer(self, batch, data): 

478 """ 

479 Assign the true customer account for a batch. 

480 

481 This calls 

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

483 for the heavy lifting. 

484 

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

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

487 

488 * :meth:`unassign_customer()` 

489 * :meth:`set_pending_customer()` 

490 """ 

491 customer_id = data.get('customer_id') 

492 if not customer_id: 

493 return {'error': "Must provide customer_id"} 

494 

495 self.batch_handler.set_customer(batch, customer_id) 

496 return self.get_context_customer(batch) 

497 

498 def unassign_customer(self, batch, data): 

499 """ 

500 Clear the customer info for a batch. 

501 

502 This calls 

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

504 for the heavy lifting. 

505 

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

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

508 

509 * :meth:`assign_customer()` 

510 * :meth:`set_pending_customer()` 

511 """ 

512 self.batch_handler.set_customer(batch, None) 

513 return self.get_context_customer(batch) 

514 

515 def set_pending_customer(self, batch, data): 

516 """ 

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

518 

519 This calls 

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

521 for the heavy lifting. 

522 

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

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

525 

526 * :meth:`assign_customer()` 

527 * :meth:`unassign_customer()` 

528 """ 

529 self.batch_handler.set_customer(batch, data, user=self.request.user) 

530 return self.get_context_customer(batch) 

531 

532 def get_product_info(self, batch, data): 

533 """ 

534 Fetch data for a specific product. (Nothing is modified.) 

535 

536 Depending on config, this will fetch a :term:`local product` 

537 or :term:`external product` to get the data. 

538 

539 This should invoke a configured handler for the query 

540 behavior, but that is not yet implemented. For now it uses 

541 built-in logic only, which queries the 

542 :class:`~sideshow.db.model.products.LocalProduct` table. 

543 

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

545 :meth:`create()`. 

546 """ 

547 product_id = data.get('product_id') 

548 if not product_id: 

549 return {'error': "Must specify a product ID"} 

550 

551 session = self.Session() 

552 use_local = self.batch_handler.use_local_products() 

553 if use_local: 

554 data = self.batch_handler.get_product_info_local(session, product_id) 

555 else: 

556 data = self.batch_handler.get_product_info_external(session, product_id) 

557 

558 if 'error' in data: 

559 return data 

560 

561 if 'unit_price_reg' in data and 'unit_price_reg_display' not in data: 

562 data['unit_price_reg_display'] = self.app.render_currency(data['unit_price_reg']) 

563 

564 if 'unit_price_reg' in data and 'unit_price_quoted' not in data: 

565 data['unit_price_quoted'] = data['unit_price_reg'] 

566 

567 if 'unit_price_quoted' in data and 'unit_price_quoted_display' not in data: 

568 data['unit_price_quoted_display'] = self.app.render_currency(data['unit_price_quoted']) 

569 

570 if 'case_price_quoted' not in data: 

571 if data.get('unit_price_quoted') is not None and data.get('case_size') is not None: 

572 data['case_price_quoted'] = data['unit_price_quoted'] * data['case_size'] 

573 

574 if 'case_price_quoted' in data and 'case_price_quoted_display' not in data: 

575 data['case_price_quoted_display'] = self.app.render_currency(data['case_price_quoted']) 

576 

577 if 'default_item_discount' not in data: 

578 data['default_item_discount'] = self.batch_handler.get_default_item_discount() 

579 

580 decimal_fields = [ 

581 'case_size', 

582 'unit_price_reg', 

583 'unit_price_quoted', 

584 'case_price_quoted', 

585 'default_item_discount', 

586 ] 

587 

588 for field in decimal_fields: 

589 if field in list(data): 

590 value = data[field] 

591 if isinstance(value, decimal.Decimal): 

592 data[field] = float(value) 

593 

594 return data 

595 

596 def add_item(self, batch, data): 

597 """ 

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

599 

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

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

602 

603 * :meth:`update_item()` 

604 * :meth:`delete_item()` 

605 """ 

606 kw = {'user': self.request.user} 

607 if 'discount_percent' in data and self.batch_handler.allow_item_discounts(): 

608 kw['discount_percent'] = data['discount_percent'] 

609 row = self.batch_handler.add_item(batch, data['product_info'], 

610 data['order_qty'], data['order_uom'], **kw) 

611 

612 return {'batch': self.normalize_batch(batch), 

613 'row': self.normalize_row(row)} 

614 

615 def update_item(self, batch, data): 

616 """ 

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

618 

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

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

621 

622 * :meth:`add_item()` 

623 * :meth:`delete_item()` 

624 """ 

625 model = self.app.model 

626 session = self.Session() 

627 

628 uuid = data.get('uuid') 

629 if not uuid: 

630 return {'error': "Must specify row UUID"} 

631 

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

633 if not row: 

634 return {'error': "Row not found"} 

635 

636 if row.batch is not batch: 

637 return {'error': "Row is for wrong batch"} 

638 

639 kw = {'user': self.request.user} 

640 if 'discount_percent' in data and self.batch_handler.allow_item_discounts(): 

641 kw['discount_percent'] = data['discount_percent'] 

642 self.batch_handler.update_item(row, data['product_info'], 

643 data['order_qty'], data['order_uom'], **kw) 

644 

645 return {'batch': self.normalize_batch(batch), 

646 'row': self.normalize_row(row)} 

647 

648 def delete_item(self, batch, data): 

649 """ 

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

651 

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

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

654 

655 * :meth:`add_item()` 

656 * :meth:`update_item()` 

657 """ 

658 model = self.app.model 

659 session = self.app.get_session(batch) 

660 

661 uuid = data.get('uuid') 

662 if not uuid: 

663 return {'error': "Must specify a row UUID"} 

664 

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

666 if not row: 

667 return {'error': "Row not found"} 

668 

669 if row.batch is not batch: 

670 return {'error': "Row is for wrong batch"} 

671 

672 self.batch_handler.do_remove_row(row) 

673 return {'batch': self.normalize_batch(batch)} 

674 

675 def submit_order(self, batch, data): 

676 """ 

677 This submits the user's current new order batch, hence 

678 executing the batch and creating the true order. 

679 

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

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

682 

683 * :meth:`start_over()` 

684 * :meth:`cancel_order()` 

685 """ 

686 user = self.request.user 

687 reason = self.batch_handler.why_not_execute(batch, user=user) 

688 if reason: 

689 return {'error': reason} 

690 

691 try: 

692 order = self.batch_handler.do_execute(batch, user) 

693 except Exception as error: 

694 log.warning("failed to execute new order batch: %s", batch, 

695 exc_info=True) 

696 return {'error': self.app.render_error(error)} 

697 

698 return { 

699 'next_url': self.get_action_url('view', order), 

700 } 

701 

702 def normalize_batch(self, batch): 

703 """ """ 

704 return { 

705 'uuid': batch.uuid.hex, 

706 'total_price': str(batch.total_price or 0), 

707 'total_price_display': self.app.render_currency(batch.total_price), 

708 'status_code': batch.status_code, 

709 'status_text': batch.status_text, 

710 } 

711 

712 def get_default_uom_choices(self): 

713 """ """ 

714 enum = self.app.enum 

715 return [{'key': key, 'value': val} 

716 for key, val in enum.ORDER_UOM.items()] 

717 

718 def normalize_row(self, row): 

719 """ """ 

720 data = { 

721 'uuid': row.uuid.hex, 

722 'sequence': row.sequence, 

723 'product_id': None, 

724 'product_scancode': row.product_scancode, 

725 'product_brand': row.product_brand, 

726 'product_description': row.product_description, 

727 'product_size': row.product_size, 

728 'product_full_description': self.app.make_full_name(row.product_brand, 

729 row.product_description, 

730 row.product_size), 

731 'product_weighed': row.product_weighed, 

732 'department_display': row.department_name, 

733 'special_order': row.special_order, 

734 'case_size': float(row.case_size) if row.case_size is not None else None, 

735 'order_qty': float(row.order_qty), 

736 'order_uom': row.order_uom, 

737 'order_uom_choices': self.get_default_uom_choices(), 

738 'discount_percent': self.app.render_quantity(row.discount_percent), 

739 'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None, 

740 'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted), 

741 'case_price_quoted': float(row.case_price_quoted) if row.case_price_quoted is not None else None, 

742 'case_price_quoted_display': self.app.render_currency(row.case_price_quoted), 

743 'total_price': float(row.total_price) if row.total_price is not None else None, 

744 'total_price_display': self.app.render_currency(row.total_price), 

745 'status_code': row.status_code, 

746 'status_text': row.status_text, 

747 } 

748 

749 use_local = self.batch_handler.use_local_products() 

750 

751 # product_id 

752 if use_local: 

753 if row.local_product: 

754 data['product_id'] = row.local_product.uuid.hex 

755 else: 

756 data['product_id'] = row.product_id 

757 

758 # vendor_name 

759 if use_local: 

760 if row.local_product: 

761 data['vendor_name'] = row.local_product.vendor_name 

762 else: # use external 

763 pass # TODO 

764 if not data.get('product_id') and row.pending_product: 

765 data['vendor_name'] = row.pending_product.vendor_name 

766 

767 if row.unit_price_reg: 

768 data['unit_price_reg'] = float(row.unit_price_reg) 

769 data['unit_price_reg_display'] = self.app.render_currency(row.unit_price_reg) 

770 

771 if row.unit_price_sale: 

772 data['unit_price_sale'] = float(row.unit_price_sale) 

773 data['unit_price_sale_display'] = self.app.render_currency(row.unit_price_sale) 

774 if row.sale_ends: 

775 sale_ends = row.sale_ends 

776 data['sale_ends'] = str(row.sale_ends) 

777 data['sale_ends_display'] = self.app.render_date(row.sale_ends) 

778 

779 if row.pending_product: 

780 pending = row.pending_product 

781 data['pending_product'] = { 

782 'uuid': pending.uuid.hex, 

783 'scancode': pending.scancode, 

784 'brand_name': pending.brand_name, 

785 'description': pending.description, 

786 'size': pending.size, 

787 'department_id': pending.department_id, 

788 'department_name': pending.department_name, 

789 'unit_price_reg': float(pending.unit_price_reg) if pending.unit_price_reg is not None else None, 

790 'vendor_name': pending.vendor_name, 

791 'vendor_item_code': pending.vendor_item_code, 

792 'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None, 

793 'case_size': float(pending.case_size) if pending.case_size is not None else None, 

794 'notes': pending.notes, 

795 'special_order': pending.special_order, 

796 } 

797 

798 # display text for order qty/uom 

799 data['order_qty_display'] = self.order_handler.get_order_qty_uom_text( 

800 row.order_qty, row.order_uom, case_size=row.case_size, html=True) 

801 

802 return data 

803 

804 def get_instance_title(self, order): 

805 """ """ 

806 return f"#{order.order_id} for {order.customer_name}" 

807 

808 def configure_form(self, f): 

809 """ """ 

810 super().configure_form(f) 

811 order = f.model_instance 

812 

813 # local_customer 

814 if order.customer_id and not order.local_customer: 

815 f.remove('local_customer') 

816 else: 

817 f.set_node('local_customer', LocalCustomerRef(self.request)) 

818 

819 # pending_customer 

820 if order.customer_id or order.local_customer: 

821 f.remove('pending_customer') 

822 else: 

823 f.set_node('pending_customer', PendingCustomerRef(self.request)) 

824 

825 # total_price 

826 f.set_node('total_price', WuttaMoney(self.request)) 

827 

828 # created_by 

829 f.set_node('created_by', UserRef(self.request)) 

830 f.set_readonly('created_by') 

831 

832 def get_xref_buttons(self, order): 

833 """ """ 

834 buttons = super().get_xref_buttons(order) 

835 model = self.app.model 

836 session = self.Session() 

837 

838 if self.request.has_perm('neworder_batches.view'): 

839 batch = session.query(model.NewOrderBatch)\ 

840 .filter(model.NewOrderBatch.id == order.order_id)\ 

841 .first() 

842 if batch: 

843 url = self.request.route_url('neworder_batches.view', uuid=batch.uuid) 

844 buttons.append( 

845 self.make_button("View the Batch", primary=True, icon_left='eye', url=url)) 

846 

847 return buttons 

848 

849 def get_row_grid_data(self, order): 

850 """ """ 

851 model = self.app.model 

852 session = self.Session() 

853 return session.query(model.OrderItem)\ 

854 .filter(model.OrderItem.order == order) 

855 

856 def configure_row_grid(self, g): 

857 """ """ 

858 super().configure_row_grid(g) 

859 # enum = self.app.enum 

860 

861 # sequence 

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

863 g.set_link('sequence') 

864 

865 # product_scancode 

866 g.set_link('product_scancode') 

867 

868 # product_brand 

869 g.set_link('product_brand') 

870 

871 # product_description 

872 g.set_link('product_description') 

873 

874 # product_size 

875 g.set_link('product_size') 

876 

877 # TODO 

878 # order_uom 

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

880 

881 # discount_percent 

882 g.set_renderer('discount_percent', 'percent') 

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

884 

885 # total_price 

886 g.set_renderer('total_price', g.render_currency) 

887 

888 # status_code 

889 g.set_renderer('status_code', self.render_status_code) 

890 

891 # TODO: upstream should set this automatically 

892 g.row_class = self.row_grid_row_class 

893 

894 def row_grid_row_class(self, item, data, i): 

895 """ """ 

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

897 if variant: 

898 return f'has-background-{variant}' 

899 

900 def render_status_code(self, item, key, value): 

901 """ """ 

902 enum = self.app.enum 

903 return enum.ORDER_ITEM_STATUS[value] 

904 

905 def get_row_action_url_view(self, item, i): 

906 """ """ 

907 return self.request.route_url('order_items.view', uuid=item.uuid) 

908 

909 def configure_get_simple_settings(self): 

910 """ """ 

911 settings = [ 

912 

913 # batches 

914 {'name': 'wutta.batch.neworder.handler.spec'}, 

915 

916 # customers 

917 {'name': 'sideshow.orders.use_local_customers', 

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

919 #'type': bool, 

920 'default': 'true'}, 

921 

922 # products 

923 {'name': 'sideshow.orders.allow_item_discounts', 

924 'type': bool}, 

925 {'name': 'sideshow.orders.allow_item_discounts_if_on_sale', 

926 'type': bool}, 

927 {'name': 'sideshow.orders.default_item_discount', 

928 'type': float}, 

929 {'name': 'sideshow.orders.use_local_products', 

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

931 #'type': bool, 

932 'default': 'true'}, 

933 {'name': 'sideshow.orders.allow_unknown_products', 

934 'type': bool, 

935 'default': True}, 

936 ] 

937 

938 # required fields for new product entry 

939 for field in self.PENDING_PRODUCT_ENTRY_FIELDS: 

940 setting = {'name': f'sideshow.orders.unknown_product.fields.{field}.required', 

941 'type': bool} 

942 if field == 'description': 

943 setting['default'] = True 

944 settings.append(setting) 

945 

946 return settings 

947 

948 def configure_get_context(self, **kwargs): 

949 """ """ 

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

951 

952 context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS 

953 

954 handlers = self.app.get_batch_handler_specs('neworder') 

955 handlers = [{'spec': spec} for spec in handlers] 

956 context['batch_handlers'] = handlers 

957 

958 return context 

959 

960 @classmethod 

961 def defaults(cls, config): 

962 cls._order_defaults(config) 

963 cls._defaults(config) 

964 

965 @classmethod 

966 def _order_defaults(cls, config): 

967 route_prefix = cls.get_route_prefix() 

968 permission_prefix = cls.get_permission_prefix() 

969 url_prefix = cls.get_url_prefix() 

970 model_title = cls.get_model_title() 

971 model_title_plural = cls.get_model_title_plural() 

972 

973 # fix perm group 

974 config.add_wutta_permission_group(permission_prefix, 

975 model_title_plural, 

976 overwrite=False) 

977 

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

979 config.add_wutta_permission(permission_prefix, 

980 f'{permission_prefix}.create_unknown_product', 

981 f"Create new {model_title} for unknown/pending product") 

982 

983 # customer autocomplete 

984 config.add_route(f'{route_prefix}.customer_autocomplete', 

985 f'{url_prefix}/customer-autocomplete', 

986 request_method='GET') 

987 config.add_view(cls, attr='customer_autocomplete', 

988 route_name=f'{route_prefix}.customer_autocomplete', 

989 renderer='json', 

990 permission=f'{permission_prefix}.list') 

991 

992 # product autocomplete 

993 config.add_route(f'{route_prefix}.product_autocomplete', 

994 f'{url_prefix}/product-autocomplete', 

995 request_method='GET') 

996 config.add_view(cls, attr='product_autocomplete', 

997 route_name=f'{route_prefix}.product_autocomplete', 

998 renderer='json', 

999 permission=f'{permission_prefix}.list') 

1000 

1001 

1002class OrderItemView(MasterView): 

1003 """ 

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

1005 route prefix is ``order_items``. 

1006 

1007 Notable URLs provided by this class: 

1008 

1009 * ``/order-items/`` 

1010 * ``/order-items/XXX`` 

1011 

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

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

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

1015 

1016 * :class:`PlacementView` 

1017 * :class:`ReceivingView` 

1018 * :class:`ContactView` 

1019 * :class:`DeliveryView` 

1020 

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

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

1023 

1024 .. attribute:: order_handler 

1025 

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

1027 :meth:`get_order_handler()`. 

1028 """ 

1029 model_class = OrderItem 

1030 model_title = "Order Item (All)" 

1031 model_title_plural = "Order Items (All)" 

1032 route_prefix = 'order_items' 

1033 url_prefix = '/order-items' 

1034 creatable = False 

1035 editable = False 

1036 deletable = False 

1037 

1038 labels = { 

1039 'order_id': "Order ID", 

1040 'product_id': "Product ID", 

1041 'product_scancode': "Scancode", 

1042 'product_brand': "Brand", 

1043 'product_description': "Description", 

1044 'product_size': "Size", 

1045 'product_weighed': "Sold by Weight", 

1046 'department_id': "Department ID", 

1047 'order_uom': "Order UOM", 

1048 'status_code': "Status", 

1049 } 

1050 

1051 grid_columns = [ 

1052 'order_id', 

1053 'customer_name', 

1054 # 'sequence', 

1055 'product_scancode', 

1056 'product_brand', 

1057 'product_description', 

1058 'product_size', 

1059 'department_name', 

1060 'special_order', 

1061 'order_qty', 

1062 'order_uom', 

1063 'total_price', 

1064 'status_code', 

1065 ] 

1066 

1067 sort_defaults = ('order_id', 'desc') 

1068 

1069 form_fields = [ 

1070 'order', 

1071 # 'customer_name', 

1072 'sequence', 

1073 'product_id', 

1074 'local_product', 

1075 'pending_product', 

1076 'product_scancode', 

1077 'product_brand', 

1078 'product_description', 

1079 'product_size', 

1080 'product_weighed', 

1081 'department_id', 

1082 'department_name', 

1083 'special_order', 

1084 'case_size', 

1085 'unit_cost', 

1086 'unit_price_reg', 

1087 'unit_price_sale', 

1088 'sale_ends', 

1089 'unit_price_quoted', 

1090 'case_price_quoted', 

1091 'order_qty', 

1092 'order_uom', 

1093 'discount_percent', 

1094 'total_price', 

1095 'status_code', 

1096 'paid_amount', 

1097 'payment_transaction_number', 

1098 ] 

1099 

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

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

1102 self.order_handler = self.get_order_handler() 

1103 

1104 def get_order_handler(self): 

1105 """ 

1106 Returns the configured :term:`order handler`. 

1107 

1108 You normally would not need to call this, and can use 

1109 :attr:`order_handler` instead. 

1110 

1111 :rtype: :class:`~sideshow.orders.OrderHandler` 

1112 """ 

1113 if hasattr(self, 'order_handler'): 

1114 return self.order_handler 

1115 return OrderHandler(self.config) 

1116 

1117 def get_fallback_templates(self, template): 

1118 """ """ 

1119 templates = super().get_fallback_templates(template) 

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

1121 return templates 

1122 

1123 def get_query(self, session=None): 

1124 """ """ 

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

1126 model = self.app.model 

1127 return query.join(model.Order) 

1128 

1129 def configure_grid(self, g): 

1130 """ """ 

1131 super().configure_grid(g) 

1132 model = self.app.model 

1133 # enum = self.app.enum 

1134 

1135 # order_id 

1136 g.set_sorter('order_id', model.Order.order_id) 

1137 g.set_renderer('order_id', self.render_order_id) 

1138 g.set_link('order_id') 

1139 

1140 # customer_name 

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

1142 

1143 # # sequence 

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

1145 

1146 # product_scancode 

1147 g.set_link('product_scancode') 

1148 

1149 # product_brand 

1150 g.set_link('product_brand') 

1151 

1152 # product_description 

1153 g.set_link('product_description') 

1154 

1155 # product_size 

1156 g.set_link('product_size') 

1157 

1158 # order_uom 

1159 # TODO 

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

1161 

1162 # total_price 

1163 g.set_renderer('total_price', g.render_currency) 

1164 

1165 # status_code 

1166 g.set_renderer('status_code', self.render_status_code) 

1167 

1168 def render_order_id(self, item, key, value): 

1169 """ """ 

1170 return item.order.order_id 

1171 

1172 def render_status_code(self, item, key, value): 

1173 """ """ 

1174 enum = self.app.enum 

1175 return enum.ORDER_ITEM_STATUS[value] 

1176 

1177 def grid_row_class(self, item, data, i): 

1178 """ """ 

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

1180 if variant: 

1181 return f'has-background-{variant}' 

1182 

1183 def configure_form(self, f): 

1184 """ """ 

1185 super().configure_form(f) 

1186 enum = self.app.enum 

1187 item = f.model_instance 

1188 

1189 # order 

1190 f.set_node('order', OrderRef(self.request)) 

1191 

1192 # local_product 

1193 f.set_node('local_product', LocalProductRef(self.request)) 

1194 

1195 # pending_product 

1196 if item.product_id or item.local_product: 

1197 f.remove('pending_product') 

1198 else: 

1199 f.set_node('pending_product', PendingProductRef(self.request)) 

1200 

1201 # order_qty 

1202 f.set_node('order_qty', WuttaQuantity(self.request)) 

1203 

1204 # order_uom 

1205 f.set_node('order_uom', WuttaDictEnum(self.request, enum.ORDER_UOM)) 

1206 

1207 # case_size 

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

1209 

1210 # unit_cost 

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

1212 

1213 # unit_price_reg 

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

1215 

1216 # unit_price_quoted 

1217 f.set_node('unit_price_quoted', WuttaMoney(self.request)) 

1218 

1219 # case_price_quoted 

1220 f.set_node('case_price_quoted', WuttaMoney(self.request)) 

1221 

1222 # total_price 

1223 f.set_node('total_price', WuttaMoney(self.request)) 

1224 

1225 # status 

1226 f.set_node('status_code', WuttaDictEnum(self.request, enum.ORDER_ITEM_STATUS)) 

1227 

1228 # paid_amount 

1229 f.set_node('paid_amount', WuttaMoney(self.request)) 

1230 

1231 def get_template_context(self, context): 

1232 """ """ 

1233 if self.viewing: 

1234 model = self.app.model 

1235 enum = self.app.enum 

1236 route_prefix = self.get_route_prefix() 

1237 item = context['instance'] 

1238 form = context['form'] 

1239 

1240 context['item'] = item 

1241 context['order'] = item.order 

1242 context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text( 

1243 item.order_qty, item.order_uom, case_size=item.case_size, html=True) 

1244 context['item_status_variant'] = self.order_handler.item_status_to_variant(item.status_code) 

1245 

1246 grid = self.make_grid(key=f'{route_prefix}.view.events', 

1247 model_class=model.OrderItemEvent, 

1248 data=item.events, 

1249 columns=[ 

1250 'occurred', 

1251 'actor', 

1252 'type_code', 

1253 'note', 

1254 ], 

1255 labels={ 

1256 'occurred': "Date/Time", 

1257 'actor': "User", 

1258 'type_code': "Event Type", 

1259 }) 

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

1261 grid.set_renderer('note', self.render_event_note) 

1262 if self.request.has_perm('users.view'): 

1263 grid.set_renderer('actor', lambda e, k, v: tags.link_to( 

1264 e.actor, self.request.route_url('users.view', uuid=e.actor.uuid))) 

1265 form.add_grid_vue_context(grid) 

1266 context['events_grid'] = grid 

1267 

1268 return context 

1269 

1270 def render_event_note(self, event, key, value): 

1271 """ """ 

1272 enum = self.app.enum 

1273 if event.type_code == enum.ORDER_ITEM_EVENT_NOTE_ADDED: 

1274 return HTML.tag('span', class_='has-background-info-light', 

1275 style='padding: 0.25rem 0.5rem;', 

1276 c=[value]) 

1277 return value 

1278 

1279 def get_xref_buttons(self, item): 

1280 """ """ 

1281 buttons = super().get_xref_buttons(item) 

1282 

1283 if self.request.has_perm('orders.view'): 

1284 url = self.request.route_url('orders.view', uuid=item.order_uuid) 

1285 buttons.append( 

1286 self.make_button("View the Order", url=url, 

1287 primary=True, icon_left='eye')) 

1288 

1289 return buttons 

1290 

1291 def add_note(self): 

1292 """ 

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

1294 will redirect back to the item view. 

1295 """ 

1296 enum = self.app.enum 

1297 item = self.get_instance() 

1298 

1299 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, self.request.user, 

1300 note=self.request.POST['note']) 

1301 

1302 return self.redirect(self.get_action_url('view', item)) 

1303 

1304 def change_status(self): 

1305 """ 

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

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

1308 """ 

1309 model = self.app.model 

1310 enum = self.app.enum 

1311 main_item = self.get_instance() 

1312 session = self.Session() 

1313 redirect = self.redirect(self.get_action_url('view', main_item)) 

1314 

1315 extra_note = self.request.POST.get('note') 

1316 

1317 # validate new status 

1318 new_status_code = int(self.request.POST['new_status']) 

1319 if new_status_code not in enum.ORDER_ITEM_STATUS: 

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

1321 return redirect 

1322 new_status_text = enum.ORDER_ITEM_STATUS[new_status_code] 

1323 

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

1325 items = [main_item] 

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

1327 # if uuids: 

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

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

1330 # if item: 

1331 # items.append(item) 

1332 

1333 # update item(s) 

1334 for item in items: 

1335 if item.status_code != new_status_code: 

1336 

1337 # event: change status 

1338 note = 'status changed from "{}" to "{}"'.format( 

1339 enum.ORDER_ITEM_STATUS[item.status_code], 

1340 new_status_text) 

1341 item.add_event(enum.ORDER_ITEM_EVENT_STATUS_CHANGE, 

1342 self.request.user, note=note) 

1343 

1344 # event: add note 

1345 if extra_note: 

1346 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, 

1347 self.request.user, note=extra_note) 

1348 

1349 # new status 

1350 item.status_code = new_status_code 

1351 

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

1353 return redirect 

1354 

1355 def get_order_items(self, uuids): 

1356 """ 

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

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

1359 workflow action methods. 

1360 

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

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

1363 

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

1365 

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

1367 records. 

1368 """ 

1369 model = self.app.model 

1370 session = self.Session() 

1371 

1372 if uuids is None: 

1373 uuids = [] 

1374 elif isinstance(uuids, str): 

1375 uuids = uuids.split(',') 

1376 

1377 items = [] 

1378 for uuid in uuids: 

1379 if isinstance(uuid, str): 

1380 uuid = uuid.strip() 

1381 if uuid: 

1382 try: 

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

1384 except sa.exc.StatementError: 

1385 pass # nb. invalid UUID 

1386 else: 

1387 if item: 

1388 items.append(item) 

1389 

1390 if not items: 

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

1392 raise self.redirect(self.get_index_url()) 

1393 

1394 return items 

1395 

1396 @classmethod 

1397 def defaults(cls, config): 

1398 """ """ 

1399 cls._order_item_defaults(config) 

1400 cls._defaults(config) 

1401 

1402 @classmethod 

1403 def _order_item_defaults(cls, config): 

1404 """ """ 

1405 route_prefix = cls.get_route_prefix() 

1406 permission_prefix = cls.get_permission_prefix() 

1407 instance_url_prefix = cls.get_instance_url_prefix() 

1408 model_title = cls.get_model_title() 

1409 model_title_plural = cls.get_model_title_plural() 

1410 

1411 # fix perm group 

1412 config.add_wutta_permission_group(permission_prefix, 

1413 model_title_plural, 

1414 overwrite=False) 

1415 

1416 # add note 

1417 config.add_route(f'{route_prefix}.add_note', 

1418 f'{instance_url_prefix}/add_note', 

1419 request_method='POST') 

1420 config.add_view(cls, attr='add_note', 

1421 route_name=f'{route_prefix}.add_note', 

1422 renderer='json', 

1423 permission=f'{permission_prefix}.add_note') 

1424 config.add_wutta_permission(permission_prefix, 

1425 f'{permission_prefix}.add_note', 

1426 f"Add note for {model_title}") 

1427 

1428 # change status 

1429 config.add_route(f'{route_prefix}.change_status', 

1430 f'{instance_url_prefix}/change-status', 

1431 request_method='POST') 

1432 config.add_view(cls, attr='change_status', 

1433 route_name=f'{route_prefix}.change_status', 

1434 renderer='json', 

1435 permission=f'{permission_prefix}.change_status') 

1436 config.add_wutta_permission(permission_prefix, 

1437 f'{permission_prefix}.change_status', 

1438 f"Change status for {model_title}") 

1439 

1440 

1441class PlacementView(OrderItemView): 

1442 """ 

1443 Master view for the "placement" phase of 

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

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

1446 

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

1448 status codes are shown: 

1449 

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

1451 

1452 Notable URLs provided by this class: 

1453 

1454 * ``/placement/`` 

1455 * ``/placement/XXX`` 

1456 """ 

1457 model_title = "Order Item (Placement)" 

1458 model_title_plural = "Order Items (Placement)" 

1459 route_prefix = 'order_items_placement' 

1460 url_prefix = '/placement' 

1461 

1462 def get_query(self, session=None): 

1463 """ """ 

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

1465 model = self.app.model 

1466 enum = self.app.enum 

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

1468 

1469 def configure_grid(self, g): 

1470 """ """ 

1471 super().configure_grid(g) 

1472 

1473 # checkable 

1474 if self.has_perm('process_placement'): 

1475 g.checkable = True 

1476 

1477 # tool button: Order Placed 

1478 if self.has_perm('process_placement'): 

1479 button = self.make_button("Order Placed", primary=True, 

1480 icon_left='arrow-circle-right', 

1481 **{'@click': "$emit('process-placement', checkedRows)", 

1482 ':disabled': '!checkedRows.length'}) 

1483 g.add_tool(button, key='process_placement') 

1484 

1485 def process_placement(self): 

1486 """ 

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

1488 

1489 This requires a POST request with data: 

1490 

1491 :param item_uuids: Comma-delimited list of 

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

1493 

1494 :param vendor_name: Optional name of vendor. 

1495 

1496 :param po_number: Optional PO number. 

1497 

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

1499 

1500 This invokes 

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

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

1503 back to the index page. 

1504 """ 

1505 items = self.get_order_items(self.request.POST.get('item_uuids', '')) 

1506 vendor_name = self.request.POST.get('vendor_name', '').strip() or None 

1507 po_number = self.request.POST.get('po_number', '').strip() or None 

1508 note = self.request.POST.get('note', '').strip() or None 

1509 

1510 self.order_handler.process_placement(items, self.request.user, 

1511 vendor_name=vendor_name, 

1512 po_number=po_number, 

1513 note=note) 

1514 

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

1516 return self.redirect(self.get_index_url()) 

1517 

1518 @classmethod 

1519 def defaults(cls, config): 

1520 cls._order_item_defaults(config) 

1521 cls._placement_defaults(config) 

1522 cls._defaults(config) 

1523 

1524 @classmethod 

1525 def _placement_defaults(cls, config): 

1526 route_prefix = cls.get_route_prefix() 

1527 permission_prefix = cls.get_permission_prefix() 

1528 url_prefix = cls.get_url_prefix() 

1529 model_title_plural = cls.get_model_title_plural() 

1530 

1531 # process placement 

1532 config.add_wutta_permission(permission_prefix, 

1533 f'{permission_prefix}.process_placement', 

1534 f"Process placement for {model_title_plural}") 

1535 config.add_route(f'{route_prefix}.process_placement', 

1536 f'{url_prefix}/process-placement', 

1537 request_method='POST') 

1538 config.add_view(cls, attr='process_placement', 

1539 route_name=f'{route_prefix}.process_placement', 

1540 permission=f'{permission_prefix}.process_placement') 

1541 

1542 

1543class ReceivingView(OrderItemView): 

1544 """ 

1545 Master view for the "receiving" phase of 

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

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

1548 

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

1550 status codes are shown: 

1551 

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

1553 

1554 Notable URLs provided by this class: 

1555 

1556 * ``/receiving/`` 

1557 * ``/receiving/XXX`` 

1558 """ 

1559 model_title = "Order Item (Receiving)" 

1560 model_title_plural = "Order Items (Receiving)" 

1561 route_prefix = 'order_items_receiving' 

1562 url_prefix = '/receiving' 

1563 

1564 def get_query(self, session=None): 

1565 """ """ 

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

1567 model = self.app.model 

1568 enum = self.app.enum 

1569 return query.filter(model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_PLACED) 

1570 

1571 def configure_grid(self, g): 

1572 """ """ 

1573 super().configure_grid(g) 

1574 

1575 # checkable 

1576 if self.has_any_perm('process_receiving', 'process_reorder'): 

1577 g.checkable = True 

1578 

1579 # tool button: Received 

1580 if self.has_perm('process_receiving'): 

1581 button = self.make_button("Received", primary=True, 

1582 icon_left='arrow-circle-right', 

1583 **{'@click': "$emit('process-receiving', checkedRows)", 

1584 ':disabled': '!checkedRows.length'}) 

1585 g.add_tool(button, key='process_receiving') 

1586 

1587 # tool button: Re-Order 

1588 if self.has_perm('process_reorder'): 

1589 button = self.make_button("Re-Order", 

1590 icon_left='redo', 

1591 **{'@click': "$emit('process-reorder', checkedRows)", 

1592 ':disabled': '!checkedRows.length'}) 

1593 g.add_tool(button, key='process_reorder') 

1594 

1595 def process_receiving(self): 

1596 """ 

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

1598 

1599 This requires a POST request with data: 

1600 

1601 :param item_uuids: Comma-delimited list of 

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

1603 

1604 :param vendor_name: Optional name of vendor. 

1605 

1606 :param invoice_number: Optional invoice number. 

1607 

1608 :param po_number: Optional PO number. 

1609 

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

1611 

1612 This invokes 

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

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

1615 back to the index page. 

1616 """ 

1617 items = self.get_order_items(self.request.POST.get('item_uuids', '')) 

1618 vendor_name = self.request.POST.get('vendor_name', '').strip() or None 

1619 invoice_number = self.request.POST.get('invoice_number', '').strip() or None 

1620 po_number = self.request.POST.get('po_number', '').strip() or None 

1621 note = self.request.POST.get('note', '').strip() or None 

1622 

1623 self.order_handler.process_receiving(items, self.request.user, 

1624 vendor_name=vendor_name, 

1625 invoice_number=invoice_number, 

1626 po_number=po_number, 

1627 note=note) 

1628 

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

1630 return self.redirect(self.get_index_url()) 

1631 

1632 def process_reorder(self): 

1633 """ 

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

1635 

1636 This requires a POST request with data: 

1637 

1638 :param item_uuids: Comma-delimited list of 

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

1640 

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

1642 

1643 This invokes 

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

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

1646 to the index page. 

1647 """ 

1648 items = self.get_order_items(self.request.POST.get('item_uuids', '')) 

1649 note = self.request.POST.get('note', '').strip() or None 

1650 

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

1652 

1653 self.request.session.flash(f"{len(items)} Order Items were marked as ready for placement") 

1654 return self.redirect(self.get_index_url()) 

1655 

1656 @classmethod 

1657 def defaults(cls, config): 

1658 cls._order_item_defaults(config) 

1659 cls._receiving_defaults(config) 

1660 cls._defaults(config) 

1661 

1662 @classmethod 

1663 def _receiving_defaults(cls, config): 

1664 route_prefix = cls.get_route_prefix() 

1665 permission_prefix = cls.get_permission_prefix() 

1666 url_prefix = cls.get_url_prefix() 

1667 model_title_plural = cls.get_model_title_plural() 

1668 

1669 # process receiving 

1670 config.add_wutta_permission(permission_prefix, 

1671 f'{permission_prefix}.process_receiving', 

1672 f"Process receiving for {model_title_plural}") 

1673 config.add_route(f'{route_prefix}.process_receiving', 

1674 f'{url_prefix}/process-receiving', 

1675 request_method='POST') 

1676 config.add_view(cls, attr='process_receiving', 

1677 route_name=f'{route_prefix}.process_receiving', 

1678 permission=f'{permission_prefix}.process_receiving') 

1679 

1680 # process reorder 

1681 config.add_wutta_permission(permission_prefix, 

1682 f'{permission_prefix}.process_reorder', 

1683 f"Process re-order for {model_title_plural}") 

1684 config.add_route(f'{route_prefix}.process_reorder', 

1685 f'{url_prefix}/process-reorder', 

1686 request_method='POST') 

1687 config.add_view(cls, attr='process_reorder', 

1688 route_name=f'{route_prefix}.process_reorder', 

1689 permission=f'{permission_prefix}.process_reorder') 

1690 

1691 

1692class ContactView(OrderItemView): 

1693 """ 

1694 Master view for the "contact" phase of 

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

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

1697 

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

1699 status codes are shown: 

1700 

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

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

1703 

1704 Notable URLs provided by this class: 

1705 

1706 * ``/contact/`` 

1707 * ``/contact/XXX`` 

1708 """ 

1709 model_title = "Order Item (Contact)" 

1710 model_title_plural = "Order Items (Contact)" 

1711 route_prefix = 'order_items_contact' 

1712 url_prefix = '/contact' 

1713 

1714 def get_query(self, session=None): 

1715 """ """ 

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

1717 model = self.app.model 

1718 enum = self.app.enum 

1719 return query.filter(model.OrderItem.status_code.in_(( 

1720 enum.ORDER_ITEM_STATUS_RECEIVED, 

1721 enum.ORDER_ITEM_STATUS_CONTACT_FAILED))) 

1722 

1723 def configure_grid(self, g): 

1724 """ """ 

1725 super().configure_grid(g) 

1726 

1727 # checkable 

1728 if self.has_perm('process_contact'): 

1729 g.checkable = True 

1730 

1731 # tool button: Contact Success 

1732 if self.has_perm('process_contact'): 

1733 button = self.make_button("Contact Success", primary=True, 

1734 icon_left='phone', 

1735 **{'@click': "$emit('process-contact-success', checkedRows)", 

1736 ':disabled': '!checkedRows.length'}) 

1737 g.add_tool(button, key='process_contact_success') 

1738 

1739 # tool button: Contact Failure 

1740 if self.has_perm('process_contact'): 

1741 button = self.make_button("Contact Failure", variant='is-warning', 

1742 icon_left='phone', 

1743 **{'@click': "$emit('process-contact-failure', checkedRows)", 

1744 ':disabled': '!checkedRows.length'}) 

1745 g.add_tool(button, key='process_contact_failure') 

1746 

1747 def process_contact_success(self): 

1748 """ 

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

1750 item(s). 

1751 

1752 This requires a POST request with data: 

1753 

1754 :param item_uuids: Comma-delimited list of 

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

1756 

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

1758 

1759 This invokes 

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

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

1762 user back to the index page. 

1763 """ 

1764 items = self.get_order_items(self.request.POST.get('item_uuids', '')) 

1765 note = self.request.POST.get('note', '').strip() or None 

1766 

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

1768 

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

1770 return self.redirect(self.get_index_url()) 

1771 

1772 def process_contact_failure(self): 

1773 """ 

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

1775 item(s). 

1776 

1777 This requires a POST request with data: 

1778 

1779 :param item_uuids: Comma-delimited list of 

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

1781 

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

1783 

1784 This invokes 

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

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

1787 user back to the index page. 

1788 """ 

1789 items = self.get_order_items(self.request.POST.get('item_uuids', '')) 

1790 note = self.request.POST.get('note', '').strip() or None 

1791 

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

1793 

1794 self.request.session.flash(f"{len(items)} Order Items were marked as contact failed") 

1795 return self.redirect(self.get_index_url()) 

1796 

1797 @classmethod 

1798 def defaults(cls, config): 

1799 cls._order_item_defaults(config) 

1800 cls._contact_defaults(config) 

1801 cls._defaults(config) 

1802 

1803 @classmethod 

1804 def _contact_defaults(cls, config): 

1805 route_prefix = cls.get_route_prefix() 

1806 permission_prefix = cls.get_permission_prefix() 

1807 url_prefix = cls.get_url_prefix() 

1808 model_title_plural = cls.get_model_title_plural() 

1809 

1810 # common perm for processing contact success + failure 

1811 config.add_wutta_permission(permission_prefix, 

1812 f'{permission_prefix}.process_contact', 

1813 f"Process contact success/failure for {model_title_plural}") 

1814 

1815 # process contact success 

1816 config.add_route(f'{route_prefix}.process_contact_success', 

1817 f'{url_prefix}/process-contact-success', 

1818 request_method='POST') 

1819 config.add_view(cls, attr='process_contact_success', 

1820 route_name=f'{route_prefix}.process_contact_success', 

1821 permission=f'{permission_prefix}.process_contact') 

1822 

1823 # process contact failure 

1824 config.add_route(f'{route_prefix}.process_contact_failure', 

1825 f'{url_prefix}/process-contact-failure', 

1826 request_method='POST') 

1827 config.add_view(cls, attr='process_contact_failure', 

1828 route_name=f'{route_prefix}.process_contact_failure', 

1829 permission=f'{permission_prefix}.process_contact') 

1830 

1831 

1832class DeliveryView(OrderItemView): 

1833 """ 

1834 Master view for the "delivery" phase of 

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

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

1837 

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

1839 status codes are shown: 

1840 

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

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

1843 

1844 Notable URLs provided by this class: 

1845 

1846 * ``/delivery/`` 

1847 * ``/delivery/XXX`` 

1848 """ 

1849 model_title = "Order Item (Delivery)" 

1850 model_title_plural = "Order Items (Delivery)" 

1851 route_prefix = 'order_items_delivery' 

1852 url_prefix = '/delivery' 

1853 

1854 def get_query(self, session=None): 

1855 """ """ 

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

1857 model = self.app.model 

1858 enum = self.app.enum 

1859 return query.filter(model.OrderItem.status_code.in_(( 

1860 enum.ORDER_ITEM_STATUS_RECEIVED, 

1861 enum.ORDER_ITEM_STATUS_CONTACTED))) 

1862 

1863 def configure_grid(self, g): 

1864 """ """ 

1865 super().configure_grid(g) 

1866 

1867 # checkable 

1868 if self.has_any_perm('process_delivery', 'process_restock'): 

1869 g.checkable = True 

1870 

1871 # tool button: Delivered 

1872 if self.has_perm('process_delivery'): 

1873 button = self.make_button("Delivered", primary=True, 

1874 icon_left='check', 

1875 **{'@click': "$emit('process-delivery', checkedRows)", 

1876 ':disabled': '!checkedRows.length'}) 

1877 g.add_tool(button, key='process_delivery') 

1878 

1879 # tool button: Restocked 

1880 if self.has_perm('process_restock'): 

1881 button = self.make_button("Restocked", 

1882 icon_left='redo', 

1883 **{'@click': "$emit('process-restock', checkedRows)", 

1884 ':disabled': '!checkedRows.length'}) 

1885 g.add_tool(button, key='process_restock') 

1886 

1887 def process_delivery(self): 

1888 """ 

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

1890 

1891 This requires a POST request with data: 

1892 

1893 :param item_uuids: Comma-delimited list of 

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

1895 

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

1897 

1898 This invokes 

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

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

1901 back to the index page. 

1902 """ 

1903 items = self.get_order_items(self.request.POST.get('item_uuids', '')) 

1904 note = self.request.POST.get('note', '').strip() or None 

1905 

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

1907 

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

1909 return self.redirect(self.get_index_url()) 

1910 

1911 def process_restock(self): 

1912 """ 

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

1914 

1915 This requires a POST request with data: 

1916 

1917 :param item_uuids: Comma-delimited list of 

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

1919 

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

1921 

1922 This invokes 

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

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

1925 to the index page. 

1926 """ 

1927 items = self.get_order_items(self.request.POST.get('item_uuids', '')) 

1928 note = self.request.POST.get('note', '').strip() or None 

1929 

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

1931 

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

1933 return self.redirect(self.get_index_url()) 

1934 

1935 @classmethod 

1936 def defaults(cls, config): 

1937 cls._order_item_defaults(config) 

1938 cls._delivery_defaults(config) 

1939 cls._defaults(config) 

1940 

1941 @classmethod 

1942 def _delivery_defaults(cls, config): 

1943 route_prefix = cls.get_route_prefix() 

1944 permission_prefix = cls.get_permission_prefix() 

1945 url_prefix = cls.get_url_prefix() 

1946 model_title_plural = cls.get_model_title_plural() 

1947 

1948 # process delivery 

1949 config.add_wutta_permission(permission_prefix, 

1950 f'{permission_prefix}.process_delivery', 

1951 f"Process delivery for {model_title_plural}") 

1952 config.add_route(f'{route_prefix}.process_delivery', 

1953 f'{url_prefix}/process-delivery', 

1954 request_method='POST') 

1955 config.add_view(cls, attr='process_delivery', 

1956 route_name=f'{route_prefix}.process_delivery', 

1957 permission=f'{permission_prefix}.process_delivery') 

1958 

1959 # process restock 

1960 config.add_wutta_permission(permission_prefix, 

1961 f'{permission_prefix}.process_restock', 

1962 f"Process restock for {model_title_plural}") 

1963 config.add_route(f'{route_prefix}.process_restock', 

1964 f'{url_prefix}/process-restock', 

1965 request_method='POST') 

1966 config.add_view(cls, attr='process_restock', 

1967 route_name=f'{route_prefix}.process_restock', 

1968 permission=f'{permission_prefix}.process_restock') 

1969 

1970 

1971def defaults(config, **kwargs): 

1972 base = globals() 

1973 

1974 OrderView = kwargs.get('OrderView', base['OrderView']) 

1975 OrderView.defaults(config) 

1976 

1977 OrderItemView = kwargs.get('OrderItemView', base['OrderItemView']) 

1978 OrderItemView.defaults(config) 

1979 

1980 PlacementView = kwargs.get('PlacementView', base['PlacementView']) 

1981 PlacementView.defaults(config) 

1982 

1983 ReceivingView = kwargs.get('ReceivingView', base['ReceivingView']) 

1984 ReceivingView.defaults(config) 

1985 

1986 ContactView = kwargs.get('ContactView', base['ContactView']) 

1987 ContactView.defaults(config) 

1988 

1989 DeliveryView = kwargs.get('DeliveryView', base['DeliveryView']) 

1990 DeliveryView.defaults(config) 

1991 

1992 

1993def includeme(config): 

1994 defaults(config)