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

700 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-23 16:52 -0600

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# Sideshow -- Case/Special Order Tracker 

5# Copyright © 2024 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 'total_price', 

139 'status_code', 

140 ] 

141 

142 PENDING_PRODUCT_ENTRY_FIELDS = [ 

143 'scancode', 

144 'brand_name', 

145 'description', 

146 'size', 

147 'department_name', 

148 'vendor_name', 

149 'vendor_item_code', 

150 'case_size', 

151 'unit_cost', 

152 'unit_price_reg', 

153 ] 

154 

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

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

157 self.order_handler = self.get_order_handler() 

158 

159 def get_order_handler(self): 

160 """ 

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

162 

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

164 :attr:`order_handler` instead. 

165 

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

167 """ 

168 if hasattr(self, 'order_handler'): 

169 return self.order_handler 

170 return OrderHandler(self.config) 

171 

172 def get_batch_handler(self): 

173 """ 

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

175 batches <new order batch>`. 

176 

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

178 :attr:`batch_handler` instead. 

179 

180 :returns: 

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

182 instance. 

183 """ 

184 if hasattr(self, 'batch_handler'): 

185 return self.batch_handler 

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

187 

188 def configure_grid(self, g): 

189 """ """ 

190 super().configure_grid(g) 

191 

192 # order_id 

193 g.set_link('order_id') 

194 

195 # customer_id 

196 g.set_link('customer_id') 

197 

198 # customer_name 

199 g.set_link('customer_name') 

200 

201 # total_price 

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

203 

204 def create(self): 

205 """ 

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

207 of sorts. 

208 

209 Under the hood a 

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

211 automatically created for the user when they first visit this 

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

213 

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

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

216 batch, which in turn creates a true 

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

218 redirected to the "view order" page. 

219 

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

221 based on user actions: 

222 

223 * :meth:`start_over()` 

224 * :meth:`cancel_order()` 

225 * :meth:`assign_customer()` 

226 * :meth:`unassign_customer()` 

227 * :meth:`set_pending_customer()` 

228 * :meth:`get_product_info()` 

229 * :meth:`add_item()` 

230 * :meth:`update_item()` 

231 * :meth:`delete_item()` 

232 * :meth:`submit_order()` 

233 """ 

234 enum = self.app.enum 

235 self.creating = True 

236 self.batch_handler = self.get_batch_handler() 

237 batch = self.get_current_batch() 

238 

239 context = self.get_context_customer(batch) 

240 

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

242 

243 # first we check for traditional form post 

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

245 post_actions = [ 

246 'start_over', 

247 'cancel_order', 

248 ] 

249 if action in post_actions: 

250 return getattr(self, action)(batch) 

251 

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

253 data = dict(self.request.json_body) 

254 action = data.pop('action') 

255 json_actions = [ 

256 'assign_customer', 

257 'unassign_customer', 

258 # 'update_phone_number', 

259 # 'update_email_address', 

260 'set_pending_customer', 

261 # 'get_customer_info', 

262 # # 'set_customer_data', 

263 'get_product_info', 

264 # 'get_past_items', 

265 'add_item', 

266 'update_item', 

267 'delete_item', 

268 'submit_order', 

269 ] 

270 if action in json_actions: 

271 try: 

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

273 except Exception as error: 

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

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

276 return self.json_response(result) 

277 

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

279 

280 context.update({ 

281 'batch': batch, 

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

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

284 for row in batch.rows], 

285 'default_uom_choices': self.get_default_uom_choices(), 

286 'default_uom': None, # TODO? 

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

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

289 'pending_product_required_fields': self.get_pending_product_required_fields(), 

290 }) 

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

292 

293 def get_current_batch(self): 

294 """ 

295 Returns the current batch for the current user. 

296 

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

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

299 created. 

300 

301 :returns: 

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

303 instance 

304 """ 

305 model = self.app.model 

306 session = self.Session() 

307 

308 user = self.request.user 

309 if not user: 

310 raise self.forbidden() 

311 

312 try: 

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

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

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

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

317 .one() 

318 

319 except orm.exc.NoResultFound: 

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

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

322 session.add(batch) 

323 session.flush() 

324 

325 return batch 

326 

327 def customer_autocomplete(self): 

328 """ 

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

330 

331 This invokes one of the following on the 

332 :attr:`batch_handler`: 

333 

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

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

336 

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

338 ``value`` and ``label`` keys. 

339 """ 

340 session = self.Session() 

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

342 if not term: 

343 return [] 

344 

345 handler = self.get_batch_handler() 

346 if handler.use_local_customers(): 

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

348 else: 

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

350 

351 def product_autocomplete(self): 

352 """ 

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

354 

355 This invokes one of the following on the 

356 :attr:`batch_handler`: 

357 

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

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

360 

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

362 ``value`` and ``label`` keys. 

363 """ 

364 session = self.Session() 

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

366 if not term: 

367 return [] 

368 

369 handler = self.get_batch_handler() 

370 if handler.use_local_products(): 

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

