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

398 statements  

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

31from sqlalchemy import orm 

32 

33from wuttaweb.views import MasterView 

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

35 

36from sideshow.db.model import Order, OrderItem 

37from sideshow.batch.neworder import NewOrderBatchHandler 

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

39 LocalCustomerRef, LocalProductRef, 

40 PendingCustomerRef, PendingProductRef) 

41 

42 

43log = logging.getLogger(__name__) 

44 

45 

46class OrderView(MasterView): 

47 """ 

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

49 prefix is ``orders``. 

50 

51 Notable URLs provided by this class: 

52 

53 * ``/orders/`` 

54 * ``/orders/new`` 

55 * ``/orders/XXX`` 

56 * ``/orders/XXX/delete`` 

57 

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

59 various other workflow actions to modify the order. 

60 

61 .. attribute:: batch_handler 

62 

63 Reference to the new order batch handler, as returned by 

64 :meth:`get_batch_handler()`. This gets set in the constructor. 

65 """ 

66 model_class = Order 

67 editable = False 

68 configurable = True 

69 

70 labels = { 

71 'order_id': "Order ID", 

72 'store_id': "Store ID", 

73 'customer_id': "Customer ID", 

74 } 

75 

76 grid_columns = [ 

77 'order_id', 

78 'store_id', 

79 'customer_id', 

80 'customer_name', 

81 'total_price', 

82 'created', 

83 'created_by', 

84 ] 

85 

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

87 

88 form_fields = [ 

89 'order_id', 

90 'store_id', 

91 'customer_id', 

92 'local_customer', 

93 'pending_customer', 

94 'customer_name', 

95 'phone_number', 

96 'email_address', 

97 'total_price', 

98 'created', 

99 'created_by', 

100 ] 

101 

102 has_rows = True 

103 row_model_class = OrderItem 

104 rows_title = "Order Items" 

105 rows_sort_defaults = 'sequence' 

106 rows_viewable = True 

107 

108 row_labels = { 

109 'product_scancode': "Scancode", 

110 'product_brand': "Brand", 

111 'product_description': "Description", 

112 'product_size': "Size", 

113 'department_name': "Department", 

114 'order_uom': "Order UOM", 

115 'status_code': "Status", 

116 } 

117 

118 row_grid_columns = [ 

119 'sequence', 

120 'product_scancode', 

121 'product_brand', 

122 'product_description', 

123 'product_size', 

124 'department_name', 

125 'special_order', 

126 'order_qty', 

127 'order_uom', 

128 'total_price', 

129 'status_code', 

130 ] 

131 

132 PENDING_PRODUCT_ENTRY_FIELDS = [ 

133 'scancode', 

134 'brand_name', 

135 'description', 

136 'size', 

137 'department_name', 

138 'vendor_name', 

139 'vendor_item_code', 

140 'case_size', 

141 'unit_cost', 

142 'unit_price_reg', 

143 ] 

144 

145 def configure_grid(self, g): 

146 """ """ 

147 super().configure_grid(g) 

148 

149 # order_id 

150 g.set_link('order_id') 

151 

152 # customer_id 

153 g.set_link('customer_id') 

154 

155 # customer_name 

156 g.set_link('customer_name') 

157 

158 # total_price 

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

160 

161 def get_batch_handler(self): 

162 """ 

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

164 batches <new order batch>`. 

165 

166 You normally would not need to call this; just use 

167 :attr:`batch_handler` instead. 

168 

169 :returns: 

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

171 instance. 

172 """ 

173 if hasattr(self, 'batch_handler'): 

174 return self.batch_handler 

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

176 

177 def create(self): 

178 """ 

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

180 of sorts. 

181 

182 Under the hood a 

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

184 automatically created for the user when they first visit this 

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

186 

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

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

189 batch, which in turn creates a true 

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

191 redirected to the "view order" page. 

192 

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

194 based on user actions: 

195 

196 * :meth:`start_over()` 

197 * :meth:`cancel_order()` 

198 * :meth:`assign_customer()` 

199 * :meth:`unassign_customer()` 

200 * :meth:`set_pending_customer()` 

201 * :meth:`get_product_info()` 

202 * :meth:`add_item()` 

203 * :meth:`update_item()` 

204 * :meth:`delete_item()` 

205 * :meth:`submit_order()` 

206 """ 

