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

763 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-16 07:16 -0500

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 'vendor_name': row.vendor_name, 

797 'vendor_item_code': row.vendor_item_code, 

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

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

800 'order_uom': row.order_uom, 

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

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

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

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

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

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

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

808 'status_code': row.status_code, 

809 'status_text': row.status_text, 

810 } 

811 

812 use_local = self.batch_handler.use_local_products() 

813 

814 # product_id 

815 if use_local: 

816 if row.local_product: 

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

818 else: 

819 data['product_id'] = row.product_id 

820 

821 # vendor_name 

822 if use_local: 

823 if row.local_product: 

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

825 else: # use external 

826 pass # TODO 

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

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

829 

830 if row.unit_price_reg: 

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

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

833 

834 if row.unit_price_sale: 

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

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

837 if row.sale_ends: 

838 sale_ends = row.sale_ends 

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

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

841 

842 if row.pending_product: 

843 pending = row.pending_product 

844 data['pending_product'] = { 

845 'uuid': pending.uuid.hex, 

846 'scancode': pending.scancode, 

847 'brand_name': pending.brand_name, 

848 'description': pending.description, 

849 'size': pending.size, 

850 'department_id': pending.department_id, 

851 'department_name': pending.department_name, 

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

853 'vendor_name': pending.vendor_name, 

854 'vendor_item_code': pending.vendor_item_code, 

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

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

857 'notes': pending.notes, 

858 'special_order': pending.special_order, 

859 } 

860 

861 # display text for order qty/uom 

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

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

864 

865 return data 

866 

867 def get_instance_title(self, order): 

868 """ """ 

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

870 

871 def configure_form(self, f): 

872 """ """ 

873 super().configure_form(f) 

874 order = f.model_instance 

875 

876 # store_id 

877 if not self.order_handler.expose_store_id(): 

878 f.remove('store_id') 

879 

880 # local_customer 

881 if order.customer_id and not order.local_customer: 

882 f.remove('local_customer') 

883 else: 

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

885 

886 # pending_customer 

887 if order.customer_id or order.local_customer: 

888 f.remove('pending_customer') 

889 else: 

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

891 

892 # total_price 

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

894 

895 # created_by 

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

897 f.set_readonly('created_by') 

898 

899 def get_xref_buttons(self, order): 

900 """ """ 

901 buttons = super().get_xref_buttons(order) 

902 model = self.app.model 

903 session = self.Session() 

904 

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

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

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

908 .first() 

909 if batch: 

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

911 buttons.append( 

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

913 

914 return buttons 

915 

916 def get_row_grid_data(self, order): 

917 """ """ 

918 model = self.app.model 

919 session = self.Session() 

920 return session.query(model.OrderItem)\ 

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

922 

923 def configure_row_grid(self, g): 

924 """ """ 

925 super().configure_row_grid(g) 

926 # enum = self.app.enum 

927 

928 # sequence 

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

930 g.set_link('sequence') 

931 

932 # product_scancode 

933 g.set_link('product_scancode') 

934 

935 # product_brand 

936 g.set_link('product_brand') 

937 

938 # product_description 

939 g.set_link('product_description') 

940 

941 # product_size 

942 g.set_link('product_size') 

943 

944 # TODO 

945 # order_uom 

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

947 

948 # discount_percent 

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

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

951 

952 # total_price 

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

954 

955 # status_code 

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

957 

958 # TODO: upstream should set this automatically 

959 g.row_class = self.row_grid_row_class 

960 

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

962 """ """ 

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

964 if variant: 

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

966 

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

968 """ """ 

969 enum = self.app.enum 

970 return enum.ORDER_ITEM_STATUS[value] 

971 

972 def get_row_action_url_view(self, item, i): 

973 """ """ 

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

975 

976 def configure_get_simple_settings(self): 

977 """ """ 

978 settings = [ 

979 

980 # stores 

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

982 'type': bool}, 

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

984 

985 # customers 

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

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

988 #'type': bool, 

989 'default': 'true'}, 

990 

991 # products 

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

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

994 #'type': bool, 

995 'default': 'true'}, 

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

997 'type': bool, 

998 'default': True}, 

999 

1000 # pricing 

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

1002 'type': bool}, 

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

1004 'type': bool}, 

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

1006 'type': float}, 

1007 

