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

420 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-09 12:06 -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 model_class = Order 

62 editable = False 

63 configurable = True 

64 

65 labels = { 

66 'order_id': "Order ID", 

67 'store_id': "Store ID", 

68 'customer_id': "Customer ID", 

69 } 

70 

71 grid_columns = [ 

72 'order_id', 

73 'store_id', 

74 'customer_id', 

75 'customer_name', 

76 'total_price', 

77 'created', 

78 'created_by', 

79 ] 

80 

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

82 

83 form_fields = [ 

84 'order_id', 

85 'store_id', 

86 'customer_id', 

87 'local_customer', 

88 'pending_customer', 

89 'customer_name', 

90 'phone_number', 

91 'email_address', 

92 'total_price', 

93 'created', 

94 'created_by', 

95 ] 

96 

97 has_rows = True 

98 row_model_class = OrderItem 

99 rows_title = "Order Items" 

100 rows_sort_defaults = 'sequence' 

101 rows_viewable = True 

102 

103 row_labels = { 

104 'product_scancode': "Scancode", 

105 'product_brand': "Brand", 

106 'product_description': "Description", 

107 'product_size': "Size", 

108 'department_name': "Department", 

109 'order_uom': "Order UOM", 

110 'status_code': "Status", 

111 } 

112 

113 row_grid_columns = [ 

114 'sequence', 

115 'product_scancode', 

116 'product_brand', 

117 'product_description', 

118 'product_size', 

119 'department_name', 

120 'special_order', 

121 'order_qty', 

122 'order_uom', 

123 'total_price', 

124 'status_code', 

125 ] 

126 

127 PENDING_PRODUCT_ENTRY_FIELDS = [ 

128 'scancode', 

129 'brand_name', 

130 'description', 

131 'size', 

132 'department_name', 

133 'vendor_name', 

134 'vendor_item_code', 

135 'case_size', 

136 'unit_cost', 

137 'unit_price_reg', 

138 ] 

139 

140 def configure_grid(self, g): 

141 """ """ 

142 super().configure_grid(g) 

143 

144 # order_id 

145 g.set_link('order_id') 

146 

147 # customer_id 

148 g.set_link('customer_id') 

149 

150 # customer_name 

151 g.set_link('customer_name') 

152 

153 # total_price 

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

155 

156 def create(self): 

157 """ 

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

159 of sorts. 

160 

161 Under the hood a 

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

163 automatically created for the user when they first visit this 

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

165 

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

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

168 batch, which in turn creates a true 

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

170 redirected to the "view order" page. 

171 

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

173 based on user actions: 

174 

175 * :meth:`start_over()` 

176 * :meth:`cancel_order()` 

177 * :meth:`assign_customer()` 

178 * :meth:`unassign_customer()` 

179 * :meth:`set_pending_customer()` 

180 * :meth:`get_product_info()` 

181 * :meth:`add_item()` 

182 * :meth:`update_item()` 

183 * :meth:`delete_item()` 

184 * :meth:`submit_order()` 

185 """ 

186 enum = self.app.enum 

187 self.creating = True 

188 self.batch_handler = NewOrderBatchHandler(self.config) 

189 batch = self.get_current_batch() 

190 

191 context = self.get_context_customer(batch) 

192 

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

194 

195 # first we check for traditional form post 

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

197 post_actions = [ 

198 'start_over', 

199 'cancel_order', 

200 ] 

201 if action in post_actions: 

202 return getattr(self, action)(batch) 

203 

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

205 data = dict(self.request.json_body) 

206 action = data.pop('action') 

207 json_actions = [ 

208 'assign_customer', 

209 'unassign_customer', 

210 # 'update_phone_number', 

211 # 'update_email_address', 

212 'set_pending_customer', 

213 # 'get_customer_info', 

214 # # 'set_customer_data', 

215 'get_product_info', 

216 # 'get_past_items', 

217 'add_item', 

218 'update_item', 

219 'delete_item', 

220 'submit_order', 

221 ] 

222 if action in json_actions: 

223 try: 

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

225 except Exception as error: 

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

227 return self.json_response(result) 

228 

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

230 

231 context.update({ 

232 'batch': batch, 

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

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

235 for row in batch.rows], 

236 'default_uom_choices': self.get_default_uom_choices(), 

237 'default_uom': None, # TODO? 

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

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

240 'pending_product_required_fields': self.get_pending_product_required_fields(), 

241 }) 

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

