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

400 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-13 18:58 -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, and can 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 if order.customer_id and not order.local_customer: 

782 f.remove('local_customer') 

783 else: 

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

785 

786 # pending_customer 

787 if order.customer_id or order.local_customer: 

788 f.remove('pending_customer') 

789 else: 

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

791 

792 # total_price 

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

794 

795 # created_by 

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

797 f.set_readonly('created_by') 

798 

799 def get_xref_buttons(self, order): 

800 """ """ 

801 buttons = super().get_xref_buttons(order) 

802 model = self.app.model 

803 session = self.Session() 

804 

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

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

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

808 .first() 

809 if batch: 

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

811 buttons.append( 

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

813 

814 return buttons 

815 

816 def get_row_grid_data(self, order): 

817 """ """ 

818 model = self.app.model 

819 session = self.Session() 

820 return session.query(model.OrderItem)\ 

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

822 

823 def configure_row_grid(self, g): 

824 """ """ 

825 super().configure_row_grid(g) 

826 enum = self.app.enum 

827 

828 # sequence 

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

830 g.set_link('sequence') 

831 

832 # product_scancode 

833 g.set_link('product_scancode') 

834 

835 # product_brand 

836 g.set_link('product_brand') 

837 

838 # product_description 

839 g.set_link('product_description') 

840 

841 # product_size 

842 g.set_link('product_size') 

843 

844 # TODO 

845 # order_uom 

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

847 

848 # total_price 

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

850 

851 # status_code 

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

853 

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

855 """ """ 

856 enum = self.app.enum 

857 return enum.ORDER_ITEM_STATUS[value] 

858 

859 def get_row_action_url_view(self, item, i): 

860 """ """ 

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

862 

863 def configure_get_simple_settings(self): 

864 """ """ 

865 settings = [ 

866 

867 # batches 

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

869 

870 # customers 

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

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

873 #'type': bool, 

874 'default': 'true'}, 

875 

876 # products 

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

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

879 #'type': bool, 

880 'default': 'true'}, 

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

882 'type': bool, 

883 'default': True}, 

884 ] 

885 

886 # required fields for new product entry 

887 for field in self.PENDING_PRODUCT_ENTRY_FIELDS: 

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

889 'type': bool} 

890 if field == 'description': 

891 setting['default'] = True 

892 settings.append(setting) 

893 

894 return settings 

895 

896 def configure_get_context(self, **kwargs): 

897 """ """ 

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

899 

900 context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS 

901 

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

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

904 context['batch_handlers'] = handlers 

905 

906 return context 

907 

908 @classmethod 

909 def defaults(cls, config): 

910 cls._order_defaults(config) 

911 cls._defaults(config) 

912 

913 @classmethod 

914 def _order_defaults(cls, config): 

915 route_prefix = cls.get_route_prefix() 

916 permission_prefix = cls.get_permission_prefix() 

917 url_prefix = cls.get_url_prefix() 

918 model_title = cls.get_model_title() 

919 model_title_plural = cls.get_model_title_plural() 

920 

921 # fix perm group 

922 config.add_wutta_permission_group(permission_prefix, 

923 model_title_plural, 

924 overwrite=False) 

925 

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

927 config.add_wutta_permission(permission_prefix, 

928 f'{permission_prefix}.create_unknown_product', 

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

930 

931 # customer autocomplete 

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

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

934 request_method='GET') 

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

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

937 renderer='json', 

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

939 

940 # product autocomplete 

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

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

943 request_method='GET') 

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

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

946 renderer='json', 

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

948 

949 

950class OrderItemView(MasterView): 

951 """ 

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

953 route prefix is ``order_items``. 

954 

955 Notable URLs provided by this class: 

956 

957 * ``/order-items/`` 

958 * ``/order-items/XXX`` 

959 

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

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

962 """ 

963 model_class = OrderItem 

964 model_title = "Order Item" 

965 route_prefix = 'order_items' 

966 url_prefix = '/order-items' 

967 creatable = False 

968 editable = False 

969 deletable = False 

970 

