source: tailbone/tailbone/views/products.py @ d20d22f

Last change on this file since d20d22f was d20d22f, checked in by Lance Edgar <ledgar@…>, 11 months ago

Fix rendering bug when price.multiple is null

  • Property mode set to 100644
File size: 47.4 KB
Line 
1# -*- coding: utf-8; -*-
2################################################################################
3#
4#  Rattail -- Retail Software Framework
5#  Copyright © 2010-2018 Lance Edgar
6#
7#  This file is part of Rattail.
8#
9#  Rattail is free software: you can redistribute it and/or modify it under the
10#  terms of the GNU General Public License as published by the Free Software
11#  Foundation, either version 3 of the License, or (at your option) any later
12#  version.
13#
14#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
15#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
17#  details.
18#
19#  You should have received a copy of the GNU General Public License along with
20#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Product Views
25"""
26
27from __future__ import unicode_literals, absolute_import
28
29import re
30import logging
31
32import six
33import sqlalchemy as sa
34from sqlalchemy import orm
35
36from rattail import enum, pod, sil
37from rattail.db import model, api, auth, Session as RattailSession
38from rattail.gpc import GPC
39from rattail.threads import Thread
40from rattail.exceptions import LabelPrintingError
41from rattail.util import load_object, pretty_quantity
42from rattail.batch import get_batch_handler
43
44import colander
45from deform import widget as dfwidget
46from pyramid import httpexceptions
47from webhelpers2.html import tags, HTML
48
49from tailbone import forms, grids
50from tailbone.db import Session
51from tailbone.views import MasterView, AutocompleteView
52from tailbone.progress import SessionProgress
53from tailbone.util import raw_datetime
54
55
56log = logging.getLogger(__name__)
57
58
59# TODO: For a moment I thought this was going to be necessary, but now I think
60# not.  Leaving it around for a bit just in case...
61
62# class VendorAnyFilter(grids.filters.AlchemyStringFilter):
63#     """
64#     Custom filter for "vendor (any)" so we can avoid joining on that unless we
65#     really have to.  This is because it seems to throw off the number of
66#     records which are showed in the result set, when this filter is included in
67#     the active set but no criteria is specified.
68#     """
69
70#     def filter(self, query, **kwargs):
71#         original = query
72#         query = super(VendorAnyFilter, self).filter(query, **kwargs)
73#         if query is not original:
74#             query = self.joiner(query)
75#         return query
76
77
78class ProductsView(MasterView):
79    """
80    Master view for the Product class.
81    """
82    model_class = model.Product
83    supports_mobile = True
84    has_versions = True
85    results_downloadable_xlsx = True
86
87    labels = {
88        'upc': "UPC",
89        'status_code': "Status",
90    }
91
92    grid_columns = [
93        'upc',
94        'brand',
95        'description',
96        'size',
97        'department',
98        'vendor',
99        'cost',
100        'true_cost',
101        'true_margin',
102        'regular_price',
103        'current_price',
104    ]
105
106    form_fields = [
107        'upc',
108        'brand',
109        'description',
110        'unit_size',
111        'unit_of_measure',
112        'size',
113        'packs',
114        'pack_size',
115        'unit',
116        'default_pack',
117        'case_size',
118        'weighed',
119        'department',
120        'subdepartment',
121        'category',
122        'family',
123        'report_code',
124        'suggested_price',
125        'regular_price',
126        'current_price',
127        'current_price_ends',
128        'vendor',
129        'cost',
130        'deposit_link',
131        'tax',
132        'organic',
133        'kosher',
134        'vegan',
135        'vegetarian',
136        'gluten_free',
137        'sugar_free',
138        'discountable',
139        'special_order',
140        'not_for_sale',
141        'ingredients',
142        'notes',
143        'status_code',
144        'discontinued',
145        'deleted',
146        'last_sold',
147        'inventory_on_hand',
148        'inventory_on_order',
149    ]
150
151    mobile_form_fields = form_fields
152
153    # These aliases enable the grid queries to filter products which may be
154    # purchased from *any* vendor, and yet sort by only the "preferred" vendor
155    # (since that's what shows up in the grid column).
156    ProductVendorCost = orm.aliased(model.ProductCost)
157    ProductVendorCostAny = orm.aliased(model.ProductCost)
158    VendorAny = orm.aliased(model.Vendor)
159
160    # same, but for prices
161    RegularPrice = orm.aliased(model.ProductPrice)
162    CurrentPrice = orm.aliased(model.ProductPrice)
163
164    def __init__(self, request):
165        super(ProductsView, self).__init__(request)
166        self.print_labels = request.rattail_config.getbool('tailbone', 'products.print_labels', default=False)
167
168    def query(self, session):
169        user = self.request.user
170        if user and user not in session:
171            user = session.merge(user)
172
173        query = session.query(model.Product)
174        # TODO: was this old `has_permission()` call here for a reason..? hope not..
175        # if not auth.has_permission(session, user, 'products.view_deleted'):
176        if not self.request.has_perm('products.view_deleted'):
177            query = query.filter(model.Product.deleted == False)
178
179        # TODO: This used to be a good idea I thought...but in dev it didn't
180        # seem to make much difference, except with a larger (50K) data set it
181        # totally bogged things down instead of helping...
182        # query = query\
183        #     .options(orm.joinedload(model.Product.brand))\
184        #     .options(orm.joinedload(model.Product.department))\
185        #     .options(orm.joinedload(model.Product.subdepartment))\
186        #     .options(orm.joinedload(model.Product.regular_price))\
187        #     .options(orm.joinedload(model.Product.current_price))\
188        #     .options(orm.joinedload(model.Product.vendor))
189
190        query = query.outerjoin(model.ProductInventory)
191
192        return query
193
194    def configure_grid(self, g):
195        super(ProductsView, self).configure_grid(g)
196
197        def join_vendor(q):
198            return q.outerjoin(self.ProductVendorCost,
199                               sa.and_(
200                                   self.ProductVendorCost.product_uuid == model.Product.uuid,
201                                   self.ProductVendorCost.preference == 1))\
202                    .outerjoin(model.Vendor)
203
204        def join_vendor_any(q):
205            return q.outerjoin(self.ProductVendorCostAny,
206                               self.ProductVendorCostAny.product_uuid == model.Product.uuid)\
207                    .outerjoin(self.VendorAny,
208                               self.VendorAny.uuid == self.ProductVendorCostAny.vendor_uuid)
209
210        ProductCostCode = orm.aliased(model.ProductCost)
211        ProductCostCodeAny = orm.aliased(model.ProductCost)
212
213        def join_vendor_code(q):
214            return q.outerjoin(ProductCostCode,
215                               sa.and_(
216                                   ProductCostCode.product_uuid == model.Product.uuid,
217                                   ProductCostCode.preference == 1))
218
219        def join_vendor_code_any(q):
220            return q.outerjoin(ProductCostCodeAny,
221                               ProductCostCodeAny.product_uuid == model.Product.uuid)
222
223        g.joiners['brand'] = lambda q: q.outerjoin(model.Brand)
224        g.joiners['department'] = lambda q: q.outerjoin(model.Department,
225                                                        model.Department.uuid == model.Product.department_uuid)
226        g.joiners['subdepartment'] = lambda q: q.outerjoin(model.Subdepartment,
227                                                           model.Subdepartment.uuid == model.Product.subdepartment_uuid)
228        g.joiners['code'] = lambda q: q.outerjoin(model.ProductCode)
229        g.joiners['vendor'] = join_vendor
230        g.joiners['vendor_any'] = join_vendor_any
231        g.joiners['vendor_code'] = join_vendor_code
232        g.joiners['vendor_code_any'] = join_vendor_code_any
233
234        g.sorters['brand'] = g.make_sorter(model.Brand.name)
235        g.sorters['department'] = g.make_sorter(model.Department.name)
236        g.sorters['subdepartment'] = g.make_sorter(model.Subdepartment.name)
237        g.sorters['vendor'] = g.make_sorter(model.Vendor.name)
238
239        ProductTrueCost = orm.aliased(model.ProductVolatile)
240        ProductTrueMargin = orm.aliased(model.ProductVolatile)
241
242        # true_cost
243        g.set_joiner('true_cost', lambda q: q.outerjoin(ProductTrueCost))
244        g.set_filter('true_cost', ProductTrueCost.true_cost)
245        g.set_sorter('true_cost', ProductTrueCost.true_cost)
246        g.set_renderer('true_cost', self.render_true_cost)
247
248        # true_margin
249        g.set_joiner('true_margin', lambda q: q.outerjoin(ProductTrueMargin))
250        g.set_filter('true_margin', ProductTrueMargin.true_margin)
251        g.set_sorter('true_margin', ProductTrueMargin.true_margin)
252        g.set_renderer('true_margin', self.render_true_margin)
253
254        # on_hand
255        g.set_sorter('on_hand', model.ProductInventory.on_hand)
256        g.set_filter('on_hand', model.ProductInventory.on_hand)
257
258        # on_order
259        g.set_sorter('on_order', model.ProductInventory.on_order)
260        g.set_filter('on_order', model.ProductInventory.on_order)
261
262        g.filters['upc'].default_active = True
263        g.filters['upc'].default_verb = 'equal'
264        g.filters['description'].default_active = True
265        g.filters['description'].default_verb = 'contains'
266        g.filters['brand'] = g.make_filter('brand', model.Brand.name,
267                                           default_active=True, default_verb='contains')
268        g.filters['department'] = g.make_filter('department', model.Department.name,
269                                                default_active=True, default_verb='contains')
270        g.filters['subdepartment'] = g.make_filter('subdepartment', model.Subdepartment.name)
271        g.filters['code'] = g.make_filter('code', model.ProductCode.code)
272        g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name)
273        g.filters['vendor_any'] = g.make_filter('vendor_any', self.VendorAny.name)
274                                                # factory=VendorAnyFilter, joiner=join_vendor_any)
275        g.filters['vendor_code'] = g.make_filter('vendor_code', ProductCostCode.code)
276        g.filters['vendor_code_any'] = g.make_filter('vendor_code_any', ProductCostCodeAny.code)
277
278        # category
279        g.set_joiner('category', lambda q: q.outerjoin(model.Category))
280        g.set_filter('category', model.Category.name)
281
282        # family
283        g.set_joiner('family', lambda q: q.outerjoin(model.Family))
284        g.set_filter('family', model.Family.name)
285
286        g.set_label('regular_price', "Reg. Price")
287        g.set_joiner('regular_price', lambda q: q.outerjoin(
288            self.RegularPrice, self.RegularPrice.uuid == model.Product.regular_price_uuid))
289        g.set_sorter('regular_price', self.RegularPrice.price)
290        g.set_filter('regular_price', self.RegularPrice.price, label="Regular Price")
291
292        g.set_label('current_price', "Cur. Price")
293        g.set_joiner('current_price', lambda q: q.outerjoin(
294            self.CurrentPrice, self.CurrentPrice.uuid == model.Product.current_price_uuid))
295        g.set_sorter('current_price', self.CurrentPrice.price)
296        g.set_filter('current_price', self.CurrentPrice.price, label="Current Price")
297
298        # (unit) cost
299        g.set_joiner('cost', lambda q: q.outerjoin(model.ProductCost,
300                                                   sa.and_(
301                                                       model.ProductCost.product_uuid == model.Product.uuid,
302                                                       model.ProductCost.preference == 1)))
303        g.set_sorter('cost', model.ProductCost.unit_cost)
304        g.set_filter('cost', model.ProductCost.unit_cost)
305        g.set_renderer('cost', self.render_cost)
306        g.set_label('cost', "Unit Cost")
307
308        # report_code_name
309        g.set_joiner('report_code_name', lambda q: q.outerjoin(model.ReportCode))
310        g.set_filter('report_code_name', model.ReportCode.name)
311
312        g.set_sort_defaults('upc')
313
314        if self.print_labels and self.request.has_perm('products.print_labels'):
315            g.more_actions.append(grids.GridAction('print_label', icon='print'))
316
317        g.set_type('upc', 'gpc')
318
319        g.set_renderer('regular_price', self.render_price)
320        g.set_renderer('current_price', self.render_price)
321        g.set_renderer('on_hand', self.render_on_hand)
322        g.set_renderer('on_order', self.render_on_order)
323
324        g.set_link('upc')
325        g.set_link('item_id')
326        g.set_link('description')
327
328        g.set_label('vendor', "Vendor (preferred)")
329        g.set_label('vendor_any', "Vendor (any)")
330        g.set_label('vendor', "Pref. Vendor")
331
332    def configure_common_form(self, f):
333        super(ProductsView, self).configure_common_form(f)
334        product = f.model_instance
335
336        # upc
337        f.set_type('upc', 'gpc')
338
339        # unit_size
340        f.set_type('unit_size', 'quantity')
341
342        # unit_of_measure
343        f.set_enum('unit_of_measure', self.enum.UNIT_OF_MEASURE)
344        f.set_label('unit_of_measure', "Unit of Measure")
345
346        # packs
347        if self.creating:
348            f.remove_field('packs')
349        elif self.viewing and product.packs:
350            f.set_renderer('packs', self.render_packs)
351            f.set_label('packs', "Pack Items")
352        else:
353            f.remove_field('packs')
354
355        # pack_size
356        if self.viewing and not product.is_pack_item():
357            f.remove_field('pack_size')
358        else:
359            f.set_type('pack_size', 'quantity')
360
361        # default_pack
362        if self.viewing and not product.is_pack_item():
363            f.remove_field('default_pack')
364
365        # unit
366        if self.creating:
367            f.remove_field('unit')
368        elif self.viewing and product.is_pack_item():
369            f.set_renderer('unit', self.render_unit)
370            f.set_label('unit', "Unit Item")
371        else:
372            f.remove_field('unit')
373
374        # suggested_price
375        if self.creating:
376            f.remove_field('suggested_price')
377        else:
378            f.set_readonly('suggested_price')
379            f.set_renderer('suggested_price', self.render_price)
380
381        # regular_price
382        if self.creating:
383            f.remove_field('regular_price')
384        else:
385            f.set_readonly('regular_price')
386            f.set_renderer('regular_price', self.render_price)
387
388        # current_price
389        if self.creating:
390            f.remove_field('current_price')
391        else:
392            f.set_readonly('current_price')
393            f.set_renderer('current_price', self.render_price)
394
395        # current_price_ends
396        if self.creating:
397            f.remove_field('current_price_ends')
398        else:
399            f.set_readonly('current_price_ends')
400            f.set_renderer('current_price_ends', self.render_current_price_ends)
401
402        # vendor
403        if self.creating:
404            f.remove_field('vendor')
405        else:
406            f.set_readonly('vendor')
407            f.set_label('vendor', "Preferred Vendor")
408
409        # cost
410        if self.creating:
411            f.remove_field('cost')
412        else:
413            f.set_readonly('cost')
414            f.set_label('cost', "Preferred Unit Cost")
415            f.set_renderer('cost', self.render_cost)
416
417        # last_sold
418        if self.creating:
419            f.remove_field('last_sold')
420        else:
421            f.set_readonly('last_sold')
422            f.set_renderer('last_sold', self.render_last_sold)
423
424        # inventory_on_hand
425        if self.creating:
426            f.remove_field('inventory_on_hand')
427        else:
428            f.set_readonly('inventory_on_hand')
429            f.set_renderer('inventory_on_hand', self.render_inventory_on_hand)
430            f.set_label('inventory_on_hand', "On Hand")
431
432        # inventory_on_order
433        if self.creating:
434            f.remove_field('inventory_on_order')
435        else:
436            f.set_readonly('inventory_on_order')
437            f.set_renderer('inventory_on_order', self.render_inventory_on_order)
438            f.set_label('inventory_on_order', "On Order")
439
440    def render_cost(self, product, field):
441        cost = getattr(product, field)
442        if cost:
443            if cost.unit_cost:
444                return "$ {:0.2f}".format(cost.unit_cost)
445            else:
446                return "TODO: does this item have a cost?"
447
448    def render_price(self, product, column):
449        price = product[column]
450        if price:
451            if not product.not_for_sale:
452                if price.price is not None and price.pack_price is not None:
453                    if price.multiple > 1:
454                        return HTML("$ {:0.2f} / {}&nbsp; ($ {:0.2f} / {})".format(
455                            price.price, price.multiple,
456                            price.pack_price, price.pack_multiple))
457                    return HTML("$ {:0.2f}&nbsp; ($ {:0.2f} / {})".format(
458                        price.price, price.pack_price, price.pack_multiple))
459                if price.price is not None:
460                    if price.multiple is not None and price.multiple > 1:
461                        return "$ {:0.2f} / {}".format(price.price, price.multiple)
462                    return "$ {:0.2f}".format(price.price)
463                if price.pack_price is not None:
464                    return "$ {:0.2f} / {}".format(price.pack_price, price.pack_multiple)
465        return ""
466       
467    def render_cost(self, product, column):
468        cost = product.cost
469        if not cost:
470            return ""
471        if cost.unit_cost is None:
472            return ""
473        return "${:0.2f}".format(cost.unit_cost)
474
475    def render_true_cost(self, product, field):
476        if not product.volatile:
477            return ""
478        if product.volatile.true_cost is None:
479            return ""
480        return "${:0.3f}".format(product.volatile.true_cost)
481
482    def render_true_margin(self, product, field):
483        if not product.volatile:
484            return ""
485        if product.volatile.true_margin is None:
486            return ""
487        return "{:0.3f} %".format(product.volatile.true_margin * 100)
488
489    def render_on_hand(self, product, column):
490        inventory = product.inventory
491        if not inventory:
492            return ""
493        return pretty_quantity(inventory.on_hand)
494
495    def render_on_order(self, product, column):
496        inventory = product.inventory
497        if not inventory:
498            return ""
499        return pretty_quantity(inventory.on_order)
500
501    def template_kwargs_index(self, **kwargs):
502        if self.print_labels:
503            kwargs['label_profiles'] = Session.query(model.LabelProfile)\
504                                              .filter(model.LabelProfile.visible == True)\
505                                              .order_by(model.LabelProfile.ordinal)\
506                                              .all()
507        return kwargs
508
509
510    def grid_extra_class(self, product, i):
511        classes = []
512        if product.not_for_sale:
513            classes.append('not-for-sale')
514        if product.deleted:
515            classes.append('deleted')
516        if classes:
517            return ' '.join(classes)
518
519    def get_xlsx_fields(self):
520        fields = super(ProductsView, self).get_xlsx_fields()
521
522        i = fields.index('department_uuid')
523        fields.insert(i + 1, 'department_number')
524        fields.insert(i + 2, 'department_name')
525
526        i = fields.index('subdepartment_uuid')
527        fields.insert(i + 1, 'subdepartment_number')
528        fields.insert(i + 2, 'subdepartment_name')
529
530        i = fields.index('category_uuid')
531        fields.insert(i + 1, 'category_code')
532
533        i = fields.index('family_uuid')
534        fields.insert(i + 1, 'family_code')
535
536        i = fields.index('report_code_uuid')
537        fields.insert(i + 1, 'report_code')
538
539        i = fields.index('deposit_link_uuid')
540        fields.insert(i + 1, 'deposit_link_code')
541
542        i = fields.index('tax_uuid')
543        fields.insert(i + 1, 'tax_code')
544
545        i = fields.index('brand_uuid')
546        fields.insert(i + 1, 'brand_name')
547
548        i = fields.index('suggested_price_uuid')
549        fields.insert(i + 1, 'suggested_price')
550
551        i = fields.index('regular_price_uuid')
552        fields.insert(i + 1, 'regular_price')
553
554        i = fields.index('current_price_uuid')
555        fields.insert(i + 1, 'current_price')
556
557        fields.append('unit_cost')
558
559        return fields
560
561    def get_xlsx_row(self, product, fields):
562        row = super(ProductsView, self).get_xlsx_row(product, fields)
563
564        if 'upc' in fields and isinstance(row['upc'], GPC):
565            row['upc'] = row['upc'].pretty()
566
567        if 'department_number' in fields:
568            row['department_number'] = product.department.number if product.department else None
569        if 'department_name' in fields:
570            row['department_name'] = product.department.name if product.department else None
571
572        if 'subdepartment_number' in fields:
573            row['subdepartment_number'] = product.subdepartment.number if product.subdepartment else None
574        if 'subdepartment_name' in fields:
575            row['subdepartment_name'] = product.subdepartment.name if product.subdepartment else None
576
577        if 'category_code' in fields:
578            row['category_code'] = product.category.code if product.category else None
579
580        if 'family_code' in fields:
581            row['family_code'] = product.family.code if product.family else None
582
583        if 'report_code' in fields:
584            row['report_code'] = product.report_code.code if product.report_code else None
585
586        if 'deposit_link_code' in fields:
587            row['deposit_link_code'] = product.deposit_link.code if product.deposit_link else None
588
589        if 'tax_code' in fields:
590            row['tax_code'] = product.tax.code if product.tax else None
591
592        if 'brand_name' in fields:
593            row['brand_name'] = product.brand.name if product.brand else None
594
595        if 'suggested_price' in fields:
596            row['suggested_price'] = product.suggested_price.price if product.suggested_price else None
597
598        if 'regular_price' in fields:
599            row['regular_price'] = product.regular_price.price if product.regular_price else None
600
601        if 'current_price' in fields:
602            row['current_price'] = product.current_price.price if product.current_price else None
603
604        if 'unit_cost' in fields:
605            row['unit_cost'] = product.cost.unit_cost if product.cost else None
606
607        return row
608
609    def get_instance(self):
610        key = self.request.matchdict['uuid']
611        product = Session.query(model.Product).get(key)
612        if product:
613            return product
614        price = Session.query(model.ProductPrice).get(key)
615        if price:
616            return price.product
617        raise httpexceptions.HTTPNotFound()
618
619    def configure_form(self, f):
620        super(ProductsView, self).configure_form(f)
621        product = f.model_instance
622
623        # department
624        if self.creating or self.editing:
625            if 'department' in f.fields:
626                f.replace('department', 'department_uuid')
627                departments = self.Session.query(model.Department)\
628                                          .order_by(model.Department.number)
629                dept_values = [(d.uuid, "{} {}".format(d.number, d.name))
630                               for d in departments]
631                require_department = False
632                if not require_department:
633                    dept_values.insert(0, ('', "(none)"))
634                f.set_widget('department_uuid', dfwidget.SelectWidget(values=dept_values))
635                f.set_label('department_uuid', "Department")
636        else:
637            f.set_readonly('department')
638            f.set_renderer('department', self.render_department)
639
640        # subdepartment
641        if self.creating or self.editing:
642            if 'subdepartment' in f.fields:
643                f.replace('subdepartment', 'subdepartment_uuid')
644                subdepartments = self.Session.query(model.Subdepartment)\
645                                          .order_by(model.Subdepartment.number)
646                subdept_values = [(s.uuid, "{} {}".format(s.number, s.name))
647                                  for s in subdepartments]
648                require_subdepartment = False
649                if not require_subdepartment:
650                    subdept_values.insert(0, ('', "(none)"))
651                f.set_widget('subdepartment_uuid', dfwidget.SelectWidget(values=subdept_values))
652                f.set_label('subdepartment_uuid', "Subdepartment")
653        else:
654            f.set_readonly('subdepartment')
655            f.set_renderer('subdepartment', self.render_subdepartment)
656
657        # category
658        if self.creating or self.editing:
659            if 'category' in f.fields:
660                f.replace('category', 'category_uuid')
661                categories = self.Session.query(model.Category)\
662                                          .order_by(model.Category.code)
663                category_values = [(c.uuid, "{} {}".format(c.code, c.name))
664                                   for c in categories]
665                require_category = False
666                if not require_category:
667                    category_values.insert(0, ('', "(none)"))
668                f.set_widget('category_uuid', dfwidget.SelectWidget(values=category_values))
669                f.set_label('category_uuid', "Category")
670        else:
671            f.set_readonly('category')
672            f.set_renderer('category', self.render_category)
673
674        # family
675        if self.creating or self.editing:
676            if 'family' in f.fields:
677                f.replace('family', 'family_uuid')
678                families = self.Session.query(model.Family)\
679                                          .order_by(model.Family.name)
680                family_values = [(fam.uuid, fam.name) for fam in families]
681                require_family = False
682                if not require_family:
683                    family_values.insert(0, ('', "(none)"))
684                f.set_widget('family_uuid', dfwidget.SelectWidget(values=family_values))
685                f.set_label('family_uuid', "Family")
686        else:
687            f.set_readonly('family')
688            # f.set_renderer('family', self.render_family)
689
690        # report_code
691        if self.creating or self.editing:
692            if 'report_code' in f.fields:
693                f.replace('report_code', 'report_code_uuid')
694                report_codes = self.Session.query(model.ReportCode)\
695                                          .order_by(model.ReportCode.code)
696                report_code_values = [(rc.uuid, "{} {}".format(rc.code, rc.name))
697                                      for rc in report_codes]
698                require_report_code = False
699                if not require_report_code:
700                    report_code_values.insert(0, ('', "(none)"))
701                f.set_widget('report_code_uuid', dfwidget.SelectWidget(values=report_code_values))
702                f.set_label('report_code_uuid', "Report_Code")
703        else:
704            f.set_readonly('report_code')
705            # f.set_renderer('report_code', self.render_report_code)
706
707        # deposit_link
708        if self.creating or self.editing:
709            if 'deposit_link' in f.fields:
710                f.replace('deposit_link', 'deposit_link_uuid')
711                deposit_links = self.Session.query(model.DepositLink)\
712                                          .order_by(model.DepositLink.code)
713                deposit_link_values = [(dl.uuid, "{} {}".format(dl.code, dl.description))
714                                      for dl in deposit_links]
715                require_deposit_link = False
716                if not require_deposit_link:
717                    deposit_link_values.insert(0, ('', "(none)"))
718                f.set_widget('deposit_link_uuid', dfwidget.SelectWidget(values=deposit_link_values))
719                f.set_label('deposit_link_uuid', "Deposit_Link")
720        else:
721            f.set_readonly('deposit_link')
722            # f.set_renderer('deposit_link', self.render_deposit_link)
723
724        # tax
725        if self.creating or self.editing:
726            if 'tax' in f.fields:
727                f.replace('tax', 'tax_uuid')
728                taxes = self.Session.query(model.Tax)\
729                                          .order_by(model.Tax.code)
730                tax_values = [(tax.uuid, "{} {}".format(tax.code, tax.description))
731                              for tax in taxes]
732                require_tax = False
733                if not require_tax:
734                    tax_values.insert(0, ('', "(none)"))
735                f.set_widget('tax_uuid', dfwidget.SelectWidget(values=tax_values))
736                f.set_label('tax_uuid', "Tax")
737        else:
738            f.set_readonly('tax')
739            # f.set_renderer('tax', self.render_tax)
740
741        # brand
742        if self.creating:
743            f.replace('brand', 'brand_uuid')
744            brand_display = ""
745            if self.request.method == 'POST':
746                if self.request.POST.get('brand_uuid'):
747                    brand = self.Session.query(model.Brand).get(self.request.POST['brand_uuid'])
748                    if brand:
749                        brand_display = six.text_type(brand)
750            brands_url = self.request.route_url('brands.autocomplete')
751            f.set_widget('brand_uuid', forms.widgets.JQueryAutocompleteWidget(
752                field_display=brand_display, service_url=brands_url))
753            f.set_label('brand_uuid', "Brand")
754
755        # status_code
756        f.set_label('status_code', "Status")
757
758        # ingredients
759        f.set_widget('ingredients', dfwidget.TextAreaWidget(cols=80, rows=10))
760
761        # notes
762        f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=10))
763
764        if not self.request.has_perm('products.view_deleted'):
765            f.remove('deleted')
766
767    def render_department(self, product, field):
768        department = product.department
769        if not department:
770            return ""
771        if department.number:
772            text = '({}) {}'.format(department.number, department.name)
773        else:
774            text = department.name
775        url = self.request.route_url('departments.view', uuid=department.uuid)
776        return tags.link_to(text, url)
777
778    def render_subdepartment(self, product, field):
779        subdepartment = product.subdepartment
780        if not subdepartment:
781            return ""
782        if subdepartment.number:
783            text = '({}) {}'.format(subdepartment.number, subdepartment.name)
784        else:
785            text = subdepartment.name
786        url = self.request.route_url('subdepartments.view', uuid=subdepartment.uuid)
787        return tags.link_to(text, url)
788
789    def render_category(self, product, field):
790        category = product.category
791        if not category:
792            return ""
793        if category.code:
794            text = '({}) {}'.format(category.code, category.name)
795        elif category.number:
796            text = '({}) {}'.format(category.number, category.name)
797        else:
798            text = category.name
799        url = self.request.route_url('categories.view', uuid=category.uuid)
800        return tags.link_to(text, url)
801
802    def render_packs(self, product, field):
803        if product.is_pack_item():
804            return ""
805
806        links = []
807        for pack in product.packs:
808            if pack.upc:
809                code = pack.upc.pretty()
810            elif pack.scancode:
811                code = pack.scancode
812            else:
813                code = pack.item_id
814            text = "({}) {}".format(code, pack.full_description)
815            url = self.get_action_url('view', pack, mobile=self.mobile)
816            links.append(tags.link_to(text, url))
817
818        items = [HTML.tag('li', c=[link]) for link in links]
819        return HTML.tag('ul', c=items)
820
821    def render_unit(self, product, field):
822        unit = product.unit
823        if not unit:
824            return ""
825
826        if unit.upc:
827            code = unit.upc.pretty()
828        elif unit.scancode:
829            code = unit.scancode
830        else:
831            code = unit.item_id
832
833        text = "({}) {}".format(code, unit.full_description)
834        url = self.get_action_url('view', unit, mobile=self.mobile)
835        return tags.link_to(text, url)
836
837    def render_current_price_ends(self, product, field):
838        if not product.current_price:
839            return ""
840        value = product.current_price.ends
841        if not value:
842            return ""
843        return raw_datetime(self.request.rattail_config, value)
844
845    def render_last_sold(self, product, field):
846        return "TODO: add default renderer for last sold"
847
848    def render_inventory_on_hand(self, product, field):
849        if not product.inventory:
850            return ""
851        value = product.inventory.on_hand
852        if not value:
853            return ""
854        return pretty_quantity(value)
855
856    def render_inventory_on_order(self, product, field):
857        if not product.inventory:
858            return ""
859        value = product.inventory.on_order
860        if not value:
861            return ""
862        return pretty_quantity(value)
863
864    def template_kwargs_view(self, **kwargs):
865        kwargs['image'] = False
866        product = kwargs['instance']
867        if product.upc:
868            kwargs['image_url'] = pod.get_image_url(self.rattail_config, product.upc)
869            kwargs['image_path'] = pod.get_image_path(self.rattail_config, product.upc)
870        kwargs['costs_label_preferred'] = "Pref."
871        kwargs['costs_label_vendor'] = "Vendor"
872        kwargs['costs_label_code'] = "Order Code"
873        kwargs['costs_label_case_size'] = "Case Size"
874        return kwargs
875
876    def edit(self):
877        # TODO: Should add some more/better hooks, so don't have to duplicate
878        # so much code here.
879        self.editing = True
880        instance = self.get_instance()
881        form = self.make_form(instance)
882        product_deleted = instance.deleted
883        if self.request.method == 'POST':
884            if self.validate_form(form):
885                self.save_edit_form(form)
886                self.request.session.flash("{} {} has been updated.".format(
887                    self.get_model_title(), self.get_instance_title(instance)))
888                return self.redirect(self.get_action_url('view', instance))
889        if product_deleted:
890            self.request.session.flash("This product is marked as deleted.", 'error')
891        return self.render_to_response('edit', {'instance': instance,
892                                                'instance_title': self.get_instance_title(instance),
893                                                'form': form})
894
895    def mobile_index(self):
896        """
897        Mobile "home" page for products
898        """
899        self.mobile = True
900        context = {
901            'quick_lookup': False,
902            'placeholder': "Enter {}".format(self.rattail_config.product_key_title()),
903            'quick_lookup_keyboard_wedge': True,
904        }
905        if self.rattail_config.getbool('rattail', 'products.mobile.quick_lookup', default=False):
906            context['quick_lookup'] = True
907        else:
908            self.listing = True
909            grid = self.make_mobile_grid()
910            context['grid'] = grid
911        return self.render_to_response('index', context, mobile=True)
912
913    def mobile_quick_lookup(self):
914        entry = self.request.POST['quick_entry'].strip()
915        provided = GPC(entry, calc_check_digit=False)
916        product = api.get_product_by_upc(self.Session(), provided)
917        if not product:
918            checked = GPC(entry, calc_check_digit='upc')
919            product = api.get_product_by_upc(self.Session(), checked)
920        if not product:
921            product = api.get_product_by_code(self.Session(), entry)
922        if not product:
923            raise self.notfound()
924        return self.redirect(self.get_action_url('view', product, mobile=True))
925
926    def get_version_child_classes(self):
927        return [
928            (model.ProductCode, 'product_uuid'),
929            (model.ProductCost, 'product_uuid'),
930            (model.ProductPrice, 'product_uuid'),
931        ]
932
933    def image(self):
934        """
935        View which renders the product's image as a response.
936        """
937        product = self.get_instance()
938        if not product.image:
939            raise httpexceptions.HTTPNotFound()
940        # TODO: how to properly detect image type?
941        # self.request.response.content_type = six.binary_type('image/png')
942        self.request.response.content_type = six.binary_type('image/jpeg')
943        self.request.response.body = product.image.bytes
944        return self.request.response
945
946    def search(self):
947        """
948        Locate a product(s) by UPC.
949
950        Eventually this should be more generic, or at least offer more fields for
951        search.  For now it operates only on the ``Product.upc`` field.
952        """
953        data = None
954        upc = self.request.GET.get('upc', '').strip()
955        upc = re.sub(r'\D', '', upc)
956        if upc:
957            product = api.get_product_by_upc(Session(), upc)
958            if not product:
959                # Try again, assuming caller did not include check digit.
960                upc = GPC(upc, calc_check_digit='upc')
961                product = api.get_product_by_upc(Session(), upc)
962            if product and (not product.deleted or self.request.has_perm('products.view_deleted')):
963                data = {
964                    'uuid': product.uuid,
965                    'upc': six.text_type(product.upc),
966                    'upc_pretty': product.upc.pretty(),
967                    'full_description': product.full_description,
968                    'image_url': pod.get_image_url(self.rattail_config, product.upc),
969                }
970                uuid = self.request.GET.get('with_vendor_cost')
971                if uuid:
972                    vendor = Session.query(model.Vendor).get(uuid)
973                    if not vendor:
974                        return {'error': "Vendor not found"}
975                    cost = product.cost_for_vendor(vendor)
976                    if cost:
977                        data['cost_found'] = True
978                        if int(cost.case_size) == cost.case_size:
979                            data['cost_case_size'] = int(cost.case_size)
980                        else:
981                            data['cost_case_size'] = '{:0.4f}'.format(cost.case_size)
982                    else:
983                        data['cost_found'] = False
984        return {'product': data}
985
986    def get_supported_batches(self):
987        return {
988            'labels': 'rattail.batch.labels:LabelBatchHandler',
989            'pricing': 'rattail.batch.pricing:PricingBatchHandler',
990        }
991
992    def make_batch(self):
993        """
994        View for making a new batch from current product grid query.
995        """
996        supported = self.get_supported_batches()
997        batch_options = []
998        for key, info in list(supported.items()):
999            handler = load_object(info['spec'])(self.rattail_config)
1000            handler.spec = info['spec']
1001            handler.option_key = key
1002            handler.option_title = info.get('title', handler.get_model_title())
1003            supported[key] = handler
1004            batch_options.append((key, handler.option_title))
1005
1006        schema = colander.SchemaNode(
1007            colander.Mapping(),
1008            colander.SchemaNode(colander.String(), name='batch_type', widget=dfwidget.SelectWidget(values=batch_options)),
1009            colander.SchemaNode(colander.String(), name='description', missing=colander.null),
1010            colander.SchemaNode(colander.String(), name='notes', missing=colander.null),
1011        )
1012
1013        form = forms.Form(schema=schema, request=self.request,
1014                          cancel_url=self.get_index_url())
1015        form.set_type('notes', 'text')
1016
1017        params_forms = {}
1018        for key, handler in supported.items():
1019            make_schema = getattr(self, 'make_batch_params_schema_{}'.format(key), None)
1020            if make_schema:
1021                schema = make_schema()
1022                # must prefix node names with batch key, to guarantee unique
1023                for node in schema:
1024                    node.param_name = node.name
1025                    node.name = '{}_{}'.format(key, node.name)
1026                params_forms[key] = forms.Form(schema=schema, request=self.request)
1027
1028        if self.request.method == 'POST':
1029            if form.validate(newstyle=True):
1030                data = form.validated
1031
1032                # collect general params
1033                batch_key = data['batch_type']
1034                params = {
1035                    'description': data['description'],
1036                    'notes': data['notes']}
1037
1038                # collect batch-type-specific params
1039                pform = params_forms.get(batch_key)
1040                if pform and pform.validate(newstyle=True):
1041                    pdata = pform.validated
1042                    for field in pform.schema:
1043                        param_name = pform.schema[field.name].param_name
1044                        params[param_name] = pdata[field.name]
1045
1046                # TODO: should this be done elsewhere?
1047                for name in params:
1048                    if params[name] is colander.null:
1049                        params[name] = None
1050
1051                handler = supported[batch_key]
1052                products = self.get_products_for_batch(batch_key)
1053                progress = SessionProgress(self.request, 'products.batch')
1054                thread = Thread(target=self.make_batch_thread,
1055                                args=(handler, self.request.user.uuid, products, params, progress))
1056                thread.start()
1057                return self.render_progress(progress, {
1058                    'cancel_url': self.get_index_url(),
1059                    'cancel_msg': "Batch creation was canceled.",
1060                })
1061
1062        return self.render_to_response('batch', {
1063            'form': form,
1064            'dform': form.make_deform_form(), # TODO: hacky? at least is explicit..
1065            'params_forms': params_forms,
1066        })
1067
1068    def get_products_for_batch(self, batch_key):
1069        """
1070        Returns the products query to be used when making a batch (of type
1071        ``batch_key``) with the user's current filters in effect.  You can
1072        override this to add eager joins for certain batch types, etc.
1073        """
1074        return self.get_effective_data()
1075
1076    def make_batch_params_schema_pricing(self):
1077        """
1078        Return params schema for making a pricing batch.
1079        """
1080        return colander.SchemaNode(
1081            colander.Mapping(),
1082            colander.SchemaNode(colander.Decimal(), name='min_diff_threshold',
1083                                quant='1.00', missing=colander.null,
1084                                title="Min $ Diff"),
1085            colander.SchemaNode(colander.Decimal(), name='min_diff_percent',
1086                                quant='1.00', missing=colander.null,
1087                                title="Min % Diff"),
1088            colander.SchemaNode(colander.Boolean(), name='calculate_for_manual'),
1089        )
1090
1091    def make_batch_thread(self, handler, user_uuid, products, params, progress):
1092        """
1093        Threat target for making a batch from current products query.
1094        """
1095        session = RattailSession()
1096        user = session.query(model.User).get(user_uuid)
1097        assert user
1098        params['created_by'] = user
1099        batch = handler.make_batch(session, **params)
1100        batch.products = products.with_session(session).all()
1101        handler.do_populate(batch, user, progress=progress)
1102
1103        session.commit()
1104        session.refresh(batch)
1105        session.close()
1106
1107        progress.session.load()
1108        progress.session['complete'] = True
1109        progress.session['success_url'] = self.get_batch_view_url(batch)
1110        progress.session['success_msg'] = 'Batch has been created: {}'.format(batch)
1111        progress.session.save()
1112
1113    def get_batch_view_url(self, batch):
1114        if batch.batch_key == 'labels':
1115            return self.request.route_url('labels.batch.view', uuid=batch.uuid)
1116        if batch.batch_key == 'pricing':
1117            return self.request.route_url('batch.pricing.view', uuid=batch.uuid)
1118
1119    @classmethod
1120    def defaults(cls, config):
1121        cls._product_defaults(config)
1122        cls._defaults(config)
1123
1124    @classmethod
1125    def _product_defaults(cls, config):
1126        route_prefix = cls.get_route_prefix()
1127        url_prefix = cls.get_url_prefix()
1128        template_prefix = cls.get_template_prefix()
1129        permission_prefix = cls.get_permission_prefix()
1130        model_title = cls.get_model_title()
1131
1132        # print labels
1133        config.add_tailbone_permission('products', 'products.print_labels',
1134                                       "Print labels for products")
1135
1136        # view deleted products
1137        config.add_tailbone_permission('products', 'products.view_deleted',
1138                                       "View products marked as deleted")
1139
1140        # make batch from product query
1141        config.add_tailbone_permission(permission_prefix, '{}.make_batch'.format(permission_prefix),
1142                                       "Create batch from {} query".format(model_title))
1143        config.add_route('{}.make_batch'.format(route_prefix), '{}/make-batch'.format(url_prefix))
1144        config.add_view(cls, attr='make_batch', route_name='{}.make_batch'.format(route_prefix),
1145                        renderer='{}/batch.mako'.format(template_prefix),
1146                        permission='{}.make_batch'.format(permission_prefix))
1147
1148        # search (by upc)
1149        config.add_route('products.search', '/products/search')
1150        config.add_view(cls, attr='search', route_name='products.search',
1151                        renderer='json', permission='products.view')
1152
1153        # product image
1154        config.add_route('products.image', '/products/{uuid}/image')
1155        config.add_view(cls, attr='image', route_name='products.image')
1156
1157        # mobile quick lookup
1158        config.add_route('mobile.products.quick_lookup', '/mobile/products/quick-lookup')
1159        config.add_view(cls, attr='mobile_quick_lookup', route_name='mobile.products.quick_lookup')
1160
1161
1162class ProductsAutocomplete(AutocompleteView):
1163    """
1164    Autocomplete view for products.
1165    """
1166    mapped_class = model.Product
1167    fieldname = 'description'
1168
1169    def query(self, term):
1170        q = Session.query(model.Product).outerjoin(model.Brand)
1171        q = q.filter(sa.or_(
1172                model.Brand.name.ilike('%{}%'.format(term)),
1173                model.Product.description.ilike('%{}%'.format(term))))
1174        if not self.request.has_perm('products.view_deleted'):
1175            q = q.filter(model.Product.deleted == False)
1176        q = q.order_by(model.Brand.name, model.Product.description)
1177        q = q.options(orm.joinedload(model.Product.brand))
1178        return q
1179
1180    def display(self, product):
1181        return product.full_description
1182
1183
1184def print_labels(request):
1185    profile = request.params.get('profile')
1186    profile = Session.query(model.LabelProfile).get(profile) if profile else None
1187    if not profile:
1188        return {'error': "Label profile not found"}
1189
1190    product = request.params.get('product')
1191    product = Session.query(model.Product).get(product) if product else None
1192    if not product:
1193        return {'error': "Product not found"}
1194
1195    quantity = request.params.get('quantity')
1196    if not quantity.isdigit():
1197        return {'error': "Quantity must be numeric"}
1198    quantity = int(quantity)
1199
1200    printer = profile.get_printer(request.rattail_config)
1201    if not printer:
1202        return {'error': "Couldn't get printer from label profile"}
1203
1204    try:
1205        printer.print_labels([(product, quantity, {})])
1206    except Exception as error:
1207        log.warning("error occurred while printing labels", exc_info=True)
1208        return {'error': six.text_type(error)}
1209    return {}
1210
1211
1212def includeme(config):
1213
1214    config.add_route('products.autocomplete', '/products/autocomplete')
1215    config.add_view(ProductsAutocomplete, route_name='products.autocomplete',
1216                    renderer='json', permission='products.list')
1217
1218    config.add_route('products.print_labels', '/products/labels')
1219    config.add_view(print_labels, route_name='products.print_labels',
1220                    renderer='json', permission='products.print_labels')
1221
1222    ProductsView.defaults(config)
Note: See TracBrowser for help on using the repository browser.