372 else: 

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

374 

375 def get_pending_product_required_fields(self): 

376 """ """ 

377 required = [] 

378 for field in self.PENDING_PRODUCT_ENTRY_FIELDS: 

379 require = self.config.get_bool( 

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

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

382 require = True 

383 if require: 

384 required.append(field) 

385 return required 

386 

387 def start_over(self, batch): 

388 """ 

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

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

391 new batch for them. 

392 

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

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

395 

396 * :meth:`cancel_order()` 

397 * :meth:`submit_order()` 

398 """ 

399 # drop current batch 

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

401 self.Session.flush() 

402 

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

404 route_prefix = self.get_route_prefix() 

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

406 return self.redirect(url) 

407 

408 def cancel_order(self, batch): 

409 """ 

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

411 back to "List Orders" page. 

412 

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

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

415 

416 * :meth:`start_over()` 

417 * :meth:`submit_order()` 

418 """ 

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

420 self.Session.flush() 

421 

422 # set flash msg just to be more obvious 

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

424 

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

426 url = self.get_index_url() 

427 return self.redirect(url) 

428 

429 def get_context_customer(self, batch): 

430 """ """ 

431 context = { 

432 'customer_is_known': True, 

433 'customer_id': None, 

434 'customer_name': batch.customer_name, 

435 'phone_number': batch.phone_number, 

436 'email_address': batch.email_address, 

437 } 

438 

439 # customer_id 

440 use_local = self.batch_handler.use_local_customers() 

441 if use_local: 

442 local = batch.local_customer 

443 if local: 

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

445 else: # use external 

446 context['customer_id'] = batch.customer_id 

447 

448 # pending customer 

449 pending = batch.pending_customer 

450 if pending: 

451 context.update({ 

452 'new_customer_first_name': pending.first_name, 

453 'new_customer_last_name': pending.last_name, 

454 'new_customer_full_name': pending.full_name, 

455 'new_customer_phone': pending.phone_number, 

456 'new_customer_email': pending.email_address, 

457 }) 

458 

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

460 if (pending 

461 and not batch.customer_id and not batch.local_customer 

462 and batch.customer_name): 

463 context['customer_is_known'] = False 

464 

465 return context 

466 

467 def assign_customer(self, batch, data): 

468 """ 

469 Assign the true customer account for a batch. 

470 

471 This calls 

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

473 for the heavy lifting. 

474 

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

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

477 

478 * :meth:`unassign_customer()` 

479 * :meth:`set_pending_customer()` 

480 """ 

481 customer_id = data.get('customer_id') 

482 if not customer_id: 

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

484 

485 self.batch_handler.set_customer(batch, customer_id) 

486 return self.get_context_customer(batch) 

487 

488 def unassign_customer(self, batch, data): 

489 """ 

490 Clear the customer info for a batch. 

491 

492 This calls 

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

494 for the heavy lifting. 

495 

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

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

498 

499 * :meth:`assign_customer()` 

500 * :meth:`set_pending_customer()` 

501 """ 

502 self.batch_handler.set_customer(batch, None) 

503 return self.get_context_customer(batch) 

504 

505 def set_pending_customer(self, batch, data): 

506 """ 

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

508 

509 This calls 

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

511 for the heavy lifting. 

512 

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

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

515 

516 * :meth:`assign_customer()` 

517 * :meth:`unassign_customer()` 

518 """ 

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

520 return self.get_context_customer(batch) 

521 

522 def get_product_info(self, batch, data): 

523 """ 

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

525 

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

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

528 

529 This should invoke a configured handler for the query 

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

531 built-in logic only, which queries the 

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

533 

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

535 :meth:`create()`. 

536 """ 

537 product_id = data.get('product_id') 

538 if not product_id: 

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

540 

541 session = self.Session() 

542 use_local = self.batch_handler.use_local_products() 

543 if use_local: 

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

545 else: 

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

547 

548 if 'error' in data: 

549 return data 

550 

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

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

553 

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

555 data['unit_price_quoted'] = data['unit_price_reg'] 

556 

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

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

559 

560 if 'case_price_quoted' not in data: 

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

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

563 

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

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

566 

567 decimal_fields = [ 

568 'case_size', 

569 'unit_price_reg', 

570 'unit_price_quoted', 

571 'case_price_quoted', 

572 ] 

573 

574 for field in decimal_fields: 

575 if field in list(data): 

576 value = data[field] 

577 if isinstance(value, decimal.Decimal): 

578 data[field] = float(value) 

579 

580 return data 

581 

582 def add_item(self, batch, data): 

583 """ 

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

585 

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

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

588 

589 * :meth:`update_item()` 

590 * :meth:`delete_item()` 

591 """ 

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

593 data['order_qty'], data['order_uom']) 

594 

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

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

597 

598 def update_item(self, batch, data): 

599 """ 

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

601 

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

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

604 

605 * :meth:`add_item()` 

606 * :meth:`delete_item()` 

607 """ 

608 model = self.app.model 

609 session = self.Session() 

