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

763 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-02-20 09:03 -0600

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

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

3# 

4# Sideshow -- Case/Special Order Tracker 

5# Copyright © 2024-2025 Lance Edgar 

6# 

7# This file is part of Sideshow. 

8# 

9# Sideshow is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# Sideshow is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 

17# General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with Sideshow. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Views for Orders 

25""" 

26 

27import decimal 

28import json 

29import logging 

30import re 

31 

32import colander 

33import sqlalchemy as sa 

34from sqlalchemy import orm 

35 

36from webhelpers2.html import tags, HTML 

37 

38from wuttaweb.views import MasterView 

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

40from wuttaweb.util import make_json_safe 

41 

42from sideshow.db.model import Order, OrderItem 

43from sideshow.batch.neworder import NewOrderBatchHandler 

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

45 LocalCustomerRef, LocalProductRef, 

46 PendingCustomerRef, PendingProductRef) 

47 

48 

49log = logging.getLogger(__name__) 

50 

51 

52class OrderView(MasterView): 

53 """ 

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

55 prefix is ``orders``. 

56 

57 Notable URLs provided by this class: 

58 

59 * ``/orders/`` 

60 * ``/orders/new`` 

61 * ``/orders/XXX`` 

62 * ``/orders/XXX/delete`` 

63 

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

65 various other workflow actions to modify the order. 

66 

67 .. attribute:: order_handler 

68 

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

70 :meth:`~sideshow.app.SideshowAppProvider.get_order_handler()`. 

71 This gets set in the constructor. 

72 

73 .. attribute:: batch_handler 

74 

75 Reference to the :term:`new order batch` handler. This gets 

76 set in the constructor. 

77 """ 

78 model_class = Order 

79 editable = False 

80 configurable = True 

81 

82 labels = { 

83 'order_id': "Order ID", 

84 'store_id': "Store ID", 

85 'customer_id': "Customer ID", 

86 } 

87 

88 grid_columns = [ 

89 'order_id', 

90 'store_id', 

91 'customer_id', 

92 'customer_name', 

93 'total_price', 

94 'created', 

95 'created_by', 

96 ] 

97 

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

99 

100 form_fields = [ 

101 'order_id', 

102 'store_id', 

103 'customer_id', 

104 'local_customer', 

105 'pending_customer', 

106 'customer_name', 

107 'phone_number', 

108 'email_address', 

109 'total_price', 

110 'created', 

111 'created_by', 

112 ] 

113 

114 has_rows = True 

115 row_model_class = OrderItem 

116 rows_title = "Order Items" 

117 rows_sort_defaults = 'sequence' 

118 rows_viewable = True 

119 

120 row_labels = { 

121 'product_scancode': "Scancode", 

122 'product_brand': "Brand", 

123 'product_description': "Description", 

124 'product_size': "Size", 

125 'department_name': "Department", 

126 'order_uom': "Order UOM", 

127 'status_code': "Status", 

128 } 

129 

130 row_grid_columns = [ 

131 'sequence', 

132 'product_scancode', 

133 'product_brand', 

134 'product_description', 

135 'product_size', 

136 'department_name', 

137 'special_order', 

138 'order_qty', 

139 'order_uom', 

140 'discount_percent', 

141 'total_price', 

142 'status_code', 

143 ] 

144 

145 PENDING_PRODUCT_ENTRY_FIELDS = [ 

146 'scancode', 

147 'brand_name', 

148 'description', 

149 'size', 

150 'department_id', 

151 'department_name', 

152 'vendor_name', 

153 'vendor_item_code', 

154 'case_size', 

155 'unit_cost', 

156 'unit_price_reg', 

157 ] 

158 

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

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

161 self.order_handler = self.app.get_order_handler() 

162 self.batch_handler = self.app.get_batch_handler('neworder') 

163 

164 def configure_grid(self, g): 

165 """ """ 

166 super().configure_grid(g) 

167 

168 # store_id 

169 if not self.order_handler.expose_store_id(): 

170 g.remove('store_id') 

171 

172 # order_id 

173 g.set_link('order_id') 

174 

175 # customer_id 

176 g.set_link('customer_id') 

177 

178 # customer_name 

179 g.set_link('customer_name') 

180 

181 # total_price 

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

183 

184 def create(self): 

185 """ 

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

187 of sorts. 

188 

189 Under the hood a 

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

191 automatically created for the user when they first visit this 

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

193 

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

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

196 batch, which in turn creates a true 

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

198 redirected to the "view order" page. 

199 

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

201 based on user actions: 

202 

203 * :meth:`start_over()` 

204 * :meth:`cancel_order()` 

205 * :meth:`set_store()` 

206 * :meth:`assign_customer()` 

207 * :meth:`unassign_customer()` 

208 * :meth:`set_pending_customer()` 

209 * :meth:`get_product_info()` 

210 * :meth:`add_item()` 

211 * :meth:`update_item()` 

212 * :meth:`delete_item()` 

213 * :meth:`submit_order()` 