243 

244 def get_current_batch(self): 

245 """ 

246 Returns the current batch for the current user. 

247 

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

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

250 created. 

251 

252 :returns: 

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

254 instance 

255 """ 

256 model = self.app.model 

257 session = self.Session() 

258 

259 user = self.request.user 

260 if not user: 

261 raise self.forbidden() 

262 

263 try: 

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

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

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

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

268 .one() 

269 

270 except orm.exc.NoResultFound: 

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

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

273 session.add(batch) 

274 session.flush() 

275 

276 return batch 

277 

278 def customer_autocomplete(self): 

279 """ 

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

281 

282 This should invoke a configured handler for the autocomplete 

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

284 built-in logic only, which queries the 

285 :class:`~sideshow.db.model.customers.LocalCustomer` table. 

286 """ 

287 session = self.Session() 

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

289 if not term: 

290 return [] 

291 return self.mock_autocomplete_customers(session, term, user=self.request.user) 

292 

293 # TODO: move this to some handler 

294 def mock_autocomplete_customers(self, session, term, user=None): 

295 """ """ 

296 import sqlalchemy as sa 

297 

298 model = self.app.model 

299 

300 # base query 

301 query = session.query(model.LocalCustomer) 

302 

303 # filter query 

304 criteria = [model.LocalCustomer.full_name.ilike(f'%{word}%') 

305 for word in term.split()] 

306 query = query.filter(sa.and_(*criteria)) 

307 

308 # sort query 

309 query = query.order_by(model.LocalCustomer.full_name) 

310 

311 # get data 

312 # TODO: need max_results option 

313 customers = query.all() 

314 

315 # get results 

316 def result(customer): 

317 return {'value': customer.uuid.hex, 

318 'label': customer.full_name} 

319 return [result(c) for c in customers] 

320 

321 def product_autocomplete(self): 

322 """ 

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

324 

325 This should invoke a configured handler for the autocomplete 

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

327 built-in logic only, which queries the 

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

329 """ 

330 session = self.Session() 

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

332 if not term: 

333 return [] 

334 return self.mock_autocomplete_products(session, term, user=self.request.user) 

335 

336 # TODO: move this to some handler 

337 def mock_autocomplete_products(self, session, term, user=None): 

338 """ """ 

339 import sqlalchemy as sa 

340 

341 model = self.app.model 

342 

343 # base query 

344 query = session.query(model.LocalProduct) 

345 

346 # filter query 

347 criteria = [] 

348 for word in term.split(): 

349 criteria.append(sa.or_( 

350 model.LocalProduct.brand_name.ilike(f'%{word}%'), 

351 model.LocalProduct.description.ilike(f'%{word}%'))) 

352 query = query.filter(sa.and_(*criteria)) 

353 

354 # sort query 

355 query = query.order_by(model.LocalProduct.brand_name, 

356 model.LocalProduct.description) 

357 

358 # get data 

359 # TODO: need max_results option 

360 products = query.all() 

361 

362 # get results 

363 def result(product): 

364 return {'value': product.uuid.hex, 

365 'label': product.full_description} 

366 return [result(c) for c in products] 

367 

368 def get_pending_product_required_fields(self): 

369 """ """ 

370 required = [] 

371 for field in self.PENDING_PRODUCT_ENTRY_FIELDS: 