610 

611 uuid = data.get('uuid') 

612 if not uuid: 

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

614 

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

616 if not row: 

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

618 

619 if row.batch is not batch: 

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

621 

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

623 data['order_qty'], data['order_uom']) 

624 

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

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

627 

628 def delete_item(self, batch, data): 

629 """ 

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

631 

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

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

634 

635 * :meth:`add_item()` 

636 * :meth:`update_item()` 

637 """ 

638 model = self.app.model 

639 session = self.app.get_session(batch) 

640 

641 uuid = data.get('uuid') 

642 if not uuid: 

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

644 

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

646 if not row: 

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

648 

649 if row.batch is not batch: 

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

651 

652 self.batch_handler.do_remove_row(row) 

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

654 

655 def submit_order(self, batch, data): 

656 """ 

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

658 executing the batch and creating the true order. 

659 

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

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

662 

663 * :meth:`start_over()` 

664 * :meth:`cancel_order()` 

665 """ 

666 user = self.request.user 

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

668 if reason: 

669 return {'error': reason} 

670 

671 try: 

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

673 except Exception as error: 

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

675 exc_info=True) 

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

677 

678 return { 

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

680 } 

681 

682 def normalize_batch(self, batch): 

683 """ """ 

684 return { 

685 'uuid': batch.uuid.hex, 

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

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

688 'status_code': batch.status_code, 

689 'status_text': batch.status_text, 

690 } 

691 

692 def get_default_uom_choices(self): 

693 """ """ 

694 enum = self.app.enum 

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

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

697 

698 def normalize_row(self, row): 

699 """ """ 

700 data = { 

701 'uuid': row.uuid.hex, 

702 'sequence': row.sequence, 

703 'product_id': None, 

704 'product_scancode': row.product_scancode, 

705 'product_brand': row.product_brand, 

706 'product_description': row.product_description, 

707 'product_size': row.product_size, 

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

709 row.product_description, 

710 row.product_size), 

711 'product_weighed': row.product_weighed, 

712 'department_display': row.department_name, 

713 'special_order': row.special_order, 

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

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

716 'order_uom': row.order_uom, 

717 'order_uom_choices': self.get_default_uom_choices(), 

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

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

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

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

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

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

724 'status_code': row.status_code, 

725 'status_text': row.status_text, 

726 } 

727 

728 use_local = self.batch_handler.use_local_products() 

729 

730 # product_id 

731 if use_local: 

732 if row.local_product: 

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

734 else: 

735 data['product_id'] = row.product_id 

736 

737 # vendor_name 

738 if use_local: 

739 if row.local_product: 

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

741 else: # use external 

742 pass # TODO 

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

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

745 

746 if row.unit_price_reg: 

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

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

749 

750 if row.unit_price_sale: 

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

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

753 if row.sale_ends: 

754 sale_ends = row.sale_ends 

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

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

757 

758 if row.pending_product: 

759 pending = row.pending_product 

760 data['pending_product'] = { 

761 'uuid': pending.uuid.hex, 

762 'scancode': pending.scancode, 

763 'brand_name': pending.brand_name, 

764 'description': pending.description, 

765 'size': pending.size, 

766 'department_id': pending.department_id, 

767 'department_name': pending.department_name, 

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

769 'vendor_name': pending.vendor_name, 

770 'vendor_item_code': pending.vendor_item_code, 

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

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

773 'notes': pending.notes, 

774 'special_order': pending.special_order, 

775 } 

776 

777 # display text for order qty/uom 

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

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

780 

781 return data 

782 

783 def get_instance_title(self, order): 

784 """ """ 

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

786 

787 def configure_form(self, f): 

788 """ """ 

789 super().configure_form(f) 

790 order = f.model_instance 

791 

792 # local_customer 

793 if order.customer_id and not order.local_customer: 

794 f.remove('local_customer') 

795 else: 

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

797 

798 # pending_customer 

799 if order.customer_id or order.local_customer: 

800 f.remove('pending_customer') 

801 else: 

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

803 

804 # total_price 

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

806 

807 # created_by 

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

809 f.set_readonly('created_by') 

810 

811 def get_xref_buttons(self, order): 

812 """ """ 

813 buttons = super().get_xref_buttons(order) 

814 model = self.app.model 

815 session = self.Session() 

816 

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

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

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

820 .first() 

821 if batch: 

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