207 enum = self.app.enum 

208 self.creating = True 

209 self.batch_handler = self.get_batch_handler() 

210 batch = self.get_current_batch() 

211 

212 context = self.get_context_customer(batch) 

213 

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

215 

216 # first we check for traditional form post 

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

218 post_actions = [ 

219 'start_over', 

220 'cancel_order', 

221 ] 

222 if action in post_actions: 

223 return getattr(self, action)(batch) 

224 

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

226 data = dict(self.request.json_body) 

227 action = data.pop('action') 

228 json_actions = [ 

229 'assign_customer', 

230 'unassign_customer', 

231 # 'update_phone_number', 

232 # 'update_email_address', 

233 'set_pending_customer', 

234 # 'get_customer_info', 

235 # # 'set_customer_data', 

236 'get_product_info', 

237 # 'get_past_items', 

238 'add_item', 

239 'update_item', 

240 'delete_item', 

241 'submit_order', 

242 ] 

243 if action in json_actions: 

244 try: 

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

246 except Exception as error: 

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

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

249 return self.json_response(result) 

250 

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

252 

253 context.update({ 

254 'batch': batch, 

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

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

257 for row in batch.rows], 

258 'default_uom_choices': self.get_default_uom_choices(), 

259 'default_uom': None, # TODO? 

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

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

262 'pending_product_required_fields': self.get_pending_product_required_fields(), 

263 }) 

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

265 

266 def get_current_batch(self): 

267 """ 

268 Returns the current batch for the current user. 

269 

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

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

272 created. 

273 

274 :returns: 

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

276 instance 

277 """ 

278 model = self.app.model 

279 session = self.Session() 

280 

281 user = self.request.user 

282 if not user: 

283 raise self.forbidden() 

284 

285 try: 

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

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

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

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

290 .one() 

291 

292 except orm.exc.NoResultFound: 

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

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

295 session.add(batch) 

296 session.flush() 

297 

298 return batch 

299 

300 def customer_autocomplete(self): 

301 """ 

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

303 

304 This invokes one of the following on the 

305 :attr:`batch_handler`: 

306 

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

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

309 

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

311 ``value`` and ``label`` keys. 

312 """ 

313 session = self.Session() 

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

315 if not term: 

316 return [] 

317 

318 handler = self.get_batch_handler() 

319 if handler.use_local_customers(): 

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

321 else: 

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

323 

324 def product_autocomplete(self): 

325 """ 

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

327 

328 This invokes one of the following on the 

329 :attr:`batch_handler`: 

330 

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

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

333 

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

335 ``value`` and ``label`` keys. 

336 """ 

337 session = self.Session() 

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

339 if not term: 

340 return [] 

341 

342 handler = self.get_batch_handler() 

343 if handler.use_local_products(): 

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

345 else: 

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

347 

348 def get_pending_product_required_fields(self): 

349 """ """ 

350 required = [] 

351 for field in self.PENDING_PRODUCT_ENTRY_FIELDS: 