214 """ 

215 model = self.app.model 

216 enum = self.app.enum 

217 session = self.Session() 

218 batch = self.get_current_batch() 

219 self.creating = True 

220 

221 context = self.get_context_customer(batch) 

222 

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

224 

225 # first we check for traditional form post 

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

227 post_actions = [ 

228 'start_over', 

229 'cancel_order', 

230 ] 

231 if action in post_actions: 

232 return getattr(self, action)(batch) 

233 

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

235 data = dict(self.request.json_body) 

236 action = data.pop('action') 

237 json_actions = [ 

238 'set_store', 

239 'assign_customer', 

240 'unassign_customer', 

241 # 'update_phone_number', 

242 # 'update_email_address', 

243 'set_pending_customer', 

244 # 'get_customer_info', 

245 # # 'set_customer_data', 

246 'get_product_info', 

247 'get_past_products', 

248 'add_item', 

249 'update_item', 

250 'delete_item', 

251 'submit_order', 

252 ] 

253 if action in json_actions: 

254 try: 

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

256 except Exception as error: 

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

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

259 return self.json_response(result) 

260 

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

262 

263 context.update({ 

264 'batch': batch, 

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

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

267 for row in batch.rows], 

268 'default_uom_choices': self.batch_handler.get_default_uom_choices(), 

269 'default_uom': None, # TODO? 

270 'expose_store_id': self.order_handler.expose_store_id(), 

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

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

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

274 'pending_product_required_fields': self.get_pending_product_required_fields(), 

275 'allow_past_item_reorder': True, # TODO: make configurable? 

276 }) 

277 

278 if context['expose_store_id']: 

279 stores = session.query(model.Store)\ 

280 .filter(model.Store.archived == False)\ 

281 .order_by(model.Store.store_id)\ 

282 .all() 

283 context['stores'] = [{'store_id': store.store_id, 'display': store.get_display()} 

284 for store in stores] 

285 

286 # set default so things just work 

287 if not batch.store_id: 

288 batch.store_id = self.batch_handler.get_default_store_id() 

289 

290 if context['allow_item_discounts']: 

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

292 .allow_item_discounts_if_on_sale() 

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

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

295 self.batch_handler.get_default_item_discount()) 

296 context['dept_item_discounts'] = dict([(d['department_id'], d['default_item_discount']) 

297 for d in self.get_dept_item_discounts()]) 

298 

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

300 

301 def get_current_batch(self): 

302 """ 

303 Returns the current batch for the current user. 

304 

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

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

307 created. 

308 

309 :returns: 

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

311 instance 

312 """ 

313 model = self.app.model 

314 session = self.Session() 

315 

316 user = self.request.user 

317 if not user: 

318 raise self.forbidden() 

319 

320 try: 

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

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

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

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

325 .one() 

326 

327 except orm.exc.NoResultFound: 

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

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

330 session.add(batch) 

331 session.flush() 

332 

333 return batch 

334 

335 def customer_autocomplete(self): 

336 """ 

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

338 

339 This invokes one of the following on the 

340 :attr:`batch_handler`: 

341 

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

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

344 

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

346 ``value`` and ``label`` keys. 

347 """ 

348 session = self.Session() 

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

350 if not term: 

351 return [] 

352 

353 handler = self.batch_handler 

354 if handler.use_local_customers(): 

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

356 else: 

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

358 

359 def product_autocomplete(self): 

360 """ 

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

362 

363 This invokes one of the following on the 

364 :attr:`batch_handler`: 

365 

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

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

368 

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

370 ``value`` and ``label`` keys. 

371 """ 

372 session = self.Session() 

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

374 if not term: 

375 return [] 

376 

377 handler = self.batch_handler 

378 if handler.use_local_products(): 

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

380 else: 

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

382 

383 def get_pending_product_required_fields(self): 

384 """ """ 

385 required = [] 

386 for field in self.PENDING_PRODUCT_ENTRY_FIELDS: 

387 require = self.config.get_bool( 

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

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

390 require = True 

391 if require: 

392 required.append(field) 

393 return required 

394 

395 def get_dept_item_discounts(self): 

396 """ 

397 Returns the list of per-department default item discount settings. 

398 

399 Each entry in the list will look like:: 

400 

401 { 

402 'department_id': '42', 

403 'department_name': 'Grocery', 

404 'default_item_discount': 10, 

405 } 

406 

407 :returns: List of department settings as shown above. 

408 """ 

409 model = self.app.model 

410 session = self.Session() 

411 pattern = re.compile(r'^sideshow\.orders\.departments\.([^.]+)\.default_item_discount$') 

412 

413 dept_item_discounts = [] 

414 settings = session.query(model.Setting)\ 

415 .filter(model.Setting.name.like('sideshow.orders.departments.%.default_item_discount'))\ 

416 .all() 

417 for setting in settings: 

418 match = pattern.match(setting.name) 

419 if not match: 

420 log.warning("invalid setting name: %s", setting.name) 

421 continue 

422 deptid = match.group(1) 

423 name = self.app.get_setting(session, f'sideshow.orders.departments.{deptid}.name') 

424 dept_item_discounts.append({ 

425 'department_id': deptid, 

426 'department_name': name, 

427 'default_item_discount': setting.value, 

428 }) 

429 dept_item_discounts.sort(key=lambda d: d['department_name']) 

430 return dept_item_discounts 

431 

432 def start_over(self, batch): 

433 """ 

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

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

436 new batch for them. 

437 

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

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

440 

441 * :meth:`cancel_order()` 

442 * :meth:`submit_order()` 

443 """ 

444 # drop current batch 

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

446 self.Session.flush() 

447 

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

449 route_prefix = self.get_route_prefix() 

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

451 return self.redirect(url) 

452 

453 def cancel_order(self, batch): 

454 """ 

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

456 back to "List Orders" page. 

457 

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

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

460 

461 * :meth:`start_over()` 

462 * :meth:`submit_order()` 

463 """ 

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

465 self.Session.flush() 

466 

467 # set flash msg just to be more obvious 

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

469 

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

471 url = self.get_index_url() 

472 return self.redirect(url) 

473 

474 def set_store(self, batch, data): 

475 """ 

476 Assign the 