823 buttons.append( 

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

825 

826 return buttons 

827 

828 def get_row_grid_data(self, order): 

829 """ """ 

830 model = self.app.model 

831 session = self.Session() 

832 return session.query(model.OrderItem)\ 

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

834 

835 def configure_row_grid(self, g): 

836 """ """ 

837 super().configure_row_grid(g) 

838 # enum = self.app.enum 

839 

840 # sequence 

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

842 g.set_link('sequence') 

843 

844 # product_scancode 

845 g.set_link('product_scancode') 

846 

847 # product_brand 

848 g.set_link('product_brand') 

849 

850 # product_description 

851 g.set_link('product_description') 

852 

853 # product_size 

854 g.set_link('product_size') 

855 

856 # TODO 

857 # order_uom 

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

859 

860 # total_price 

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

862 

863 # status_code 

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

865 

866 # TODO: upstream should set this automatically 

867 g.row_class = self.row_grid_row_class 

868 

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

870 """ """ 

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

872 if variant: 

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

874 

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

876 """ """ 

877 enum = self.app.enum 

878 return enum.ORDER_ITEM_STATUS[value] 

879 

880 def get_row_action_url_view(self, item, i): 

881 """ """ 

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

883 

884 def configure_get_simple_settings(self): 

885 """ """ 

886 settings = [ 

887 

888 # batches 

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

890 

891 # customers 

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

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

894 #'type': bool, 

895 'default': 'true'}, 

896 

897 # products 

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

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

900 #'type': bool, 

901 'default': 'true'}, 

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

903 'type': bool, 

904 'default': True}, 

905 ] 

906 

907 # required fields for new product entry 

908 for field in self.PENDING_PRODUCT_ENTRY_FIELDS: 

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

910 'type': bool} 

911 if field == 'description': 

912 setting['default'] = True 

913 settings.append(setting) 

914 

915 return settings 

916 

917 def configure_get_context(self, **kwargs): 

918 """ """ 

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

920 

921 context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS 

922 

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

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

925 context['batch_handlers'] = handlers 

926 

927 return context 

928 

929 @classmethod 

930 def defaults(cls, config): 

931 cls._order_defaults(config) 

932 cls._defaults(config) 

933 

934 @classmethod 

935 def _order_defaults(cls, config): 

936 route_prefix = cls.get_route_prefix() 

937 permission_prefix = cls.get_permission_prefix() 

938 url_prefix = cls.get_url_prefix() 

939 model_title = cls.get_model_title() 

940 model_title_plural = cls.get_model_title_plural() 

941 

942 # fix perm group 

943 config.add_wutta_permission_group(permission_prefix, 

944 model_title_plural, 

945 overwrite=False) 

946 

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

948 config.add_wutta_permission(permission_prefix, 

949 f'{permission_prefix}.create_unknown_product', 

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

951 

952 # customer autocomplete 

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

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

955 request_method='GET') 

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

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

958 renderer='json', 

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

960 

961 # product autocomplete 

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

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

964 request_method='GET') 

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

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

967 renderer='json', 

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

969 

970 

971class OrderItemView(MasterView): 

972 """ 

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

974 route prefix is ``order_items``. 

975 

976 Notable URLs provided by this class: 

977 

978 * ``/order-items/`` 

979 * ``/order-items/XXX`` 

980 

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

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

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

984 

985 * :class:`PlacementView` 

986 * :class:`ReceivingView` 

987 * :class:`ContactView` 

988 * :class:`DeliveryView` 

989 

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

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

992 

993 .. attribute:: order_handler 

994 

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

996 :meth:`get_order_handler()`. 

997 """ 

998 model_class = OrderItem 

999 model_title = "Order Item (All)" 

1000 model_title_plural = "Order Items (All)" 

1001 route_prefix = 'order_items' 

1002 url_prefix = '/order-items' 

1003 creatable = False 

1004 editable = False 

1005 deletable = False 

1006 

1007 labels = { 

1008 'order_id': "Order ID", 

1009 'product_id': "Product ID", 

1010 'product_scancode': "Scancode", 

1011 'product_brand': "Brand", 

1012 'product_description': "Description", 

1013 'product_size': "Size", 

1014 'product_weighed': "Sold by Weight", 

1015 'department_id': "Department ID", 

1016 'order_uom': "Order UOM", 

1017 'status_code': "Status", 

1018 } 

1019 

1020 grid_columns = [ 

1021 'order_id', 

1022 'customer_name', 

1023 # 'sequence', 

1024 'product_scancode', 

1025 'product_brand', 

1026 'product_description', 

1027 'product_size', 

1028 'department_name', 

1029 'special_order', 

1030 'order_qty', 

1031 'order_uom', 

1032 'total_price', 

1033 'status_code', 

1034 ] 

1035 

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

1037 

1038 form_fields = [ 

1039 'order', 

1040 # 'customer_name', 

1041 'sequence', 

1042 'product_id', 

1043 'local_product', 

1044 'pending_product', 

1045 'product_scancode', 

1046 'product_brand', 

1047 'product_description', 

1048 'product_size', 

1049 'product_weighed', 

1050 'department_id', 

1051 'department_name', 

1052 'special_order', 

1053 'case_size', 

1054 'unit_cost', 

1055 'unit_price_reg', 

1056 'unit_price_sale', 

1057 'sale_ends', 

1058 'unit_price_quoted', 

1059 'case_price_quoted', 

1060 'order_qty', 

1061 'order_uom', 

1062 'discount_percent', 

1063 'total_price', 

1064 'status_code', 

1065 'paid_amount', 

1066 'payment_transaction_number', 

1067 ] 

1068 

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

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