352 require = self.config.get_bool( 

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

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

355 require = True 

356 if require: 

357 required.append(field) 

358 return required 

359 

360 def start_over(self, batch): 

361 """ 

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

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

364 new batch for them. 

365 

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

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

368 

369 * :meth:`cancel_order()` 

370 * :meth:`submit_order()` 

371 """ 

372 # drop current batch 

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

374 self.Session.flush() 

375 

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

377 route_prefix = self.get_route_prefix() 

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

379 return self.redirect(url) 

380 

381 def cancel_order(self, batch): 

382 """ 

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

384 back to "List Orders" page. 

385 

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

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

388 

389 * :meth:`start_over()` 

390 * :meth:`submit_order()` 

391 """ 

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

393 self.Session.flush() 

394 

395 # set flash msg just to be more obvious 

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

397 

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

399 url = self.get_index_url() 

400 return self.redirect(url) 

401 

402 def get_context_customer(self, batch): 

403 """ """ 

404 context = { 

405 'customer_is_known': True, 

406 'customer_id': None, 

407 'customer_name': batch.customer_name, 

408 'phone_number': batch.phone_number, 

409 'email_address': batch.email_address, 

410 } 

411 

412 # customer_id 

413 use_local = self.batch_handler.use_local_customers() 

414 if use_local: 

415 local = batch.local_customer 

416 if local: 

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

418 else: # use external 

419 context['customer_id'] = batch.customer_id 

420 

421 # pending customer 

422 pending = batch.pending_customer 

423 if pending: 

424 context.update({ 

425 'new_customer_first_name': pending.first_name, 

426 'new_customer_last_name': pending.last_name, 

427 'new_customer_full_name': pending.full_name, 

428 'new_customer_phone': pending.phone_number, 

429 'new_customer_email': pending.email_address, 

430 }) 

431 

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

433 if (pending 

434 and not batch.customer_id and not batch.local_customer 

435 and batch.customer_name): 

436 context['customer_is_known'] = False 

437 

438 return context 

439 

440 def assign_customer(self, batch, data): 

441 """ 

442 Assign the true customer account for a batch. 

443 

444 This calls 

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

446 for the heavy lifting. 

447 

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

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

450 

451 * :meth:`unassign_customer()` 

452 * :meth:`set_pending_customer()` 

453 """ 

454 customer_id = data.get('customer_id') 

455 if not customer_id: 

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

457 

458 self.batch_handler.set_customer(batch, customer_id) 

459 return self.get_context_customer(batch) 

460 

461 def unassign_customer(self, batch, data): 

462 """ 

463 Clear the customer info for a batch. 

464 

465 This calls 

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

467 for the heavy lifting. 

468 

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

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

471 

472 * :meth:`assign_customer()` 

473 * :meth:`set_pending_customer()` 

474 """ 

475 self.batch_handler.set_customer(batch, None) 

476 return self.get_context_customer(batch) 

477 

478 def set_pending_customer(self, batch, data): 

479 """ 

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

481 

482 This calls 

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

484 for the heavy lifting. 

485 

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

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

488 

489 * :meth:`assign_customer()` 

490 * :meth:`unassign_customer()` 

491 """ 

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

493 return self.get_context_customer(batch) 

494 

495 def get_product_info(self, batch, data): 

496 """ 

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

498 

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

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

501 

502 This should invoke a configured handler for the query 

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

504 built-in logic only, which queries the 

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

506 

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

508 :meth:`create()`. 

509 """ 

510 product_id = data.get('product_id') 

511 if not product_id: 

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

513 

514 session = self.Session() 

515 use_local = self.batch_handler.use_local_products() 

516 if use_local: 

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

518 else: 

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

520 

521 if 'error' in data: 

522 return data 

523 

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

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

526 

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

528 data['unit_price_quoted'] = data['unit_price_reg'] 

529 

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

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

532 

533 if 'case_price_quoted' not in data: 

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

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

536 

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

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

539 

540 decimal_fields = [ 

541 'case_size', 

542 'unit_price_reg', 

543 'unit_price_quoted', 

544 'case_price_quoted', 

545 ] 

546 

547 for field in decimal_fields: 

548 if field in list(data): 

549 value = data[field] 

550 if isinstance(value, decimal.Decimal): 

551 data[field] = float(value) 

552 

553 return data 

554 

555 def add_item(self, batch, data): 

556 """ 

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

558 

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

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

561 

562 * :meth:`update_item()` 

563 * :meth:`delete_item()` 

564 """ 

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

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

567 

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

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

570 

571 def update_item(self, batch, data): 

572 """ 

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

574 

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

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

577 

578 * :meth:`add_item()` 

579 * :meth:`delete_item()` 

580 """ 

581 model = self.app.model 

582 session = self.Session() 

583 

584 uuid = data.get('uuid') 

585 if not uuid: 

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

587 

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

589 if not row: 

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

591 

592 if row.batch is not batch: 

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

594 

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

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

597 

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

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

600 

601 def delete_item(self, batch, data): 

602 """ 

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

604 

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

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

607 

608 * :meth:`add_item()` 

609 * :meth:`update_item()` 

610 """ 

611 model = self.app.model 

612 session = self.app.get_session(batch) 

613 

614 uuid = data.get('uuid') 

615 if not uuid: 

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

617 

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

619 if not row: 

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

621 

622 if row.batch is not batch: 

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

624 

625 self.batch_handler.do_remove_row(row) 

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

627 

628 def submit_order(self, batch, data): 

629 """ 

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

631 executing the batch and creating the true order. 

632 

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

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

635 

636 * :meth:`start_over()` 

637 * :meth:`cancel_order()` 

638 """ 

639 user = self.request.user 

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

641 if reason: 

642 return {'error': reason} 

643 

644 try: 

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

646 except Exception as error: 

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

648 exc_info=True) 

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