477 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id` 

478 for a batch. 

479 

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

481 :meth:`create()`. 

482 """ 

483 store_id = data.get('store_id') 

484 if not store_id: 

485 return {'error': "Must provide store_id"} 

486 

487 batch.store_id = store_id 

488 return self.get_context_customer(batch) 

489 

490 def get_context_customer(self, batch): 

491 """ """ 

492 context = { 

493 'store_id': batch.store_id, 

494 'customer_is_known': True, 

495 'customer_id': None, 

496 'customer_name': batch.customer_name, 

497 'phone_number': batch.phone_number, 

498 'email_address': batch.email_address, 

499 } 

500 

501 # customer_id 

502 use_local = self.batch_handler.use_local_customers() 

503 if use_local: 

504 local = batch.local_customer 

505 if local: 

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

507 else: # use external 

508 context['customer_id'] = batch.customer_id 

509 

510 # pending customer 

511 pending = batch.pending_customer 

512 if pending: 

513 context.update({ 

514 'new_customer_first_name': pending.first_name, 

515 'new_customer_last_name': pending.last_name, 

516 'new_customer_full_name': pending.full_name, 

517 'new_customer_phone': pending.phone_number, 

518 'new_customer_email': pending.email_address, 

519 }) 

520 

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

522 if (pending 

523 and not batch.customer_id and not batch.local_customer 

524 and batch.customer_name): 

525 context['customer_is_known'] = False 

526 

527 return context 

528 

529 def assign_customer(self, batch, data): 

530 """ 

531 Assign the true customer account for a batch. 

532 

533 This calls 

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

535 for the heavy lifting. 

536 

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

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

539 

540 * :meth:`unassign_customer()` 

541 * :meth:`set_pending_customer()` 

542 """ 

543 customer_id = data.get('customer_id') 

544 if not customer_id: 

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

546 

547 self.batch_handler.set_customer(batch, customer_id) 

548 return self.get_context_customer(batch) 

549 

550 def unassign_customer(self, batch, data): 

551 """ 

552 Clear the customer info for a batch. 

553 

554 This calls 

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

556 for the heavy lifting. 

557 

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

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

560 

561 * :meth:`assign_customer()` 

562 * :meth:`set_pending_customer()` 

563 """ 

564 self.batch_handler.set_customer(batch, None) 

565 return self.get_context_customer(batch) 

566 

567 def set_pending_customer(self, batch, data): 

568 """ 

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

570 

571 This calls 

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

573 for the heavy lifting. 

574 

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

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

577 

578 * :meth:`assign_customer()` 

579 * :meth:`unassign_customer()` 

580 """ 

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

582 return self.get_context_customer(batch) 

583 

584 def get_product_info(self, batch, data): 

585 """ 

586 Fetch data for a specific product. 

587 

588 Depending on config, this calls one of the following to get 

589 its primary data: 

590 

591 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_local()` 

592 * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()` 

593 

594 It then may supplement the data with additional fields. 

595 

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

597 :meth:`create()`. 

598 

599 :returns: Dict of product info. 

600 """ 

601 product_id = data.get('product_id') 

602 if not product_id: 

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

604 

605 session = self.Session() 

606 use_local = self.batch_handler.use_local_products() 

607 if use_local: 

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

609 else: 

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

611 

612 if 'error' in data: 

613 return data 

614 

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

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

617 

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

619 data['unit_price_quoted'] = data['unit_price_reg'] 

620 

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

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

623 

624 if 'case_price_quoted' not in data: 

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

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

627 

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

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

630 

631 decimal_fields = [ 

632 'case_size', 

633 'unit_price_reg', 

634 'unit_price_quoted', 

635 'case_price_quoted', 

636 'default_item_discount', 

637 ] 

638 

639 for field in decimal_fields: 

640 if field in list(data): 

641 value = data[field] 

642 if isinstance(value, decimal.Decimal): 

643 data[field] = float(value) 

644 

645 return data 

646 

647 def get_past_products(self, batch, data): 

648 """ 

649 Fetch past products for convenient re-ordering. 

650 

651 This essentially calls 

652 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_past_products()` 

653 on the :attr:`batch_handler` and returns the result. 

654 

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

656 :meth:`create()`. 

657 

658 :returns: List of product info dicts. 

659 """ 

660 past_products = self.batch_handler.get_past_products(batch) 

661 return make_json_safe(past_products) 

662 

663 def add_item(self, batch, data): 

664 """ 

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

666 

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

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

669 

670 * :meth:`update_item()` 

671 * :meth:`delete_item()` 

672 """ 

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

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

675 kw['discount_percent'] = data['discount_percent'] 

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

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

678 

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

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

681 

682 def update_item(self, batch, data): 

683 """ 

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

685 

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

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

688 

689 * :meth:`add_item()` 

690 * :meth:`delete_item()` 

691 """ 

692 model = self.app.model 

693 session = self.Session() 

694 

695 uuid = data.get('uuid') 

696 if not uuid: 

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

698 

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

700 if not row: 

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

702 

703 if row.batch is not batch: 

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

705 

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

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

708 kw['discount_percent'] = data['discount_percent'] 

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

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

711 

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

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

714 

715 def delete_item(self, batch, data): 

716 """ 

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

718 

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

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

721 

722 * :meth:`add_item()` 

723 * :meth:`update_item()` 

724 """ 

725 model = self.app.model 

726 session = self.app.get_session(batch) 

727 

728 uuid = data.get('uuid') 

729 if not uuid: 

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

731 

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

733 if not row: 

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

735 

736 if row.batch is not batch: 

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

738 

739 self.batch_handler.do_remove_row(row) 

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

741 

742 def submit_order(self, batch, data): 

743 """ 

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

745 executing the batch and creating the true order. 

746 

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

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

749 

750 * :meth:`start_over()` 

751 * :meth:`cancel_order()` 