1071 self.order_handler = self.get_order_handler() 

1072 

1073 def get_order_handler(self): 

1074 """ 

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

1076 

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

1078 :attr:`order_handler` instead. 

1079 

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

1081 """ 

1082 if hasattr(self, 'order_handler'): 

1083 return self.order_handler 

1084 return OrderHandler(self.config) 

1085 

1086 def get_fallback_templates(self, template): 

1087 """ """ 

1088 templates = super().get_fallback_templates(template) 

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

1090 return templates 

1091 

1092 def get_query(self, session=None): 

1093 """ """ 

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

1095 model = self.app.model 

1096 return query.join(model.Order) 

1097 

1098 def configure_grid(self, g): 

1099 """ """ 

1100 super().configure_grid(g) 

1101 model = self.app.model 

1102 # enum = self.app.enum 

1103 

1104 # order_id 

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

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

1107 g.set_link('order_id') 

1108 

1109 # customer_name 

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

1111 

1112 # # sequence 

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

1114 

1115 # product_scancode 

1116 g.set_link('product_scancode') 

1117 

1118 # product_brand 

1119 g.set_link('product_brand') 

1120 

1121 # product_description 

1122 g.set_link('product_description') 

1123 

1124 # product_size 

1125 g.set_link('product_size') 

1126 

1127 # order_uom 

1128 # TODO 

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

1130 

1131 # total_price 

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

1133 

1134 # status_code 

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

1136 

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

1138 """ """ 

1139 return item.order.order_id 

1140 

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

1142 """ """ 

1143 enum = self.app.enum 

1144 return enum.ORDER_ITEM_STATUS[value] 

1145 

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

1147 """ """ 

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

1149 if variant: 

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

1151 

1152 def configure_form(self, f): 

1153 """ """ 

1154 super().configure_form(f) 

1155 enum = self.app.enum 

1156 item = f.model_instance 

1157 

1158 # order 

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

1160 

1161 # local_product 

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

1163 

1164 # pending_product 

1165 if item.product_id or item.local_product: 

1166 f.remove('pending_product') 

1167 else: 

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

1169 

1170 # order_qty 

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

1172 

1173 # order_uom 

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

1175 

1176 # case_size 

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

1178 

1179 # unit_cost 

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

1181 

1182 # unit_price_reg 

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

1184 

1185 # unit_price_quoted 

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

1187 

1188 # case_price_quoted 

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

1190 

1191 # total_price 

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

1193 

1194 # status 

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

1196 

1197 # paid_amount 

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

1199 

1200 def get_template_context(self, context): 

1201 """ """ 

1202 if self.viewing: 

1203 model = self.app.model 

1204 enum = self.app.enum 

1205 route_prefix = self.get_route_prefix() 

1206 item = context['instance'] 

1207 form = context['form'] 

1208 

1209 context['item'] = item 

1210 context['order'] = item.order 

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

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

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

1214 

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

1216 model_class=model.OrderItemEvent, 

1217 data=item.events, 

1218 columns=[ 

1219 'occurred', 

1220 'actor', 

1221 'type_code', 

1222 'note', 

1223 ], 

1224 labels={ 

1225 'occurred': "Date/Time", 

1226 'actor': "User", 

1227 'type_code': "Event Type", 

1228 }) 

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

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

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

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

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

1234 form.add_grid_vue_context(grid) 

1235 context['events_grid'] = grid 

1236 

1237 return context 

1238 

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

1240 """ """ 

1241 enum = self.app.enum 

1242 if event.type_code == enum.ORDER_ITEM_EVENT_NOTE_ADDED: 

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

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

1245 c=[value]) 

1246 return value 

1247 

1248 def get_xref_buttons(self, item): 

1249 """ """ 

1250 buttons = super().get_xref_buttons(item) 

1251 

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

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

1254 buttons.append( 

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

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

1257 

1258 return buttons 

1259 

1260 def add_note(self): 

1261 """ 

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

1263 will redirect back to the item view. 

1264 """ 

1265 enum = self.app.enum 

1266 item = self.get_instance() 

1267 

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

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

1270 

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

1272 

1273 def change_status(self): 

1274 """ 

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

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

1277 """ 

1278 model = self.app.model 

1279 enum = self.app.enum 

1280 main_item = self.get_instance() 

1281 session = self.Session() 

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

1283 

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

1285 

1286 # validate new status 

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

1288 if new_status_code not in enum.ORDER_ITEM_STATUS: 

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

1290 return redirect 

1291 new_status_text = enum.ORDER_ITEM_STATUS[new_status_code] 

1292 

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

1294 items = [main_item] 

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

1296 # if uuids: 

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

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

1299 # if item: 

1300 # items.append(item) 

1301 

1302 # update item(s) 

1303 for item in items: 

1304 if item.status_code != new_status_code: 

1305 

1306 # event: change status 

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

1308 enum.ORDER_ITEM_STATUS[item.status_code], 

1309 new_status_text) 