650 

651 return { 

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

653 } 

654 

655 def normalize_batch(self, batch): 

656 """ """ 

657 return { 

658 'uuid': batch.uuid.hex, 

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

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

661 'status_code': batch.status_code, 

662 'status_text': batch.status_text, 

663 } 

664 

665 def get_default_uom_choices(self): 

666 """ """ 

667 enum = self.app.enum 

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

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

670 

671 def normalize_row(self, row): 

672 """ """ 

673 enum = self.app.enum 

674 

675 data = { 

676 'uuid': row.uuid.hex, 

677 'sequence': row.sequence, 

678 'product_id': None, 

679 'product_scancode': row.product_scancode, 

680 'product_brand': row.product_brand, 

681 'product_description': row.product_description, 

682 'product_size': row.product_size, 

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

684 row.product_description, 

685 row.product_size), 

686 'product_weighed': row.product_weighed, 

687 'department_display': row.department_name, 

688 'special_order': row.special_order, 

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

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

691 'order_uom': row.order_uom, 

692 'order_uom_choices': self.get_default_uom_choices(), 

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

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

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

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

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

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

699 'status_code': row.status_code, 

700 'status_text': row.status_text, 

701 } 

702 

703 use_local = self.batch_handler.use_local_products() 

704 

705 # product_id 

706 if use_local: 

707 if row.local_product: 

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

709 else: 

710 data['product_id'] = row.product_id 

711 

712 # vendor_name 

713 if use_local: 

714 if row.local_product: 

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

716 else: # use external 

717 pass # TODO 

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

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

720 

721 if row.unit_price_reg: 

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

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

724 

725 if row.unit_price_sale: 

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

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

728 if row.sale_ends: 

729 sale_ends = row.sale_ends 

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

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

732 

733 if row.pending_product: 

734 pending = row.pending_product 

735 data['pending_product'] = { 

736 'uuid': pending.uuid.hex, 

737 'scancode': pending.scancode, 

738 'brand_name': pending.brand_name, 

739 'description': pending.description, 

740 'size': pending.size, 

741 'department_id': pending.department_id, 

742 'department_name': pending.department_name, 

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

744 'vendor_name': pending.vendor_name, 

745 'vendor_item_code': pending.vendor_item_code, 

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

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

748 'notes': pending.notes, 

749 'special_order': pending.special_order, 

750 } 

751 

752 # display text for order qty/uom 

753 if row.order_uom == enum.ORDER_UOM_CASE: 

754 order_qty = self.app.render_quantity(row.order_qty) 

755 if row.case_size is None: 

756 case_qty = unit_qty = '??' 

757 else: 

758 case_qty = self.app.render_quantity(row.case_size) 

759 unit_qty = self.app.render_quantity(row.order_qty * row.case_size) 

760 CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE] 

761 EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] 

762 data['order_qty_display'] = (f"{order_qty} {CS} " 

763 f"(&times; {case_qty} = {unit_qty} {EA})") 

764 else: 

765 unit_qty = self.app.render_quantity(row.order_qty) 

766 EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] 

767 data['order_qty_display'] = f"{unit_qty} {EA}" 

768 

769 return data 

770 

771 def get_instance_title(self, order): 

772 """ """ 

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

774 

775 def configure_form(self, f): 

776 """ """ 

777 super().configure_form(f) 

778 order = f.model_instance 

779 