752 """ 

753 user = self.request.user 

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

755 if reason: 

756 return {'error': reason} 

757 

758 try: 

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

760 except Exception as error: 

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

762 exc_info=True) 

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

764 

765 return { 

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

767 } 

768 

769 def normalize_batch(self, batch): 

770 """ """ 

771 return { 

772 'uuid': batch.uuid.hex, 

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

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

775 'status_code': batch.status_code, 

776 'status_text': batch.status_text, 

777 } 

778 

779 def normalize_row(self, row): 

780 """ """ 

781 data = { 

782 'uuid': row.uuid.hex, 

783 'sequence': row.sequence, 

784 'product_id': None, 

785 'product_scancode': row.product_scancode, 

786 'product_brand': row.product_brand, 

787 'product_description': row.product_description, 

788 'product_size': row.product_size, 

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

790 row.product_description, 

791 row.product_size), 

792 'product_weighed': row.product_weighed, 

793 'department_id': row.department_id, 

794 'department_name': row.department_name, 

795 'special_order': row.special_order, 

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

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

798 'order_uom': row.order_uom, 

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

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

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

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

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

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

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

806 'status_code': row.status_code, 

807 'status_text': row.status_text, 

808 } 

809 

810 use_local = self.batch_handler.use_local_products() 

811 

812 # product_id 

813 if use_local: 

814 if row.local_product: 

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

816 else: 

817 data['product_id'] = row.product_id 

818 

819 # vendor_name 

820 if use_local: 

821 if row.local_product: 

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

823 else: # use external 

824 pass # TODO 

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

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

827 

828 if row.unit_price_reg: 

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

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

831 

832 if row.unit_price_sale: 

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

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

835 if row.sale_ends: 

836 sale_ends = row.sale_ends 

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

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

839 

840 if row.pending_product: 

841 pending = row.pending_product 

842 data['pending_product'] = { 

843 'uuid': pending.uuid.hex, 

844 'scancode': pending.scancode, 

845 'brand_name': pending.brand_name, 

846 'description': pending.description, 

847 'size': pending.size, 

848 'department_id': pending.department_id, 

849 'department_name': pending.department_name, 

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

851 'vendor_name': pending.vendor_name, 

852 'vendor_item_code': pending.vendor_item_code, 

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

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

855 'notes': pending.notes, 

856 'special_order': pending.special_order, 

857 } 

858 

859 # display text for order qty/uom 

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

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

862 

863 return data 

864 

865 def get_instance_title(self, order): 

866 """ """ 

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

868 

869 def configure_form(self, f): 

870 """ """ 

871 super().configure_form(f) 

872 order = f.model_instance 

873 

874 # store_id 

875 if not self.order_handler.expose_store_id(): 

876 f.remove('store_id') 

877 

878 # local_customer 

879 if order.customer_id and not order.local_customer: 

880 f.remove('local_customer') 

881 else: 

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

883 

884 # pending_customer 

885 if order.customer_id or order.local_customer: 

886 f.remove('pending_customer') 

887 else: 

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

889 

890 # total_price 

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

892 

893 # created_by 

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

895 f.set_readonly('created_by') 

896 

897 def get_xref_buttons(self, order): 

898 """ """ 

899 buttons = super().get_xref_buttons(order) 

900 model = self.app.model 

901 session = self.Session() 

902 

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

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

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

906 .first() 

907 if batch: 

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

909 buttons.append( 

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

911 

912 return buttons 

913 

914 def get_row_grid_data(self, order): 

915 """ """ 

916 model = self.app.model 

917 session = self.Session() 

918 return session.query(model.OrderItem)\ 

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

920 

921 def configure_row_grid(self, g): 

922 """ """ 

923 super().configure_row_grid(g) 

924 # enum = self.app.enum 

925 

926 # sequence 

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

928 g.set_link('sequence') 

929 

930 # product_scancode 

931 g.set_link('product_scancode') 

932 

933 # product_brand 

934 g.set_link('product_brand') 

935 

936 # product_description 

937 g.set_link('product_description') 

938 

939 # product_size 

940 g.set_link('product_size') 

941 

942 # TODO 

943 # order_uom 

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

945 

946 # discount_percent 

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

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

949 

950 # total_price 

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

952 

953 # status_code 

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

955 

956 # TODO: upstream should set this automatically 

957 g.row_class = self.row_grid_row_class 

958 

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

960 """ """ 

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

962 if variant: 

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

964 

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

966 """ """ 

967 enum = self.app.enum 

968 return enum.ORDER_ITEM_STATUS[value] 

969 

970 def get_row_action_url_view(self, item, i): 

971 """ """ 

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

973 

974 def configure_get_simple_settings(self): 

975 """ """ 

976 settings = [ 

977 

978 # stores 

979 {'name': 'sideshow.orders.expose_store_id', 

980 'type': bool}, 

981 {'name': 'sideshow.orders.default_store_id'}, 

982 

983 # customers 

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

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

986 #'type': bool, 

987 'default': 'true'}, 

988 

989 # products 

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

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

992 #'type': bool, 

993 'default': 'true'}, 

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

995 'type': bool, 

996 'default': True}, 

997 

998 # pricing 

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

1000 'type': bool}, 

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

1002 'type': bool}, 

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

1004 'type': float}, 

1005 

1006 # batches 

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

1008 ] 

1009 

1010 # required fields for new product entry 

1011 for field in self.PENDING_PRODUCT_ENTRY_FIELDS: 

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

1013 'type': bool} 

1014 if field == 'description': 

1015 setting['default'] = True 

1016 settings.append(setting) 

1017 

1018 return settings 

1019 

1020 def configure_get_context(self, **kwargs): 

1021 """ """ 

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

1023 

1024 context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS 

1025 

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

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

1028 context['batch_handlers'] = handlers 

1029 

1030 context['dept_item_discounts'] = self.get_dept_item_discounts() 

1031 

1032 return context 

1033 

1034 def configure_gather_settings(self, data, simple_settings=None): 

1035 """ """ 

1036 settings = super().configure_gather_settings(data, simple_settings=simple_settings) 

1037 

1038 for dept in json.loads(data['dept_item_discounts']): 

1039 deptid = dept['department_id'] 

1040 settings.append({'name': f'sideshow.orders.departments.{deptid}.name', 

1041 'value': dept['department_name']}) 

1042 settings.append({'name': f'sideshow.orders.departments.{deptid}.default_item_discount', 

1043 'value': dept['default_item_discount']}) 

1044 

1045 return settings 

1046 

1047 def configure_remove_settings(self, **kwargs): 

1048 """ """ 

1049 model = self.app.model 

1050 session = self.Session() 

1051 

1052 super().configure_remove_settings(**kwargs) 

1053 

1054 to_delete = session.query(model.Setting)\ 

1055 .filter(sa.or_( 

1056 model.Setting.name.like('sideshow.orders.departments.%.name'), 

1057 model.Setting.name.like('sideshow.orders.departments.%.default_item_discount')))\ 

1058 .all() 

1059 for setting in to_delete: 

1060 self.app.delete_setting(session, setting.name) 

1061 

1062 

1063 @classmethod 

1064 def defaults(cls, config): 

1065 cls._order_defaults(config) 

1066 cls._defaults(config) 

1067 

1068 @classmethod 

1069 def _order_defaults(cls, config): 

1070 route_prefix = cls.get_route_prefix() 

1071 permission_prefix = cls.get_permission_prefix() 

1072 url_prefix = cls.get_url_prefix() 

1073 model_title = cls.get_model_title() 

1074 model_title_plural = cls.get_model_title_plural() 

1075 

1076 # fix perm group 

1077 config.add_wutta_permission_group(permission_prefix, 

1078 model_title_plural, 

1079 overwrite=False) 

1080 

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

1082 config.add_wutta_permission(permission_prefix, 

1083 f'{permission_prefix}.create_unknown_product', 

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

1085 

1086 # customer autocomplete 

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

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

1089 request_method='GET') 

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

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

1092 renderer='json', 

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

1094 

1095 # product autocomplete 

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

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

1098 request_method='GET') 

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

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

1101 renderer='json', 

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

1103 

1104 

1105class OrderItemView(MasterView): 

1106 """ 

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