1008 # batches 

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

1010 ] 

1011 

1012 # required fields for new product entry 

1013 for field in self.PENDING_PRODUCT_ENTRY_FIELDS: 

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

1015 'type': bool} 

1016 if field == 'description': 

1017 setting['default'] = True 

1018 settings.append(setting) 

1019 

1020 return settings 

1021 

1022 def configure_get_context(self, **kwargs): 

1023 """ """ 

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

1025 

1026 context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS 

1027 

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

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

1030 context['batch_handlers'] = handlers 

1031 

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

1033 

1034 return context 

1035 

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

1037 """ """ 

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

1039 

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

1041 deptid = dept['department_id'] 

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

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

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

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

1046 

1047 return settings 

1048 

1049 def configure_remove_settings(self, **kwargs): 

1050 """ """ 

1051 model = self.app.model 

1052 session = self.Session() 

1053 

1054 super().configure_remove_settings(**kwargs) 

1055 

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

1057 .filter(sa.or_( 

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

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

1060 .all() 

1061 for setting in to_delete: 

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

1063 

1064 

1065 @classmethod 

1066 def defaults(cls, config): 

1067 cls._order_defaults(config) 

1068 cls._defaults(config) 

1069 

1070 @classmethod 

1071 def _order_defaults(cls, config): 

1072 route_prefix = cls.get_route_prefix() 

1073 permission_prefix = cls.get_permission_prefix() 

1074 url_prefix = cls.get_url_prefix() 

1075 model_title = cls.get_model_title() 

1076 model_title_plural = cls.get_model_title_plural() 

1077 

1078 # fix perm group 

1079 config.add_wutta_permission_group(permission_prefix, 

1080 model_title_plural, 

1081 overwrite=False) 

1082 

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

1084 config.add_wutta_permission(permission_prefix, 

1085 f'{permission_prefix}.create_unknown_product', 

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

1087 

1088 # customer autocomplete 

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

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

1091 request_method='GET') 

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

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

1094 renderer='json', 

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

1096 

1097 # product autocomplete 

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

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

1100 request_method='GET') 

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

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

1103 renderer='json', 

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

1105 

1106 

1107class OrderItemView(MasterView): 

1108 """ 

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

1110 route prefix is ``order_items``. 

1111 

1112 Notable URLs provided by this class: 

1113 

1114 * ``/order-items/`` 

1115 * ``/order-items/XXX`` 

1116 

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

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

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

1120 

1121 * :class:`PlacementView` 

1122 * :class:`ReceivingView` 

1123 * :class:`ContactView` 

1124 * :class:`DeliveryView` 

1125 

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

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

1128 

1129 .. attribute:: order_handler 

1130 

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

1132 :meth:`get_order_handler()`. 

1133 """ 

1134 model_class = OrderItem 

1135 model_title = "Order Item (All)" 

1136 model_title_plural = "Order Items (All)" 

1137 route_prefix = 'order_items' 

1138 url_prefix = '/order-items' 

1139 creatable = False 

1140 editable = False 

1141 deletable = False 

1142 

1143 labels = { 

1144 'order_id': "Order ID", 

1145 'store_id': "Store ID", 

1146 'product_id': "Product ID", 

1147 'product_scancode': "Scancode", 

1148 'product_brand': "Brand", 

1149 'product_description': "Description", 

1150 'product_size': "Size", 

1151 'product_weighed': "Sold by Weight", 

1152 'department_id': "Department ID", 

1153 'order_uom': "Order UOM", 

1154 'status_code': "Status", 

1155 } 

1156 

1157 grid_columns = [ 

1158 'order_id', 

1159 'store_id', 

1160 'customer_name', 

1161 # 'sequence', 

1162 'product_scancode', 

1163 'product_brand', 

1164 'product_description', 

1165 'product_size', 

1166 'department_name', 

1167 'special_order', 

1168 'order_qty', 

1169 'order_uom', 

1170 'total_price', 

1171 'status_code', 

1172 ] 

1173 

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

1175 

1176 form_fields = [ 

1177 'order', 

1178 # 'customer_name', 

1179 'sequence', 

1180 'product_id', 

1181 'local_product', 

1182 'pending_product', 

1183 'product_scancode', 

1184 'product_brand', 

1185 'product_description', 

1186 'product_size', 

1187 'product_weighed', 

1188 'department_id', 

1189 'department_name', 

1190 'special_order', 

1191 'case_size', 

1192 'unit_cost', 

1193 'unit_price_reg', 

1194 'unit_price_sale', 

1195 'sale_ends', 

1196 'unit_price_quoted', 

1197 'case_price_quoted', 

1198 'order_qty', 

1199 'order_uom', 

1200 'discount_percent', 

1201 'total_price', 

1202 'status_code', 

1203 'paid_amount', 

1204 'payment_transaction_number', 

1205 ] 

1206 

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

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

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

1210 

1211 def get_fallback_templates(self, template): 

1212 """ """ 

1213 templates = super().get_fallback_templates(template) 

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

1215 return templates 

1216 

1217 def get_query(self, session=None): 

1218 """ """ 

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

1220 model = self.app.model 

1221 return query.join(model.Order) 

1222 

1223 def configure_grid(self, g): 

1224 """ """ 

1225 super().configure_grid(g) 

1226 model = self.app.model 

1227 # enum = self.app.enum 

1228 

1229 # store_id 

1230 if not self.order_handler.expose_store_id(): 

1231 g.remove('store_id') 

1232 

1233 # order_id 

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

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

1236 g.set_link('order_id') 

1237 

1238 # store_id 

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

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

1241 

1242 # customer_name 

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

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

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

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

1247 

1248 # # sequence 

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

1250 

1251 # product_scancode 

1252 g.set_link('product_scancode') 

1253 

1254 # product_brand 

1255 g.set_link('product_brand') 

1256 

1257 # product_description 

1258 g.set_link('product_description') 

1259 

1260 # product_size 

1261 g.set_link('product_size') 

1262 

1263 # order_uom 

1264 # TODO 

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

1266 

1267 # total_price 

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

1269 

1270 # status_code 

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

1272 

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

1274 """ """ 

1275 order = item.order 

1276 return getattr(order, key) 

1277 

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

1279 """ """ 

1280 enum = self.app.enum 

1281 return enum.ORDER_ITEM_STATUS[value] 

1282 

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

1284 """ """ 

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

1286 if variant: 

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

1288 

1289 def configure_form(self, f): 

1290 """ """ 

1291 super().configure_form(f) 

1292 enum = self.app.enum 

1293 item = f.model_instance 

1294 

1295 # order 

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

1297 

1298 # local_product 

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

1300 

1301 # pending_product 

1302 if item.product_id or item.local_product: 

1303 f.remove('pending_product') 

1304 else: 

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

1306 

1307 # order_qty 

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

1309 

1310 # order_uom 

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

1312 

1313 # case_size 

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

1315 

1316 # unit_cost 

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

1318 

1319 # unit_price_reg 

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

1321 

1322 # unit_price_quoted 

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

1324 

1325 # case_price_quoted 

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

1327 

1328 # total_price 

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

1330 

1331 # status 

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

1333 

1334 # paid_amount 

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

1336 

1337 def get_template_context(self, context): 

1338 """ """ 

1339 if self.viewing: 

1340 model = self.app.model 

1341 enum = self.app.enum 

1342 route_prefix = self.get_route_prefix() 

1343 item = context['instance'] 

1344 form = context['form'] 

1345 

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

1347 

1348 context['item'] = item 

1349 context['order'] = item.order 

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

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

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

1353 

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

1355 model_class=model.OrderItemEvent, 

1356 data=item.events, 

1357 columns=[ 

1358 'occurred', 

1359 'actor', 

1360 'type_code', 

1361 'note', 

1362 ], 

1363 labels={ 

1364 'occurred': "Date/Time", 

1365 'actor': "User", 

1366 'type_code': "Event Type", 

1367 }) 

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

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

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

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

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