1310 item.add_event(enum.ORDER_ITEM_EVENT_STATUS_CHANGE, 

1311 self.request.user, note=note) 

1312 

1313 # event: add note 

1314 if extra_note: 

1315 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, 

1316 self.request.user, note=extra_note) 

1317 

1318 # new status 

1319 item.status_code = new_status_code 

1320 

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

1322 return redirect 

1323 

1324 def get_order_items(self, uuids): 

1325 """ 

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

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

1328 workflow action methods. 

1329 

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

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

1332 

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

1334 

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

1336 records. 

1337 """ 

1338 model = self.app.model 

1339 session = self.Session() 

1340 

1341 if uuids is None: 

1342 uuids = [] 

1343 elif isinstance(uuids, str): 

1344 uuids = uuids.split(',') 

1345 

1346 items = [] 

1347 for uuid in uuids: 

1348 if isinstance(uuid, str): 

1349 uuid = uuid.strip() 

1350 if uuid: 

1351 try: 

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

1353 except sa.exc.StatementError: 

1354 pass # nb. invalid UUID 

1355 else: 

1356 if item: 

1357 items.append(item) 

1358 

1359 if not items: 

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

1361 raise self.redirect(self.get_index_url()) 

1362 

1363 return items 

1364 

1365 @classmethod 

1366 def defaults(cls, config): 

1367 """ """ 

1368 cls._order_item_defaults(config) 

1369 cls._defaults(config) 

1370 

1371 @classmethod 

1372 def _order_item_defaults(cls, config): 

1373 """ """ 

1374 route_prefix = cls.get_route_prefix() 

1375 permission_prefix = cls.get_permission_prefix() 

1376 instance_url_prefix = cls.get_instance_url_prefix() 

1377 model_title = cls.get_model_title() 

1378 model_title_plural = cls.get_model_title_plural() 

1379 

1380 # fix perm group 

1381 config.add_wutta_permission_group(permission_prefix, 

1382 model_title_plural, 

1383 overwrite=False) 

1384 

1385 # add note 

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

1387 f'{instance_url_prefix}/add_note', 

1388 request_method='POST') 

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

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

1391 renderer='json', 

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

1393 config.add_wutta_permission(permission_prefix, 

1394 f'{permission_prefix}.add_note', 

1395 f"Add note for {model_title}") 

1396 

1397 # change status 

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

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

1400 request_method='POST') 

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

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

1403 renderer='json', 

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

1405 config.add_wutta_permission(permission_prefix, 

1406 f'{permission_prefix}.change_status', 

1407 f"Change status for {model_title}") 

1408 

1409 

1410class PlacementView(OrderItemView): 

1411 """ 

1412 Master view for the "placement" phase of 

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

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

1415 

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

1417 status codes are shown: 

1418 

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

1420 

1421 Notable URLs provided by this class: 

1422 

1423 * ``/placement/`` 

1424 * ``/placement/XXX`` 

1425 """ 

1426 model_title = "Order Item (Placement)" 

1427 model_title_plural = "Order Items (Placement)" 

1428 route_prefix = 'order_items_placement' 

1429 url_prefix = '/placement' 

1430 

1431 def get_query(self, session=None): 

1432 """ """ 

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

1434 model = self.app.model 

1435 enum = self.app.enum 

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

1437 

1438 def configure_grid(self, g): 

1439 """ """ 

1440 super().configure_grid(g) 

1441 

1442 # checkable 

1443 if self.has_perm('process_placement'): 

1444 g.checkable = True 

1445 

1446 # tool button: Order Placed 

1447 if self.has_perm('process_placement'): 

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

1449 icon_left='arrow-circle-right', 

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

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

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

1453 

1454 def process_placement(self): 

1455 """ 

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

1457 

1458 This requires a POST request with data: 

1459 

1460 :param item_uuids: Comma-delimited list of 

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

1462 

1463 :param vendor_name: Optional name of vendor. 

1464 

1465 :param po_number: Optional PO number. 

1466 

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

1468 

1469 This invokes 

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

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

1472 back to the index page. 

1473 """ 

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

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

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

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

1478 

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

1480 vendor_name=vendor_name, 

1481 po_number=po_number, 

1482 note=note) 

1483 

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

1485 return self.redirect(self.get_index_url()) 

1486 

1487 @classmethod 

1488 def defaults(cls, config): 

1489 cls._order_item_defaults(config) 

1490 cls._placement_defaults(config) 

1491 cls._defaults(config) 

1492 

1493 @classmethod 

1494 def _placement_defaults(cls, config): 

1495 route_prefix = cls.get_route_prefix() 

1496 permission_prefix = cls.get_permission_prefix() 

1497 url_prefix = cls.get_url_prefix() 

1498 model_title_plural = cls.get_model_title_plural() 

1499 

1500 # process placement 

1501 config.add_wutta_permission(permission_prefix, 

1502 f'{permission_prefix}.process_placement', 

1503 f"Process placement for {model_title_plural}") 

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

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

1506 request_method='POST') 

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

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

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

1510 

1511 