1108 route prefix is ``order_items``. 

1109 

1110 Notable URLs provided by this class: 

1111 

1112 * ``/order-items/`` 

1113 * ``/order-items/XXX`` 

1114 

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

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

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

1118 

1119 * :class:`PlacementView` 

1120 * :class:`ReceivingView` 

1121 * :class:`ContactView` 

1122 * :class:`DeliveryView` 

1123 

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

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

1126 

1127 .. attribute:: order_handler 

1128 

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

1130 :meth:`get_order_handler()`. 

1131 """ 

1132 model_class = OrderItem 

1133 model_title = "Order Item (All)" 

1134 model_title_plural = "Order Items (All)" 

1135 route_prefix = 'order_items' 

1136 url_prefix = '/order-items' 

1137 creatable = False 

1138 editable = False 

1139 deletable = False 

1140 

1141 labels = { 

1142 'order_id': "Order ID", 

1143 'store_id': "Store ID", 

1144 'product_id': "Product ID", 

1145 'product_scancode': "Scancode", 

1146 'product_brand': "Brand", 

1147 'product_description': "Description", 

1148 'product_size': "Size", 

1149 'product_weighed': "Sold by Weight", 

1150 'department_id': "Department ID", 

1151 'order_uom': "Order UOM", 

1152 'status_code': "Status", 

1153 } 

1154 

1155 grid_columns = [ 

1156 'order_id', 

1157 'store_id', 

1158 'customer_name', 

1159 # 'sequence', 

1160 'product_scancode', 

1161 'product_brand', 

1162 'product_description', 

1163 'product_size', 

1164 'department_name', 

1165 'special_order', 

1166 'order_qty', 

1167 'order_uom', 

1168 'total_price', 

1169 'status_code', 

1170 ] 

1171 

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

1173 

1174 form_fields = [ 

1175 'order', 

1176 # 'customer_name', 

1177 'sequence', 

1178 'product_id', 

1179 'local_product', 

1180 'pending_product', 

1181 'product_scancode', 

1182 'product_brand', 

1183 'product_description', 

1184 'product_size', 

1185 'product_weighed', 

1186 'department_id', 

1187 'department_name', 

1188 'special_order', 

1189 'case_size', 

1190 'unit_cost', 

1191 'unit_price_reg', 

1192 'unit_price_sale', 

1193 'sale_ends', 

1194 'unit_price_quoted', 

1195 'case_price_quoted', 

1196 'order_qty', 

1197 'order_uom', 

1198 'discount_percent', 

1199 'total_price', 

1200 'status_code', 

1201 'paid_amount', 

1202 'payment_transaction_number', 

1203 ] 

1204 

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

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

1207 self.order_handler = self.app.get_order_handler() 

1208 

1209 def get_fallback_templates(self, template): 

1210 """ """ 

1211 templates = super().get_fallback_templates(template) 

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

1213 return templates 

1214 

1215 def get_query(self, session=None): 

1216 """ """ 

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

1218 model = self.app.model 

1219 return query.join(model.Order) 

1220 

1221 def configure_grid(self, g): 

1222 """ """ 

1223 super().configure_grid(g) 

1224 model = self.app.model 

1225 # enum = self.app.enum 

1226 

1227 # store_id 

1228 if not self.order_handler.expose_store_id(): 

1229 g.remove('store_id') 

1230 

1231 # order_id 

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

1233 g.set_renderer('order_id', self.render_order_attr) 

1234 g.set_link('order_id') 

1235 

1236 # store_id 

1237 g.set_sorter('store_id', model.Order.store_id) 

1238 g.set_renderer('store_id', self.render_order_attr) 

1239 

1240 # customer_name 

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

1242 g.set_renderer('customer_name', self.render_order_attr) 

1243 g.set_sorter('customer_name', model.Order.customer_name) 

1244 g.set_filter('customer_name', model.Order.customer_name) 

1245 

1246 # # sequence 

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

1248 

1249 # product_scancode 

1250 g.set_link('product_scancode') 

1251 

1252 # product_brand 

1253 g.set_link('product_brand') 

1254 

1255 # product_description 

1256 g.set_link('product_description') 

1257 

1258 # product_size 

1259 g.set_link('product_size') 

1260 

1261 # order_uom 

1262 # TODO 

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

1264 

1265 # total_price 

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

1267 

1268 # status_code 

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

1270 

1271 def render_order_attr(self, item, key, value): 

1272 """ """ 

1273 order = item.order 

1274 return getattr(order, key) 

1275 

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

1277 """ """ 

1278 enum = self.app.enum 

1279 return enum.ORDER_ITEM_STATUS[value] 

1280 

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

1282 """ """ 

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

1284 if variant: 

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

1286 

1287 def configure_form(self, f): 

1288 """ """ 

1289 super().configure_form(f) 

1290 enum = self.app.enum 

1291 item = f.model_instance 

1292 

1293 # order 

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

1295 

1296 # local_product 

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

1298 

1299 # pending_product 

1300 if item.product_id or item.local_product: 

1301 f.remove('pending_product') 

1302 else: 

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

1304 

1305 # order_qty 

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

1307 

1308 # order_uom 

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

1310 

1311 # case_size 

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

1313 

1314 # unit_cost 

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

1316 

1317 # unit_price_reg 

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

1319 

1320 # unit_price_quoted 

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

1322 

1323 # case_price_quoted 

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

1325 

1326 # total_price 

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

1328 

1329 # status 

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

1331 

1332 # paid_amount 

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

1334 

1335 def get_template_context(self, context): 

1336 """ """ 

1337 if self.viewing: 

1338 model = self.app.model 

1339 enum = self.app.enum 

1340 route_prefix = self.get_route_prefix() 

1341 item = context['instance'] 

1342 form = context['form'] 

1343 

1344 context['expose_store_id'] = self.order_handler.expose_store_id() 

1345 

1346 context['item'] = item 

1347 context['order'] = item.order 

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

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

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

1351 

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

1353 model_class=model.OrderItemEvent, 

1354 data=item.events, 

1355 columns=[ 

1356 'occurred', 

1357 'actor', 

1358 'type_code', 

1359 'note', 

1360 ], 

1361 labels={ 

1362 'occurred': "Date/Time", 

1363 'actor': "User", 

1364 'type_code': "Event Type", 

1365 }) 

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

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

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

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

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

1371 form.add_grid_vue_context(grid) 

1372 context['events_grid'] = grid 

1373 

1374 return context 

1375 

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

1377 """ """ 

1378 enum = self.app.enum 

1379 if event.type_code == enum.ORDER_ITEM_EVENT_NOTE_ADDED: 

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

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

1382 c=[value]) 

1383 return value 

1384 

1385 def get_xref_buttons(self, item): 

1386 """ """ 

1387 buttons = super().get_xref_buttons(item) 

1388 

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

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

1391 buttons.append( 

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

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

1394 

1395 return buttons 

1396 

1397 def add_note(self): 

1398 """ 

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