1373 form.add_grid_vue_context(grid) 

1374 context['events_grid'] = grid 

1375 

1376 return context 

1377 

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

1379 """ """ 

1380 enum = self.app.enum 

1381 if event.type_code == enum.ORDER_ITEM_EVENT_NOTE_ADDED: 

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

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

1384 c=[value]) 

1385 return value 

1386 

1387 def get_xref_buttons(self, item): 

1388 """ """ 

1389 buttons = super().get_xref_buttons(item) 

1390 

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

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

1393 buttons.append( 

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

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

1396 

1397 return buttons 

1398 

1399 def add_note(self): 

1400 """ 

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

1402 will redirect back to the item view. 

1403 """ 

1404 enum = self.app.enum 

1405 item = self.get_instance() 

1406 

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

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

1409 

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

1411 

1412 def change_status(self): 

1413 """ 

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

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

1416 """ 

1417 model = self.app.model 

1418 enum = self.app.enum 

1419 main_item = self.get_instance() 

1420 session = self.Session() 

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

1422 

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

1424 

1425 # validate new status 

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

1427 if new_status_code not in enum.ORDER_ITEM_STATUS: 

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

1429 return redirect 

1430 new_status_text = enum.ORDER_ITEM_STATUS[new_status_code] 

1431 

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

1433 items = [main_item] 

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

1435 # if uuids: 

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

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

1438 # if item: 

1439 # items.append(item) 

1440 

1441 # update item(s) 

1442 for item in items: 

1443 if item.status_code != new_status_code: 

1444 

1445 # event: change status 

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

1447 enum.ORDER_ITEM_STATUS[item.status_code], 

1448 new_status_text) 