1512class ReceivingView(OrderItemView): 

1513 """ 

1514 Master view for the "receiving" phase of 

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

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

1517 

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

1519 status codes are shown: 

1520 

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

1522 

1523 Notable URLs provided by this class: 

1524 

1525 * ``/receiving/`` 

1526 * ``/receiving/XXX`` 

1527 """ 

1528 model_title = "Order Item (Receiving)" 

1529 model_title_plural = "Order Items (Receiving)" 

1530 route_prefix = 'order_items_receiving' 

1531 url_prefix = '/receiving' 

1532 

1533 def get_query(self, session=None): 

1534 """ """ 

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

1536 model = self.app.model 

1537 enum = self.app.enum 

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

1539 

1540 def configure_grid(self, g): 

1541 """ """ 

1542 super().configure_grid(g) 

1543 

1544 # checkable 

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

1546 g.checkable = True 

1547 

1548 # tool button: Received 

1549 if self.has_perm('process_receiving'): 

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

1551 icon_left='arrow-circle-right', 

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

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

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

1555 

1556 # tool button: Re-Order 

1557 if self.has_perm('process_reorder'): 

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

1559 icon_left='redo', 

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

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

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

1563 

1564 def process_receiving(self): 

1565 """ 

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

1567 

1568 This requires a POST request with data: 

1569 

1570 :param item_uuids: Comma-delimited list of 

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

1572 

1573 :param vendor_name: Optional name of vendor. 

1574 

1575 :param invoice_number: Optional invoice number. 

1576 

1577 :param po_number: Optional PO number. 

1578 

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

1580 

1581 This invokes 

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

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

1584 back to the index page. 

1585 """ 

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

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

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

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

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

1591 

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

1593 vendor_name=vendor_name, 

1594 invoice_number=invoice_number, 

1595 po_number=po_number, 

1596 note=note) 

1597 

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

1599 return self.redirect(self.get_index_url()) 

1600 

1601 def process_reorder(self): 

1602 """ 

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

1604 

1605 This requires a POST request with data: 

1606 

1607 :param item_uuids: Comma-delimited list of 

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

1609 

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

1611 

1612 This invokes 

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

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

1615 to the index page. 

1616 """ 

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

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

1619 

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

1621 

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

1623 return self.redirect(self.get_index_url()) 

1624 

1625 @classmethod 

1626 def defaults(cls, config): 

1627 cls._order_item_defaults(config) 

1628 cls._receiving_defaults(config) 

1629 cls._defaults(config) 

1630 

1631 @classmethod 

1632 def _receiving_defaults(cls, config): 

1633 route_prefix = cls.get_route_prefix() 

1634 permission_prefix = cls.get_permission_prefix() 

1635 url_prefix = cls.get_url_prefix() 

1636 model_title_plural = cls.get_model_title_plural() 

1637 

1638 # process receiving 

1639 config.add_wutta_permission(permission_prefix, 

1640 f'{permission_prefix}.process_receiving', 

1641 f"Process receiving for {model_title_plural}") 

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

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

1644 request_method='POST') 

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

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

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

1648 

1649 # process reorder 

1650 config.add_wutta_permission(permission_prefix, 

1651 f'{permission_prefix}.process_reorder', 

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

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

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

1655 request_method='POST') 

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

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

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

1659 

1660 

1661class ContactView(OrderItemView): 

1662 """ 

1663 Master view for the "contact" phase of 

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

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

1666 

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

1668 status codes are shown: 

1669 

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

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

1672 

1673 Notable URLs provided by this class: 

1674 

1675 * ``/contact/`` 

1676 * ``/contact/XXX`` 

1677 """ 

1678 model_title = "Order Item (Contact)" 

1679 model_title_plural = "Order Items (Contact)" 

1680 route_prefix = 'order_items_contact' 

1681 url_prefix = '/contact' 

1682 

1683 def get_query(self, session=None): 

1684 """ """ 

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

1686 model = self.app.model 

1687 enum = self.app.enum 

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

1689 enum.ORDER_ITEM_STATUS_RECEIVED, 

1690 enum.ORDER_ITEM_STATUS_CONTACT_FAILED))) 

1691 

1692 def configure_grid(self, g): 

1693 """ """ 

1694 super().configure_grid(g) 

1695 

1696 # checkable 

1697 if self.has_perm('process_contact'): 

1698 g.checkable = True 

1699 

1700 # tool button: Contact Success 

1701 if self.has_perm('process_contact'): 

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

1703 icon_left='phone', 

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

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

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

1707 

1708 # tool button: Contact Failure 

1709 if self.has_perm('process_contact'): 

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

1711 icon_left='phone', 

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

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

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

1715 

1716 def process_contact_success(self): 

1717 """ 

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

1719 item(s). 

1720 

1721 This requires a POST request with data: 

1722 

1723 :param item_uuids: Comma-delimited list of 

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

1725 

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

1727 

1728 This invokes 

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

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

1731 user back to the index page. 

1732 """ 

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

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

1735 

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

1737 

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