780 # local_customer 

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

782 

783 # pending_customer 

784 if order.customer_id or order.local_customer: 

785 f.remove('pending_customer') 

786 else: 

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

788 

789 # total_price 

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

791 

792 # created_by 

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

794 f.set_readonly('created_by') 

795 

796 def get_xref_buttons(self, order): 

797 """ """ 

798 buttons = super().get_xref_buttons(order) 

799 model = self.app.model 

800 session = self.Session() 

801 

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

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

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

805 .first() 

806 if batch: 

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

808 buttons.append( 

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

810 

811 return buttons 

812 

813 def get_row_grid_data(self, order): 

814 """ """ 

815 model = self.app.model 

816 session = self.Session() 

817 return session.query(model.OrderItem)\ 

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

819 

820 def configure_row_grid(self, g): 

821 """ """ 

822 super().configure_row_grid(g) 

823 enum = self.app.enum 

824 

825 # sequence 

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

827 g.set_link('sequence') 

828 

829 # product_scancode 

830 g.set_link('product_scancode') 

831 

832 # product_brand 

833 g.set_link('product_brand') 

834 

835 # product_description 

836 g.set_link('product_description') 

837 

838 # product_size 

839 g.set_link('product_size') 

840 

841 # TODO 

842 # order_uom 

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

844 

845 # total_price 

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

847 

848 # status_code 

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

850 

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

852 """ """ 

853 enum = self.app.enum 

854 return enum.ORDER_ITEM_STATUS[value] 

855 

856 def get_row_action_url_view(self, item, i): 

857 """ """ 

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

859 

860 def configure_get_simple_settings(self): 

861 """ """ 

862 settings = [ 

863 

864 # batches 

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

866 

867 # customers 

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

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

870 #'type': bool, 

871 'default': 'true'}, 

872 

873 # products 

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

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

876 #'type': bool, 

877 'default': 'true'}, 

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

879 'type': bool, 

880 'default': True}, 

881 ] 

882 

883 # required fields for new product entry 

884 for field in self.PENDING_PRODUCT_ENTRY_FIELDS: 

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

886 'type': bool} 

887 if field == 'description': 

888 setting['default'] = True 

889 settings.append(setting) 

890 

891 return settings 

892 

893 def configure_get_context(self, **kwargs): 

894 """ """ 

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

896 

897 context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS 

898 

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

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

901 context['batch_handlers'] = handlers 

902 

903 return context 

904 

905 @classmethod 

906 def defaults(cls, config): 

907 cls._order_defaults(config) 

908 cls._defaults(config) 

909 

910 @classmethod 

911 def _order_defaults(cls, config): 

912 route_prefix = cls.get_route_prefix() 

913 permission_prefix = cls.get_permission_prefix() 

914 url_prefix = cls.get_url_prefix() 

915 model_title = cls.get_model_title() 

916 model_title_plural = cls.get_model_title_plural() 

917 

918 # fix perm group 

919 config.add_wutta_permission_group(permission_prefix, 

920 model_title_plural, 

921 overwrite=False) 

922 

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

924 config.add_wutta_permission(permission_prefix, 

925 f'{permission_prefix}.create_unknown_product', 

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

927 

928 # customer autocomplete 

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

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

931 request_method='GET') 

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

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

934 renderer='json', 

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

936 

937 # product autocomplete 

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

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

940 request_method='GET') 

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

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

943 renderer='json', 

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

945 

946 

947class OrderItemView(MasterView): 

948 """ 

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

950 route prefix is ``order_items``. 

951 

952 Notable URLs provided by this class: 

953 

954 * ``/order-items/`` 

955 * ``/order-items/XXX`` 

956 

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

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

959 """ 

960 model_class = OrderItem 

961 model_title = "Order Item" 

962 route_prefix = 'order_items' 

963 url_prefix = '/order-items' 

964 creatable = False 

965 editable = False 

966 deletable = False 

967 

968 labels = { 

969 'order_id': "Order ID", 

970 'product_id': "Product ID", 

971 'product_scancode': "Scancode", 

972 'product_brand': "Brand", 

973 'product_description': "Description", 

974 'product_size': "Size", 

975 'product_weighed': "Sold by Weight", 

976 'department_id': "Department ID", 

977 'order_uom': "Order UOM", 

978 'status_code': "Status", 

979 } 