1449 item.add_event(enum.ORDER_ITEM_EVENT_STATUS_CHANGE, 

1450 self.request.user, note=note) 

1451 

1452 # event: add note 

1453 if extra_note: 

1454 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, 

1455 self.request.user, note=extra_note) 

1456 

1457 # new status 

1458 item.status_code = new_status_code 

1459 

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

1461 return redirect 

1462 

1463 def get_order_items(self, uuids): 

1464 """ 

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

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

1467 workflow action methods. 

1468 

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

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

1471 

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

1473 

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

1475 records. 

1476 """ 

1477 model = self.app.model 

1478 session = self.Session() 

1479 

1480 if uuids is None: 

1481 uuids = [] 

1482 elif isinstance(uuids, str): 

1483 uuids = uuids.split(',') 

1484 

1485 items = [] 

1486 for uuid in uuids: 

1487 if isinstance(uuid, str): 

1488 uuid = uuid.strip() 

1489 if uuid: 

1490 try: 

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

1492 except sa.exc.StatementError: 

1493 pass # nb. invalid UUID 

1494 else: 

1495 if item: 

1496 items.append(item) 

1497 

1498 if not items: 

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

1500 raise self.redirect(self.get_index_url()) 

1501 

1502 return items 

1503 

1504 @classmethod 

1505 def defaults(cls, config): 

1506 """ """ 

1507 cls._order_item_defaults(config) 

1508 cls._defaults(config) 

1509 

1510 @classmethod 

1511 def _order_item_defaults(cls, config): 

1512 """ """ 

1513 route_prefix = cls.get_route_prefix() 

1514 permission_prefix = cls.get_permission_prefix() 

1515 instance_url_prefix = cls.get_instance_url_prefix() 

1516 model_title = cls.get_model_title() 

1517 model_title_plural = cls.get_model_title_plural() 

1518 

1519 # fix perm group 

1520 config.add_wutta_permission_group(permission_prefix, 

1521 model_title_plural, 

1522 overwrite=False) 

1523 

1524 # add note 

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

1526 f'{instance_url_prefix}/add_note', 

1527 request_method='POST') 

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

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

1530 renderer='json', 

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

1532 config.add_wutta_permission(permission_prefix, 

1533 f'{permission_prefix}.add_note', 

1534 f"Add note for {model_title}") 

1535 

1536 # change status 

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

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

1539 request_method='POST') 

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

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

1542 renderer='json', 

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

1544 config.add_wutta_permission(permission_prefix, 

1545 f'{permission_prefix}.change_status', 

1546 f"Change status for {model_title}") 

1547 

1548 

1549class PlacementView(OrderItemView): 

1550 """ 

1551 Master view for the "placement" phase of 

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

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

1554 

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

1556 status codes are shown: 

1557 

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

1559 

1560 Notable URLs provided by this class: 

1561 

1562 * ``/placement/`` 

1563 * ``/placement/XXX`` 

1564 """ 

1565 model_title = "Order Item (Placement)" 

1566 model_title_plural = "Order Items (Placement)" 

1567 route_prefix = 'order_items_placement' 

1568 url_prefix = '/placement' 

1569 