1739 return self.redirect(self.get_index_url()) 

1740 

1741 def process_contact_failure(self): 

1742 """ 

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

1744 item(s). 

1745 

1746 This requires a POST request with data: 

1747 

1748 :param item_uuids: Comma-delimited list of 

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

1750 

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

1752 

1753 This invokes 

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

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

1756 user back to the index page. 

1757 """ 

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

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

1760 

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

1762 

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

1764 return self.redirect(self.get_index_url()) 

1765 

1766 @classmethod 

1767 def defaults(cls, config): 

1768 cls._order_item_defaults(config) 

1769 cls._contact_defaults(config) 

1770 cls._defaults(config) 

1771 

1772 @classmethod 

1773 def _contact_defaults(cls, config): 

1774 route_prefix = cls.get_route_prefix() 

1775 permission_prefix = cls.get_permission_prefix() 

1776 url_prefix = cls.get_url_prefix() 

1777 model_title_plural = cls.get_model_title_plural() 

1778 

1779 # common perm for processing contact success + failure 

1780 config.add_wutta_permission(permission_prefix, 

1781 f'{permission_prefix}.process_contact', 

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

1783 

1784 # process contact success 

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

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

1787 request_method='POST') 

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

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

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

1791 

1792 # process contact failure 

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

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

1795 request_method='POST') 

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

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

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

1799 

1800 

1801class DeliveryView(OrderItemView): 

1802 """ 

1803 Master view for the "delivery" phase of 

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

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

1806 

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

1808 status codes are shown: 

1809 

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

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

1812 

1813 Notable URLs provided by this class: 

1814 

1815 * ``/delivery/`` 

1816 * ``/delivery/XXX`` 

1817 """ 

1818 model_title = "Order Item (Delivery)" 

1819 model_title_plural = "Order Items (Delivery)" 

1820 route_prefix = 'order_items_delivery' 

1821 url_prefix = '/delivery' 

1822 

1823 def get_query(self, session=None): 

1824 """ """ 

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

1826 model = self.app.model 

1827 enum = self.app.enum 

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

1829 enum.ORDER_ITEM_STATUS_RECEIVED, 

1830 enum.ORDER_ITEM_STATUS_CONTACTED))) 

1831 

1832 def configure_grid(self, g): 

1833 """ """ 

1834 super().configure_grid(g) 

1835 

1836 # checkable 

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

1838 g.checkable = True 

1839 

1840 # tool button: Delivered 

1841 if self.has_perm('process_delivery'): 

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

1843 icon_left='check', 

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

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

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

1847 

1848 # tool button: Restocked 

1849 if self.has_perm('process_restock'): 

1850 button = self.make_button("Restocked", 

1851 icon_left='redo', 

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

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

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

1855 

1856 def process_delivery(self): 

1857 """ 

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

1859 

1860 This requires a POST request with data: 

1861 

1862 :param item_uuids: Comma-delimited list of 

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

1864 

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

1866 

1867 This invokes 

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

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

1870 back to the index page. 

1871 """ 

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

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

1874 

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

1876 

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

1878 return self.redirect(self.get_index_url()) 

1879 

1880 def process_restock(self): 

1881 """ 

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

1883 

1884 This requires a POST request with data: 

1885 

1886 :param item_uuids: Comma-delimited list of 

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

1888 

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

1890 

1891 This invokes 

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

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

1894 to the index page. 

1895 """ 

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

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

1898 

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

1900 

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

1902 return self.redirect(self.get_index_url()) 

1903 

1904 @classmethod 

1905 def defaults(cls, config): 

1906 cls._order_item_defaults(config) 

1907 cls._delivery_defaults(config) 

1908 cls._defaults(config) 

1909 

1910 @classmethod 

1911 def _delivery_defaults(cls, config): 

1912 route_prefix = cls.get_route_prefix() 

1913 permission_prefix = cls.get_permission_prefix() 

1914 url_prefix = cls.get_url_prefix() 

1915 model_title_plural = cls.get_model_title_plural() 

1916 

1917 # process delivery 

1918 config.add_wutta_permission(permission_prefix, 

1919 f'{permission_prefix}.process_delivery', 

1920 f"Process delivery for {model_title_plural}") 

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

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

1923 request_method='POST') 

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

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

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

1927 

1928 # process restock 

1929 config.add_wutta_permission(permission_prefix, 

1930 f'{permission_prefix}.process_restock', 

1931 f"Process restock for {model_title_plural}") 

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

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

1934 request_method='POST') 

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

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

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

1938 

1939 

1940def defaults(config, **kwargs): 

1941 base = globals() 

1942 

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

1944 OrderView.defaults(config) 

1945 

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

1947 OrderItemView.defaults(config) 

1948 

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

1950 PlacementView.defaults(config) 

1951 

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

1953 ReceivingView.defaults(config) 

1954 

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

1956 ContactView.defaults(config) 

1957 

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

1959 DeliveryView.defaults(config) 

1960 

1961 

1962def includeme(config): 

1963 defaults(config)