980 

981 grid_columns = [ 

982 'order_id', 

983 'customer_name', 

984 # 'sequence', 

985 'product_scancode', 

986 'product_brand', 

987 'product_description', 

988 'product_size', 

989 'department_name', 

990 'special_order', 

991 'order_qty', 

992 'order_uom', 

993 'total_price', 

994 'status_code', 

995 ] 

996 

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

998 

999 form_fields = [ 

1000 'order', 

1001 # 'customer_name', 

1002 'sequence', 

1003 'product_id', 

1004 'local_product', 

1005 'pending_product', 

1006 'product_scancode', 

1007 'product_brand', 

1008 'product_description', 

1009 'product_size', 

1010 'product_weighed', 

1011 'department_id', 

1012 'department_name', 

1013 'special_order', 

1014 'case_size', 

1015 'unit_cost', 

1016 'unit_price_reg', 

1017 'unit_price_sale', 

1018 'sale_ends', 

1019 'unit_price_quoted', 

1020 'case_price_quoted', 

1021 'order_qty', 

1022 'order_uom', 

1023 'discount_percent', 

1024 'total_price', 

1025 'status_code', 

1026 'paid_amount', 

1027 'payment_transaction_number', 

1028 ] 

1029 

1030 def get_query(self, session=None): 

1031 """ """ 

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

1033 model = self.app.model 

1034 return query.join(model.Order) 

1035 

1036 def configure_grid(self, g): 

1037 """ """ 

1038 super().configure_grid(g) 

1039 model = self.app.model 

1040 # enum = self.app.enum 

1041 

1042 # order_id 

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

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

1045 g.set_link('order_id') 

1046 

1047 # customer_name 

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

1049 

1050 # # sequence 

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

1052 

1053 # product_scancode 

1054 g.set_link('product_scancode') 

1055 

1056 # product_brand 

1057 g.set_link('product_brand') 

1058 

1059 # product_description 

1060 g.set_link('product_description') 

1061 

1062 # product_size 

1063 g.set_link('product_size') 

1064 

1065 # order_uom 

1066 # TODO 

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

1068 

1069 # total_price 

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

1071 

1072 # status_code 

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

1074 

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

1076 """ """ 

1077 return item.order.order_id 

1078 

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

1080 """ """ 

1081 enum = self.app.enum 

1082 return enum.ORDER_ITEM_STATUS[value] 

1083 

1084 def get_instance_title(self, item): 

1085 """ """ 

1086 enum = self.app.enum 

1087 title = str(item) 

1088 status = enum.ORDER_ITEM_STATUS[item.status_code] 

1089 return f"({status}) {title}" 

1090 

1091 def configure_form(self, f): 

1092 """ """ 

1093 super().configure_form(f) 

1094 enum = self.app.enum 

1095 item = f.model_instance 

1096 

1097 # order 

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

1099 

1100 # local_product 

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

1102 

1103 # pending_product 

1104 if item.product_id or item.local_product: 

1105 f.remove('pending_product') 

1106 else: 

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

1108 

1109 # order_qty 

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

1111 

1112 # order_uom 

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

1114 

1115 # case_size 

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

1117 

1118 # unit_cost 

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

1120 

1121 # unit_price_reg 

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

1123 

1124 # unit_price_quoted 

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

1126 

1127 # case_price_quoted 

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

1129 

1130 # total_price 

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

1132 

1133 # status 

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

1135 

1136 # paid_amount 

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

1138 

1139 def get_xref_buttons(self, item): 

1140 """ """ 

1141 buttons = super().get_xref_buttons(item) 

1142 

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

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

1145 buttons.append( 

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

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

1148 

1149 return buttons 

1150 

1151 

1152def defaults(config, **kwargs): 

1153 base = globals() 

1154 

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

1156 OrderView.defaults(config) 

1157 

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

1159 OrderItemView.defaults(config) 

1160 

1161 

1162def includeme(config): 

1163 defaults(config)