1570 grid_columns = [ 

1571 'order_id', 

1572 'store_id', 

1573 'customer_name', 

1574 'product_brand', 

1575 'product_description', 

1576 'product_size', 

1577 'department_name', 

1578 'special_order', 

1579 'vendor_name', 

1580 'vendor_item_code', 

1581 'order_qty', 

1582 'order_uom', 

1583 'total_price', 

1584 ] 

1585 

1586 filter_defaults = { 

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

1588 } 

1589 

1590 def get_query(self, session=None): 

1591 """ """ 

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

1593 model = self.app.model 

1594 enum = self.app.enum 

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

1596 

1597 def configure_grid(self, g): 

1598 """ """ 

1599 super().configure_grid(g) 

1600 

1601 # checkable 

1602 if self.has_perm('process_placement'): 

1603 g.checkable = True 

1604 

1605 # tool button: Order Placed 

1606 if self.has_perm('process_placement'): 

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

1608 icon_left='arrow-circle-right', 

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

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

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

1612 

1613 def process_placement(self): 

1614 """ 

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

1616 

1617 This requires a POST request with data: 

1618 

1619 :param item_uuids: Comma-delimited list of 

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

1621 

1622 :param vendor_name: Optional name of vendor. 

1623 

1624 :param po_number: Optional PO number. 

1625 

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

1627 

1628 This invokes 

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

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

1631 back to the index page. 

1632 """ 

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

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

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

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

1637 

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

1639 vendor_name=vendor_name, 

1640 po_number=po_number, 

1641 note=note) 

1642 

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

1644 return self.redirect(self.get_index_url()) 

1645 

1646 @classmethod 

1647 def defaults(cls, config): 

1648 cls._order_item_defaults(config) 

1649 cls._placement_defaults(config) 

1650 cls._defaults(config) 

1651 

1652 @classmethod 

1653 def _placement_defaults(cls, config): 

1654 route_prefix = cls.get_route_prefix() 

1655 permission_prefix = cls.get_permission_prefix() 

1656 url_prefix = cls.get_url_prefix() 

1657 model_title_plural = cls.get_model_title_plural() 

1658 

1659 # process placement 

1660 config.add_wutta_permission(permission_prefix, 

1661 f'{permission_prefix}.process_placement', 

1662 f"Process placement for {model_title_plural}") 

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

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

1665 request_method='POST') 

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

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

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

1669 

1670 

1671class ReceivingView(OrderItemView): 

1672 """ 

1673 Master view for the "receiving" phase of 

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

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

1676 

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

1678 status codes are shown: 

1679 

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

1681 

1682 Notable URLs provided by this class: 

1683 

1684 * ``/receiving/`` 

1685 * ``/receiving/XXX`` 

1686 """ 

1687 model_title = "Order Item (Receiving)" 

1688 model_title_plural = "Order Items (Receiving)" 

1689 route_prefix = 'order_items_receiving' 

1690 url_prefix = '/receiving' 

1691 

1692 grid_columns = [ 

1693 'order_id', 

1694 'store_id', 

1695 'customer_name', 

1696 'product_brand', 

1697 'product_description', 

1698 'product_size', 

1699 'department_name', 

1700 'special_order', 

1701 'vendor_name', 

1702 'vendor_item_code', 

1703 'order_qty', 

1704 'order_uom', 

1705 'total_price', 

1706 ] 

1707 

1708 filter_defaults = { 

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

1710 } 

1711 

1712 def get_query(self, session=None): 

1713 """ """ 

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

1715 model = self.app.model 

1716 enum = self.app.enum 

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

1718 

1719 def configure_grid(self, g): 

1720 """ """ 

1721 super().configure_grid(g) 

1722 

1723 # checkable 

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

1725 g.checkable = True 

1726 

1727 # tool button: Received 

1728 if self.has_perm('process_receiving'): 

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

1730 icon_left='arrow-circle-right', 

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

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

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

1734 

1735 # tool button: Re-Order 

1736 if self.has_perm('process_reorder'): 

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

1738 icon_left='redo', 

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

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

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

1742 

1743 def process_receiving(self): 