372 require = self.config.get_bool( 

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

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

375 require = True 

376 if require: 

377 required.append(field) 

378 return required 

379 

380 def start_over(self, batch): 

381 """ 

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

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

384 new batch for them. 

385 

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

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

388 

389 * :meth:`cancel_order()` 

390 * :meth:`submit_order()` 

391 """ 

392 # drop current batch 

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

394 self.Session.flush() 

395 

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

397 route_prefix = self.get_route_prefix() 

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

399 return self.redirect(url) 

400 

401 def cancel_order(self, batch): 

402 """ 

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

404 back to "List Orders" page. 

405 

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

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

408 

409 * :meth:`start_over()` 

410 * :meth:`submit_order()` 

411 """ 

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

413 self.Session.flush() 

414 

415 # set flash msg just to be more obvious 

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

417 

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

419 url = self.get_index_url() 

420 return self.redirect(url) 

421 

422 def get_context_customer(self, batch): 

423 """ """ 

424 context = { 

425 'customer_is_known': True, 

426 'customer_id': None, 

427 'customer_name': batch.customer_name, 

428 'phone_number': batch.phone_number, 

429 'email_address': batch.email_address, 

430 } 

431 

432 # customer_id 

433 use_local = self.batch_handler.use_local_customers() 

434 if use_local: 

435 local = batch.local_customer 

436 if local: 

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

438 else: # use external 

439 context['customer_id'] = batch.customer_id 

440 

441 # pending customer 

442 pending = batch.pending_customer 

443 if pending: 

444 context.update({ 

445 'new_customer_first_name': pending.first_name, 

446 'new_customer_last_name': pending.last_name, 

447 'new_customer_full_name': pending.full_name, 

448 'new_customer_phone': pending.phone_number, 

449 'new_customer_email': pending.email_address, 

450 }) 

451 

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

453 if (pending 

454 and not batch.customer_id and not batch.local_customer 

455 and batch.customer_name): 

456 context['customer_is_known'] = False 

457 

458 return context 

459 

460 def assign_customer(self, batch, data): 

461 """ 

462 Assign the true customer account for a batch. 

463 

464 This calls 

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

466 for the heavy lifting. 

467 

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

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

470 

471 * :meth:`unassign_customer()` 

472 * :meth:`set_pending_customer()` 

473 """ 

474 customer_id = data.get('customer_id') 

475 if not customer_id: 

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

477 

478 self.batch_handler.set_customer(batch, customer_id) 

479 return self.get_context_customer(batch) 

480 

481 def unassign_customer(self, batch, data): 

482 """ 

483 Clear the customer info for a batch. 

484 

485 This calls 

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

487 for the heavy lifting. 

488 

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

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

491 

492 * :meth:`assign_customer()` 

493 * :meth:`set_pending_customer()` 

494 """ 

495 self.batch_handler.set_customer(batch, None) 

496 return self.get_context_customer(batch) 

497 

498 def set_pending_customer(self, batch, data): 

499 """ 

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

501 

502 This calls 

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

504 for the heavy lifting. 

505 

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

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

508 

509 * :meth:`assign_customer()` 

510 * :meth:`unassign_customer()` 

511 """ 

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

513 return self.get_context_customer(batch) 

514 

515 def get_product_info(self, batch, data): 

516 """ 

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

518 

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

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

521 

522 This should invoke a configured handler for the query 

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

524 built-in logic only, which queries the 

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

526 

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

528 :meth:`create()`. 

529 """ 

530 product_id = data.get('product_id') 

531 if not product_id: 

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

533 

534 use_local = self.batch_handler.use_local_products() 

535 if use_local: 

536 data = self.get_local_product_info(product_id) 

537 else: 

538 raise NotImplementedError("TODO: add integration handler") 

539 

540 if 'error' in data: 

541 return data 

542 

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

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

545 

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

547 data['unit_price_quoted'] = data['unit_price_reg'] 

548 

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

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

551 

552 if 'case_price_quoted' not in data: 

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

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

555 

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

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

558 

559 decimal_fields = [ 

560 'case_size', 

561 'unit_price_reg', 

562 'unit_price_quoted', 

563 'case_price_quoted', 

564 ] 

565 

566 for field in decimal_fields: 

567 if field in list(data): 

568 value = data[field] 

569 if isinstance(value, decimal.Decimal): 

570 data[field] = float(value) 

571 

572 return data 

573 

574 # TODO: move this to some handler 

575 def get_local_product_info(self, product_id): 

576 """ """ 

577 model = self.app.model 

578 session = self.Session() 

579 product = session.get(model.LocalProduct, product_id) 

580 if not product: 

581 return {'error': "Product not found"} 

582 

583 return { 

584 'product_id': product.uuid.hex, 

585 'scancode': product.scancode, 

586 'brand_name': product.brand_name, 

587 'description': product.description, 

588 'size': product.size, 

589 'full_description': product.full_description, 

590 'weighed': product.weighed, 

591 'special_order': product.special_order, 

592 'department_id': product.department_id, 

593 'department_name': product.department_name, 

594 'case_size': product.case_size, 

595 'unit_price_reg': product.unit_price_reg, 

596 'vendor_name': product.vendor_name, 

597 'vendor_item_code': product.vendor_item_code, 

598 } 

599 

600 def add_item(self, batch, data): 

601 """ 

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

603 

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

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

606 

607 * :meth:`update_item()` 

608 * :meth:`delete_item()` 

609 """ 

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

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

612 

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

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

615 

616 def update_item(self, batch, data): 

617 """ 

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

619 

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

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

622 

623 * :meth:`add_item()` 

624 * :meth:`delete_item()` 

625 """ 

626 model = self.app.model 

627 session = self.Session() 

628 

629 uuid = data.get('uuid') 

630 if not uuid: 

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

632 

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

634 if not row: 

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

636 

637 if row.batch is not batch: 

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

639 

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

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

642 

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

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

645 

646 def delete_item(self, batch, data): 

647 """ 

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

649 

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

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

652 

653 * :meth:`add_item()` 

654 * :meth:`update_item()` 

655 """ 

656 model = self.app.model 

657 session = self.app.get_session(batch) 

658 

659 uuid = data.get('uuid') 

660 if not uuid: 

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

662 

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

664 if not row: 

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

666 

667 if row.batch is not batch: 

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

669 

670 self.batch_handler.do_remove_row(row) 

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

672 

673 def submit_order(self, batch, data): 

674 """ 

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

676 executing the batch and creating the true order. 

677 

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

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

680 

681 * :meth:`start_over()` 

682 * :meth:`cancel_order()` 

683 """ 

684 user = self.request.user 

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

686 if reason: 

687 return {'error': reason} 

688 

689 try: 

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

691 except Exception as error: 

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

693 exc_info=True) 

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