1400 will redirect back to the item view. 

1401 """ 

1402 enum = self.app.enum 

1403 item = self.get_instance() 

1404 

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

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

1407 

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

1409 

1410 def change_status(self): 

1411 """ 

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

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

1414 """ 

1415 model = self.app.model 

1416 enum = self.app.enum 

1417 main_item = self.get_instance() 

1418 session = self.Session() 

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

1420 

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

1422 

1423 # validate new status 

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

1425 if new_status_code not in enum.ORDER_ITEM_STATUS: 

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

1427 return redirect 

1428 new_status_text = enum.ORDER_ITEM_STATUS[new_status_code] 

1429 

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

1431 items = [main_item] 

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

1433 # if uuids: 

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

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

1436 # if item: 

1437 # items.append(item) 

1438 

1439 # update item(s) 

1440 for item in items: 

1441 if item.status_code != new_status_code: 

1442 

1443 # event: change status 

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

1445 enum.ORDER_ITEM_STATUS[item.status_code], 

1446 new_status_text) 

1447 item.add_event(enum.ORDER_ITEM_EVENT_STATUS_CHANGE, 

1448 self.request.user, note=note) 

1449 

1450 # event: add note 

1451 if extra_note: 

1452 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, 

1453 self.request.user, note=extra_note) 

1454 

1455 # new status 

1456 item.status_code = new_status_code 

1457 

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

1459 return redirect 

1460 

1461 def get_order_items(self, uuids): 

1462 """ 

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

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

1465 workflow action methods. 

1466 

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

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

1469 

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

1471 

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

1473 records. 

1474 """ 

1475 model = self.app.model 

1476 session = self.Session() 

1477 

1478 if uuids is None: 

1479 uuids = [] 

1480 elif isinstance(uuids, str): 

1481 uuids = uuids.split(',') 

1482 

1483 items = [] 

1484 for uuid in uuids: 

1485 if isinstance(uuid, str): 

1486 uuid = uuid.strip() 

1487 if uuid: 

1488 try: 

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

1490 except sa.exc.StatementError: 

1491 pass # nb. invalid UUID 

1492 else: 

1493 if item: 

1494 items.append(item) 

1495 

1496 if not items: 

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

1498 raise self.redirect(self.get_index_url()) 

1499 

1500 return items 

1501 

1502 @classmethod 

1503 def defaults(cls, config): 

1504 """ """ 

1505 cls._order_item_defaults(config) 

1506 cls._defaults(config) 

1507 

1508 @classmethod 

1509 def _order_item_defaults(cls, config): 

1510 """ """ 

1511 route_prefix = cls.get_route_prefix() 

1512 permission_prefix = cls.get_permission_prefix() 

1513 instance_url_prefix = cls.get_instance_url_prefix() 

1514 model_title = cls.get_model_title() 

1515 model_title_plural = cls.get_model_title_plural() 

1516 

1517 # fix perm group 

1518 config.add_wutta_permission_group(permission_prefix, 

1519 model_title_plural, 

1520 overwrite=False) 

1521 

1522 # add note 

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

1524 f'{instance_url_prefix}/add_note', 

1525 request_method='POST') 

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

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

1528 renderer='json', 

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

1530 config.add_wutta_permission(permission_prefix, 

1531 f'{permission_prefix}.add_note', 

1532 f"Add note for {model_title}") 

1533 

1534 # change status 

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

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

1537 request_method='POST') 

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

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

1540 renderer='json', 

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

1542 config.add_wutta_permission(permission_prefix, 

1543 f'{permission_prefix}.change_status', 

1544 f"Change status for {model_title}") 

1545 

1546 

1547class PlacementView(OrderItemView): 

1548 """ 

1549 Master view for the "placement" phase of 

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

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

1552 

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

1554 status codes are shown: 

1555 

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

1557 

1558 Notable URLs provided by this class: 

1559 

1560 * ``/placement/`` 

1561 * ``/placement/XXX`` 

1562 """ 

1563 model_title = "Order Item (Placement)" 

1564 model_title_plural = "Order Items (Placement)" 

1565 route_prefix = 'order_items_placement' 

1566 url_prefix = '/placement' 

1567 

1568 grid_columns = [ 

1569 'order_id', 

1570 'store_id', 

1571 'customer_name', 

1572 'product_brand', 

1573 'product_description', 

1574 'product_size', 

1575 'department_name', 

1576 'special_order', 

1577 'vendor_name', 

1578 'vendor_item_code', 

1579 'order_qty', 

1580 'order_uom', 

1581 'total_price', 

1582 ] 

1583 

1584 filter_defaults = { 

1585 'vendor_name': {'active': True}, 

1586 } 

1587 

1588 def get_query(self, session=None): 

1589 """ """ 

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

1591 model = self.app.model 

1592 enum = self.app.enum 

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

1594 

1595 def configure_grid(self, g): 

1596 """ """ 

1597 super().configure_grid(g) 

1598 

1599 # checkable 

1600 if self.has_perm('process_placement'): 

1601 g.checkable = True 

1602 

1603 # tool button: Order Placed 

1604 if self.has_perm('process_placement'): 

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

1606 icon_left='arrow-circle-right', 

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

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

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

1610 

1611 def process_placement(self): 

1612 """ 

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