1744 """ 

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

1746 

1747 This requires a POST request with data: 

1748 

1749 :param item_uuids: Comma-delimited list of 

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

1751 

1752 :param vendor_name: Optional name of vendor. 

1753 

1754 :param invoice_number: Optional invoice number. 

1755 

1756 :param po_number: Optional PO number. 

1757 

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

1759 

1760 This invokes 

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

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

1763 back to the index page. 

1764 """ 

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

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

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

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

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

1770 

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

1772 vendor_name=vendor_name, 

1773 invoice_number=invoice_number, 

1774 po_number=po_number, 

1775 note=note) 

1776 

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

1778 return self.redirect(self.get_index_url()) 

1779 

1780 def process_reorder(self): 

1781 """ 

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

1783 

1784 This requires a POST request with data: 

1785 

1786 :param item_uuids: Comma-delimited list of 

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

1788 

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

1790 

1791 This invokes 

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

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

1794 to the index page. 

1795 """ 

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

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

1798 

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

1800 

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

1802 return self.redirect(self.get_index_url()) 

1803 

1804 @classmethod 

1805 def defaults(cls, config): 

1806 cls._order_item_defaults(config) 

1807 cls._receiving_defaults(config) 

1808 cls._defaults(config) 

1809 

1810 @classmethod 

1811 def _receiving_defaults(cls, config): 

1812 route_prefix = cls.get_route_prefix() 

1813 permission_prefix = cls.get_permission_prefix() 

1814 url_prefix = cls.get_url_prefix() 

1815 model_title_plural = cls.get_model_title_plural() 

1816 

1817 # process receiving 

1818 config.add_wutta_permission(permission_prefix, 

1819 f'{permission_prefix}.process_receiving', 

1820 f"Process receiving for {model_title_plural}") 

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

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

1823 request_method='POST') 

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

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

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

1827 

1828 # process reorder 

1829 config.add_wutta_permission(permission_prefix, 

1830 f'{permission_prefix}.process_reorder', 

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

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

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

1834 request_method='POST') 

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

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

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

1838 

1839 

1840class ContactView(OrderItemView): 

1841 """ 

1842 Master view for the "contact" phase of 

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

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

1845 

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

1847 status codes are shown: 

1848 

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

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

1851 

1852 Notable URLs provided by this class: 

1853 

1854 * ``/contact/`` 

1855 * ``/contact/XXX`` 

1856 """ 

1857 model_title = "Order Item (Contact)" 

1858 model_title_plural = "Order Items (Contact)" 

1859 route_prefix = 'order_items_contact' 

1860 url_prefix = '/contact' 

1861 

1862 def get_query(self, session=None): 

1863 """ """ 

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

1865 model = self.app.model 

1866 enum = self.app.enum 

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

1868 enum.ORDER_ITEM_STATUS_RECEIVED, 

1869 enum.ORDER_ITEM_STATUS_CONTACT_FAILED))) 

1870 

1871 def configure_grid(self, g): 

1872 """ """ 

1873 super().configure_grid(g) 

1874 

1875 # checkable 

1876 if self.has_perm('process_contact'): 

1877 g.checkable = True 

1878 

1879 # tool button: Contact Success 

1880 if self.has_perm('process_contact'): 

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

1882 icon_left='phone', 

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

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

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

1886 

1887 # tool button: Contact Failure 

1888 if self.has_perm('process_contact'): 

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

1890 icon_left='phone', 

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

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

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

1894 

1895 def process_contact_success(self): 

1896 """ 

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

1898 item(s). 

1899 

1900 This requires a POST request with data: 

1901 

1902 :param item_uuids: Comma-delimited list of 

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

1904 

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

1906 

1907 This invokes 

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

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

1910 user back to the index page. 

1911 """ 

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

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

1914 

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

1916 

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

1918 return self.redirect(self.get_index_url()) 

1919 

1920 def process_contact_failure(self): 

1921 """ 

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

1923 item(s). 

1924 

1925 This requires a POST request with data: 

1926 

1927 :param item_uuids: Comma-delimited list of 

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

1929 

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

1931 

1932 This invokes 

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

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

1935 user back to the index page. 

1936 """ 

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

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

1939 

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

1941 

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

1943 return self.redirect(self.get_index_url()) 

1944 

1945 @classmethod 

1946 def defaults(cls, config): 

1947 cls._order_item_defaults(config) 

1948 cls._contact_defaults(config) 

1949 cls._defaults(config) 

1950 

1951 @classmethod 

1952 def _contact_defaults(cls, config): 