695 

696 return { 

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

698 } 

699 

700 def normalize_batch(self, batch): 

701 """ """ 

702 return { 

703 'uuid': batch.uuid.hex, 

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

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

706 'status_code': batch.status_code, 

707 'status_text': batch.status_text, 

708 } 

709 

710 def get_default_uom_choices(self): 

711 """ """ 

712 enum = self.app.enum 

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

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

715 

716 def normalize_row(self, row): 

717 """ """ 

718 enum = self.app.enum 

719 

720 data = { 

721 'uuid': row.uuid.hex, 

722 'sequence': row.sequence, 

723 'product_id': None, 

724 'product_scancode': row.product_scancode, 

725 'product_brand': row.product_brand, 

726 'product_description': row.product_description, 

727 'product_size': row.product_size, 

728 'product_weighed': row.product_weighed, 

729 'department_display': row.department_name, 

730 'special_order': row.special_order, 

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

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

733 'order_uom': row.order_uom, 

734 'order_uom_choices': self.get_default_uom_choices(), 

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

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

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

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

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

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

741 'status_code': row.status_code, 

742 'status_text': row.status_text, 

743 } 

744 

745 use_local = self.batch_handler.use_local_products() 

746 

747 # product_id 

748 if use_local: 

749 if row.local_product: 

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

751 else: 

752 data['product_id'] = row.product_id 

753 

754 # product_full_description 

755 if use_local: 

756 if row.local_product: 

757 data['product_full_description'] = row.local_product.full_description 

758 else: # use external 

759 pass # TODO 

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

761 data['product_full_description'] = row.pending_product.full_description 

762 

763 # vendor_name 

764 if use_local: 

765 if row.local_product: 

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

767 else: # use external 

768 pass # TODO 

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

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

771 

772 if row.unit_price_reg: 

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

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

775 

776 if row.unit_price_sale: 

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

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

779 if row.sale_ends: 

780 sale_ends = row.sale_ends 

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

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

783 

784 if row.pending_product: 

785 pending = row.pending_product 

786 data['pending_product'] = { 

787 'uuid': pending.uuid.hex, 

788 'scancode': pending.scancode, 

789 'brand_name': pending.brand_name, 

790 'description': pending.description, 

791 'size': pending.size, 

792 'department_id': pending.department_id, 

793 'department_name': pending.department_name, 

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

795 'vendor_name': pending.vendor_name, 

796 'vendor_item_code': pending.vendor_item_code, 

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

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

799 'notes': pending.notes, 

800 'special_order': pending.special_order, 

801 } 

802 