1614 

1615 This requires a POST request with data: 

1616 

1617 :param item_uuids: Comma-delimited list of 

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

1619 

1620 :param vendor_name: Optional name of vendor. 

1621 

1622 :param po_number: Optional PO number. 

1623 

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

1625 

1626 This invokes 

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

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

1629 back to the index page. 

1630 """ 

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

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

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

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

1635 

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

1637 vendor_name=vendor_name, 

1638 po_number=po_number, 

1639 note=note) 

1640 

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

1642 return self.redirect(self.get_index_url()) 

1643 

1644 @classmethod 

1645 def defaults(cls, config): 

1646 cls._order_item_defaults(config) 

1647 cls._placement_defaults(config) 

1648 cls._defaults(config) 

1649 

1650 @classmethod 

1651 def _placement_defaults(cls, config): 

1652 route_prefix = cls.get_route_prefix() 

1653 permission_prefix = cls.get_permission_prefix() 

1654 url_prefix = cls.get_url_prefix() 

1655 model_title_plural = cls.get_model_title_plural() 

1656 

1657 # process placement 

1658 config.add_wutta_permission(permission_prefix, 

1659 f'{permission_prefix}.process_placement', 

1660 f"Process placement for {model_title_plural}") 

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

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

1663 request_method='POST') 

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

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

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

1667 

1668 

1669class ReceivingView(OrderItemView): 

1670 """ 

1671 Master view for the "receiving" phase of 

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

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

1674 

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

1676 status codes are shown: 

1677 

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

1679 

1680 Notable URLs provided by this class: 

1681 

1682 * ``/receiving/`` 

1683 * ``/receiving/XXX`` 

1684 """ 

1685 model_title = "Order Item (Receiving)" 

1686 model_title_plural = "Order Items (Receiving)" 

1687 route_prefix = 'order_items_receiving' 

1688 url_prefix = '/receiving' 

1689 

1690 grid_columns = [ 

1691 'order_id', 

1692 'store_id', 

1693 'customer_name', 

1694 'product_brand', 

1695 'product_description', 

1696 'product_size', 

1697 'department_name', 

1698 'special_order', 

1699 'vendor_name', 

1700 'vendor_item_code', 

1701 'order_qty', 

1702 'order_uom', 

1703 'total_price', 

1704 ] 

1705 

1706 filter_defaults = { 

1707 'vendor_name': {'active': True}, 

1708 } 

1709 

1710 def get_query(self, session=None): 

1711 """ """ 

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

1713 model = self.app.model 

1714 enum = self.app.enum 

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

1716 

1717 def configure_grid(self, g): 

1718 """ """ 

1719 super().configure_grid(g) 

1720 

1721 # checkable 

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

1723 g.checkable = True 

1724 

1725 # tool button: Received 

1726 if self.has_perm('process_receiving'): 

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

1728 icon_left='arrow-circle-right', 

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

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

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

1732 

1733 # tool button: Re-Order 

1734 if self.has_perm('process_reorder'): 

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

1736 icon_left='redo', 

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

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

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

1740 

1741 def process_receiving(self): 

1742 """ 

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

1744 

1745 This requires a POST request with data: 

1746 

1747 :param item_uuids: Comma-delimited list of 

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

1749 

1750 :param vendor_name: Optional name of vendor. 

1751 

1752 :param invoice_number: Optional invoice number. 

1753 

1754 :param po_number: Optional PO number. 

1755 

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

1757 

1758 This invokes 

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

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

1761 back to the index page. 

1762 """ 

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

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

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

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

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

1768 

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

1770 vendor_name=vendor_name, 

1771 invoice_number=invoice_number, 

1772 po_number=po_number, 

1773 note=note) 

1774 

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

1776 return self.redirect(self.get_index_url()) 

1777 

1778 def process_reorder(self): 

1779 """ 

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

1781 

1782 This requires a POST request with data: 

1783 

1784 :param item_uuids: Comma-delimited list of 

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

1786 

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

1788 

1789 This invokes 

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

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

1792 to the index page. 

1793 """ 

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

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

1796 

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

1798 

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

1800 return self.redirect(self.get_index_url()) 

1801 

1802 @classmethod 

1803 def defaults(cls, config): 

1804 cls._order_item_defaults(config) 

1805 cls._receiving_defaults(config) 

1806 cls._defaults(config) 

1807 

1808 @classmethod 

1809 def _receiving_defaults(cls, config): 

1810 route_prefix = cls.get_route_prefix() 

1811 permission_prefix = cls.get_permission_prefix() 

1812 url_prefix = cls.get_url_prefix() 

1813 model_title_plural = cls.get_model_title_plural() 

1814 

1815 # process receiving 

1816 config.add_wutta_permission(permission_prefix, 

1817 f'{permission_prefix}.process_receiving', 

1818 f"Process receiving for {model_title_plural}") 

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

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

1821 request_method='POST') 

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

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

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

1825 

1826 # process reorder 

1827 config.add_wutta_permission(permission_prefix, 

1828 f'{permission_prefix}.process_reorder', 

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

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

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

1832 request_method='POST') 

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

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

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

1836 

1837 

1838class ContactView(OrderItemView): 

1839 """ 

1840 Master view for the "contact" phase of 

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

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

1843 

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

1845 status codes are shown: 

1846 

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

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

1849 

1850 Notable URLs provided by this class: 

1851 

1852 * ``/contact/`` 

1853 * ``/contact/XXX`` 