1953 route_prefix = cls.get_route_prefix() 

1954 permission_prefix = cls.get_permission_prefix() 

1955 url_prefix = cls.get_url_prefix() 

1956 model_title_plural = cls.get_model_title_plural() 

1957 

1958 # common perm for processing contact success + failure 

1959 config.add_wutta_permission(permission_prefix, 

1960 f'{permission_prefix}.process_contact', 

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

1962 

1963 # process contact success 

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

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

1966 request_method='POST') 

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

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

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

1970 

1971 # process contact failure 

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

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

1974 request_method='POST') 

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

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

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

1978 

1979 

1980class DeliveryView(OrderItemView): 

1981 """ 

1982 Master view for the "delivery" phase of 

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

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

1985 

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

1987 status codes are shown: 

1988 

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

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

1991 

1992 Notable URLs provided by this class: 

1993 

1994 * ``/delivery/`` 

1995 * ``/delivery/XXX`` 

1996 """ 

1997 model_title = "Order Item (Delivery)" 

1998 model_title_plural = "Order Items (Delivery)" 

1999 route_prefix = 'order_items_delivery' 

2000 url_prefix = '/delivery' 

2001 

2002 def get_query(self, session=None): 

2003 """ """ 

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

2005 model = self.app.model 

2006 enum = self.app.enum 

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

2008 enum.ORDER_ITEM_STATUS_RECEIVED, 

2009 enum.ORDER_ITEM_STATUS_CONTACTED))) 

2010 

2011 def configure_grid(self, g): 

2012 """ """ 

2013 super().configure_grid(g) 

2014 

2015 # checkable 

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

2017 g.checkable = True 

2018 

2019 # tool button: Delivered 

2020 if self.has_perm('process_delivery'): 

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

2022 icon_left='check', 

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

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

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

2026 

2027 # tool button: Restocked 

2028 if self.has_perm('process_restock'): 

2029 button = self.make_button("Restocked", 

2030 icon_left='redo', 

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

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

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

2034 

2035 def process_delivery(self): 

2036 """ 

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

2038 

2039 This requires a POST request with data: 

2040 

2041 :param item_uuids: Comma-delimited list of 

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

2043 

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

2045 

2046 This invokes 

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

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

2049 back to the index page. 

2050 """ 

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

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

2053 

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

2055 

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

2057 return self.redirect(self.get_index_url()) 

2058 

2059 def process_restock(self): 

2060 """ 

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

2062 

2063 This requires a POST request with data: 

2064 

2065 :param item_uuids: Comma-delimited list of 

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

2067 

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

2069 

2070 This invokes 

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

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

2073 to the index page. 

2074 """ 

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

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

2077 

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

2079 

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

2081 return self.redirect(self.get_index_url()) 

2082 

2083 @classmethod 

2084 def defaults(cls, config): 

2085 cls._order_item_defaults(config) 

2086 cls._delivery_defaults(config) 

2087 cls._defaults(config) 

2088 

2089 @classmethod 

2090 def _delivery_defaults(cls, config): 

2091 route_prefix = cls.get_route_prefix() 

2092 permission_prefix = cls.get_permission_prefix() 

2093 url_prefix = cls.get_url_prefix() 

2094 model_title_plural = cls.get_model_title_plural() 

2095 

2096 # process delivery 

2097 config.add_wutta_permission(permission_prefix, 

2098 f'{permission_prefix}.process_delivery', 

2099 f"Process delivery for {model_title_plural}") 

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

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

2102 request_method='POST') 

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

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

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

2106 

2107 # process restock 

2108 config.add_wutta_permission(permission_prefix, 

2109 f'{permission_prefix}.process_restock', 

2110 f"Process restock for {model_title_plural}") 

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

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

2113 request_method='POST') 

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

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

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

2117 

2118 

2119def defaults(config, **kwargs): 

2120 base = globals() 

2121 

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

2123 OrderView.defaults(config) 

2124 

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

2126 OrderItemView.defaults(config) 

2127 

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

2129 PlacementView.defaults(config) 

2130 

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

2132 ReceivingView.defaults(config) 

2133 

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

2135 ContactView.defaults(config) 

2136 

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

2138 DeliveryView.defaults(config) 

2139 

2140 

2141def includeme(config): 

2142 defaults(config)