803 # display text for order qty/uom 

804 if row.order_uom == enum.ORDER_UOM_CASE: 

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

806 if row.case_size is None: 

807 case_qty = unit_qty = '??' 

808 else: 

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

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

811 CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE] 

812 EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] 

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

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

815 else: 

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

817 EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] 

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

819 

820 return data 

821 

822 def get_instance_title(self, order): 

823 """ """ 

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

825 

826 def configure_form(self, f): 

827 """ """ 

828 super().configure_form(f) 

829 order = f.model_instance 

830 

831 # local_customer 

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

833 

834 # pending_customer 

835 if order.customer_id or order.local_customer: 

836 f.remove('pending_customer') 

837 else: 

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

839 

840 # total_price 

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

842 

843 # created_by 

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

845 f.set_readonly('created_by') 

846 

847 def get_xref_buttons(self, order): 

848 """ """ 

849 buttons = super().get_xref_buttons(order) 

850 model = self.app.model 

851 session = self.Session() 

852 

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

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

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

856 .first() 

857 if batch: 

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

859 buttons.append( 

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

861 

862 return buttons 

863 

864 def get_row_grid_data(self, order): 

865 """ """ 

866 model = self.app.model 

867 session = self.Session() 

868 return session.query(model.OrderItem)\ 

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

870 

871 def configure_row_grid(self, g): 

872 """ """ 

873 super().configure_row_grid(g) 

874 enum = self.app.enum 

875 

876 # sequence 

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

878 g.set_link('sequence') 

879 

880 # product_scancode 

881 g.set_link('product_scancode') 

882 

883 # product_brand 

884 g.set_link('product_brand') 

885 

886 # product_description 

887 g.set_link('product_description') 

888 

889 # product_size 

890 g.set_link('product_size') 

891 

892 # TODO 

893 # order_uom 

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

895 

896 # total_price 

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

898 

899 # status_code 

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

901 

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

903 """ """ 

904 enum = self.app.enum 

905 return enum.ORDER_ITEM_STATUS[value] 

906 

907 def get_row_action_url_view(self, item, i): 

908 """ """ 

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

910 

911 def configure_get_simple_settings(self): 

912 """ """ 

913 settings = [ 

914 

915 # products 

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

917 'type': bool, 

918 'default': True}, 

919 ] 

920 

921 # required fields for new product entry 

922 for field in self.PENDING_PRODUCT_ENTRY_FIELDS: 

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

924 'type': bool} 

925 if field == 'description': 

926 setting['default'] = True 

927 settings.append(setting) 

928 

929 return settings 

930 

931 def configure_get_context(self, **kwargs): 

932 """ """ 

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

934 

935 context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS 

936 

937 return context 

938 

939 @classmethod 

940 def defaults(cls, config): 

941 cls._order_defaults(config) 

942 cls._defaults(config) 

943 

944 @classmethod 

945 def _order_defaults(cls, config): 

946 route_prefix = cls.get_route_prefix() 

947 permission_prefix = cls.get_permission_prefix() 

948 url_prefix = cls.get_url_prefix() 

949 model_title = cls.get_model_title() 

950 model_title_plural = cls.get_model_title_plural() 

951 

952 # fix perm group 

953 config.add_wutta_permission_group(permission_prefix, 

954 model_title_plural, 

955 overwrite=False) 

956 

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

958 config.add_wutta_permission(permission_prefix, 

959 f'{permission_prefix}.create_unknown_product', 

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

961 

962 # customer autocomplete 

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

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

965 request_method='GET') 

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

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

968 renderer='json', 

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

970 

971 # product autocomplete 

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

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

974 request_method='GET') 

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

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

977 renderer='json', 

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

979 

980 

981class OrderItemView(MasterView): 

982 """ 

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

984 route prefix is ``order_items``. 

985 

986 Notable URLs provided by this class: 

987 

988 * ``/order-items/`` 

989 * ``/order-items/XXX`` 

990 

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

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

993 """ 

994 model_class = OrderItem 

995 model_title = "Order Item" 

996 route_prefix = 'order_items' 

997 url_prefix = '/order-items' 

998 creatable = False 

999 editable = False 

1000 deletable = False 

1001 