971 labels = { 

972 'order_id': "Order ID", 

973 'product_id': "Product ID", 

974 'product_scancode': "Scancode", 

975 'product_brand': "Brand", 

976 'product_description': "Description", 

977 'product_size': "Size", 

978 'product_weighed': "Sold by Weight", 

979 'department_id': "Department ID", 

980 'order_uom': "Order UOM", 

981 'status_code': "Status", 

982 } 

983 

984 grid_columns = [ 

985 'order_id', 

986 'customer_name', 

987 # 'sequence', 

988 'product_scancode', 

989 'product_brand', 

990 'product_description', 

991 'product_size', 

992 'department_name', 

993 'special_order', 

994 'order_qty', 

995 'order_uom', 

996 'total_price', 

997 'status_code', 

998 ] 

999 

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

1001 

1002 form_fields = [ 

1003 'order', 

1004 # 'customer_name', 

1005 'sequence', 

1006 'product_id', 

1007 'local_product', 

1008 'pending_product', 

1009 'product_scancode', 

1010 'product_brand', 

1011 'product_description', 

1012 'product_size', 

1013 'product_weighed', 

1014 'department_id', 

1015 'department_name', 

1016 'special_order', 

1017 'case_size', 

1018 'unit_cost', 

1019 'unit_price_reg', 

1020 'unit_price_sale', 

1021 'sale_ends', 

1022 'unit_price_quoted', 

1023 'case_price_quoted', 

1024 'order_qty', 

1025 'order_uom', 

1026 'discount_percent', 

1027 'total_price', 

1028 'status_code', 

1029 'paid_amount', 

1030 'payment_transaction_number', 

1031 ] 

1032 

1033 def get_query(self, session=None): 

1034 """ """ 

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

1036 model = self.app.model 

1037 return query.join(model.Order) 

1038 

1039 def configure_grid(self, g): 

1040 """ """ 

1041 super().configure_grid(g) 

1042 model = self.app.model 

1043 # enum = self.app.enum 

1044 

1045 # order_id 

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

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

1048 g.set_link('order_id') 

1049 

1050 # customer_name 

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

1052 

1053 # # sequence 

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

1055 

1056 # product_scancode 

1057 g.set_link('product_scancode') 

1058 

1059 # product_brand 

1060 g.set_link('product_brand') 

1061 

1062 # product_description 

1063 g.set_link('product_description') 

1064 

1065 # product_size 

1066 g.set_link('product_size') 

1067 

1068 # order_uom 

1069 # TODO 

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

1071 

1072 # total_price 

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

1074 

1075 # status_code 

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

1077 

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

1079 """ """ 

1080 return item.order.order_id 

1081 

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

1083 """ """ 

1084 enum = self.app.enum 

1085 return enum.ORDER_ITEM_STATUS[value] 

1086 

1087 def get_instance_title(self, item): 

1088 """ """ 

1089 enum = self.app.enum 

1090 title = str(item) 

1091 status = enum.ORDER_ITEM_STATUS[item.status_code] 

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

1093 

1094 def configure_form(self, f): 

1095 """ """ 

1096 super().configure_form(f) 

1097 enum = self.app.enum 

1098 item = f.model_instance 

1099 

1100 # order 

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

1102 

1103 # local_product 

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

1105 

1106 # pending_product 

1107 if item.product_id or item.local_product: 

1108 f.remove('pending_product') 

1109 else: 

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

1111 

1112 # order_qty 

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

1114 

1115 # order_uom 

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

1117 

1118 # case_size 

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

1120 

1121 # unit_cost 

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

1123 

1124 # unit_price_reg 

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

1126 

1127 # unit_price_quoted 

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

1129 

1130 # case_price_quoted 

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

1132 

1133 # total_price 

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

1135 

1136 # status 

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

1138 

1139 # paid_amount 

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

1141 

1142 def get_xref_buttons(self, item): 

1143 """ """ 

1144 buttons = super().get_xref_buttons(item) 

1145 

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

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

1148 buttons.append( 

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

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

1151 

1152 return buttons 

1153 

1154 

1155def defaults(config, **kwargs): 

1156 base = globals() 

1157 

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

1159 OrderView.defaults(config) 

1160 

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

1162 OrderItemView.defaults(config) 

1163 

1164 

1165def includeme(config): 

1166 defaults(config)