1854 """ 

1855 model_title = "Order Item (Contact)" 

1856 model_title_plural = "Order Items (Contact)" 

1857 route_prefix = 'order_items_contact' 

1858 url_prefix = '/contact' 

1859 

1860 def get_query(self, session=None): 

1861 """ """ 

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

1863 model = self.app.model 

1864 enum = self.app.enum 

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

1866 enum.ORDER_ITEM_STATUS_RECEIVED, 

1867 enum.ORDER_ITEM_STATUS_CONTACT_FAILED))) 

1868 

1869 def configure_grid(self, g): 

1870 """ """ 

1871 super().configure_grid(g) 

1872 

1873 # checkable 

1874 if self.has_perm('process_contact'): 

1875 g.checkable = True 

1876 

1877 # tool button: Contact Success 

1878 if self.has_perm('process_contact'): 

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

1880 icon_left='phone', 

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

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

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

1884 

1885 # tool button: Contact Failure 

1886 if self.has_perm('process_contact'): 

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

1888 icon_left='phone', 

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

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

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

1892 

1893 def process_contact_success(self): 

1894 """ 

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

1896 item(s). 

1897 

1898 This requires a POST request with data: 

1899 

1900 :param item_uuids: Comma-delimited list of 

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

1902 

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

1904 

1905 This invokes 

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

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

1908 user back to the index page. 

1909 """ 

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

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

1912 

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

1914 

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

1916 return self.redirect(self.get_index_url()) 

1917 

1918 def process_contact_failure(self): 

1919 """ 

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

1921 item(s). 

1922 

1923 This requires a POST request with data: 

1924 

1925 :param item_uuids: Comma-delimited list of 

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

1927 

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

1929 

1930 This invokes 

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

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

1933 user back to the index page. 

1934 """ 

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

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

1937 

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

1939 

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

1941 return self.redirect(self.get_index_url()) 

1942 

1943 @classmethod 

1944 def defaults(cls, config): 

1945 cls._order_item_defaults(config) 

1946 cls._contact_defaults(config) 

1947 cls._defaults(config) 

1948 

1949 @classmethod 

1950 def _contact_defaults(cls, config): 

1951 route_prefix = cls.get_route_prefix() 

1952 permission_prefix = cls.get_permission_prefix() 

1953 url_prefix = cls.get_url_prefix() 

1954 model_title_plural = cls.get_model_title_plural() 

1955 

1956 # common perm for processing contact success + failure 

1957 config.add_wutta_permission(permission_prefix, 

1958 f'{permission_prefix}.process_contact', 

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

1960 

1961 # process contact success 

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

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

1964 request_method='POST') 

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

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

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

1968 

1969 # process contact failure 

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

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

1972 request_method='POST') 

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

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

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

1976 

1977 

1978class DeliveryView(OrderItemView): 

1979 """ 

1980 Master view for the "delivery" phase of 

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

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

1983 

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

1985 status codes are shown: 

1986 

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

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

1989 

1990 Notable URLs provided by this class: 

1991 

1992 * ``/delivery/`` 

1993 * ``/delivery/XXX`` 

1994 """ 

1995 model_title = "Order Item (Delivery)" 

1996 model_title_plural = "Order Items (Delivery)" 

1997 route_prefix = 'order_items_delivery' 

1998 url_prefix = '/delivery' 

1999 

2000 def get_query(self, session=None): 

2001 """ """ 

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

2003 model = self.app.model 

2004 enum = self.app.enum 

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

2006 enum.ORDER_ITEM_STATUS_RECEIVED, 

2007 enum.ORDER_ITEM_STATUS_CONTACTED))) 

2008 

2009 def configure_grid(self, g): 

2010 """ """ 

2011 super().configure_grid(g) 

2012 

2013 # checkable 

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

2015 g.checkable = True 

2016 

2017 # tool button: Delivered 

2018 if self.has_perm('process_delivery'): 

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

2020 icon_left='check', 

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

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

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

2024 

2025 # tool button: Restocked 

2026 if self.has_perm('process_restock'): 

2027 button = self.make_button("Restocked", 

2028 icon_left='redo', 

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

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

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

2032 

2033 def process_delivery(self): 

2034 """ 

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

2036 

2037 This requires a POST request with data: 

2038 

2039 :param item_uuids: Comma-delimited list of 

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

2041 

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

2043 

2044 This invokes 

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

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

2047 back to the index page. 

2048 """ 

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

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

2051 

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

2053 

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

2055 return self.redirect(self.get_index_url()) 

2056 

2057 def process_restock(self): 

2058 """ 

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

2060 

2061 This requires a POST request with data: 

2062 

2063 :param item_uuids: Comma-delimited list of 

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

2065 

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

2067 

2068 This invokes 

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

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

2071 to the index page. 

2072 """ 

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

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

2075 

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

2077 

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

2079 return self.redirect(self.get_index_url()) 

2080 

2081 @classmethod 

2082 def defaults(cls, config): 

2083 cls._order_item_defaults(config) 

2084 cls._delivery_defaults(config) 

2085 cls._defaults(config) 

2086 

2087 @classmethod 

2088 def _delivery_defaults(cls, config): 

2089 route_prefix = cls.get_route_prefix() 

2090 permission_prefix = cls.get_permission_prefix() 

2091 url_prefix = cls.get_url_prefix() 

2092 model_title_plural = cls.get_model_title_plural() 

2093 

2094 # process delivery 

2095 config.add_wutta_permission(permission_prefix, 

2096 f'{permission_prefix}.process_delivery', 

2097 f"Process delivery for {model_title_plural}") 

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

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

2100 request_method='POST') 

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

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

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

2104 

2105 # process restock 

2106 config.add_wutta_permission(permission_prefix, 

2107 f'{permission_prefix}.process_restock', 

2108 f"Process restock for {model_title_plural}") 

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

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

2111 request_method='POST') 

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

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

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

2115 

2116 

2117def defaults(config, **kwargs): 

2118 base = globals() 

2119 

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

2121 OrderView.defaults(config) 

2122 

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

2124 OrderItemView.defaults(config) 

2125 

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

2127 PlacementView.defaults(config) 

2128 

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

2130 ReceivingView.defaults(config) 

2131 

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

2133 ContactView.defaults(config) 

2134 

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

2136 DeliveryView.defaults(config) 

2137 

2138 

2139def includeme(config): 

2140 defaults(config)