1002 labels = { 

1003 'order_id': "Order ID", 

1004 'product_id': "Product ID", 

1005 'product_scancode': "Scancode", 

1006 'product_brand': "Brand", 

1007 'product_description': "Description", 

1008 'product_size': "Size", 

1009 'product_weighed': "Sold by Weight", 

1010 'department_id': "Department ID", 

1011 'order_uom': "Order UOM", 

1012 'status_code': "Status", 

1013 } 

1014 

1015 grid_columns = [ 

1016 'order_id', 

1017 'customer_name', 

1018 # 'sequence', 

1019 'product_scancode', 

1020 'product_brand', 

1021 'product_description', 

1022 'product_size', 

1023 'department_name', 

1024 'special_order', 

1025 'order_qty', 

1026 'order_uom', 

1027 'total_price', 

1028 'status_code', 

1029 ] 

1030 

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

1032 

1033 form_fields = [ 

1034 'order', 

1035 # 'customer_name', 

1036 'sequence', 

1037 'product_id', 

1038 'local_product', 

1039 'pending_product', 

1040 'product_scancode', 

1041 'product_brand', 

1042 'product_description', 

1043 'product_size', 

1044 'product_weighed', 

1045 'department_id', 

1046 'department_name', 

1047 'special_order', 

1048 'order_qty', 

1049 'order_uom', 

1050 'case_size', 

1051 'unit_cost', 

1052 'unit_price_reg', 

1053 'unit_price_sale', 

1054 'sale_ends', 

1055 'unit_price_quoted', 

1056 'case_price_quoted', 

1057 'discount_percent', 

1058 'total_price', 

1059 'status_code', 

1060 'paid_amount', 

1061 'payment_transaction_number', 

1062 ] 

1063 

1064 def get_query(self, session=None): 

1065 """ """ 

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

1067 model = self.app.model 

1068 return query.join(model.Order) 

1069 

1070 def configure_grid(self, g): 

1071 """ """ 

1072 super().configure_grid(g) 

1073 model = self.app.model 

1074 # enum = self.app.enum 

1075 

1076 # order_id 

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

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

1079 g.set_link('order_id') 

1080 

1081 # customer_name 

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

1083 

1084 # # sequence 

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

1086 

1087 # product_scancode 

1088 g.set_link('product_scancode') 

1089 

1090 # product_brand 

1091 g.set_link('product_brand') 

1092 

1093 # product_description 

1094 g.set_link('product_description') 

1095 

1096 # product_size 

1097 g.set_link('product_size') 

1098 

1099 # order_uom 

1100 # TODO 

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

1102 

1103 # total_price 

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

1105 

1106 # status_code 

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

1108 

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

1110 """ """ 

1111 return item.order.order_id 

1112 

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

1114 """ """ 

1115 enum = self.app.enum 

1116 return enum.ORDER_ITEM_STATUS[value] 

1117 

1118 def get_instance_title(self, item): 

1119 """ """ 

1120 enum = self.app.enum 

1121 title = str(item) 

1122 status = enum.ORDER_ITEM_STATUS[item.status_code] 

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

1124 

1125 def configure_form(self, f): 

1126 """ """ 

1127 super().configure_form(f) 

1128 enum = self.app.enum 

1129 item = f.model_instance 

1130 

1131 # order 

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

1133 

1134 # local_product 

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

1136 

1137 # pending_product 

1138 if item.product_id or item.local_product: 

1139 f.remove('pending_product') 

1140 else: 

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

1142 

1143 # order_qty 

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

1145 

1146 # order_uom 

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

1148 

1149 # case_size 

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

1151 

1152 # unit_cost 

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

1154 

1155 # unit_price_reg 

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

1157 

1158 # unit_price_quoted 

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

1160 

1161 # case_price_quoted 

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

1163 

1164 # total_price 

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

1166 

1167 # status 

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

1169 

1170 # paid_amount 

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

1172 

1173 def get_xref_buttons(self, item): 

1174 """ """ 

1175 buttons = super().get_xref_buttons(item) 

1176 

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

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

1179 buttons.append( 

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

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

1182 

1183 return buttons 

1184 

1185 

1186def defaults(config, **kwargs): 

1187 base = globals() 

1188 

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

1190 OrderView.defaults(config) 

1191 

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

1193 OrderItemView.defaults(config) 

1194 

1195 

1196def includeme(config): 

1197 defaults(config)