source: tailbone/tailbone/views/products.py @ 5a96672

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

Log details of one-off label printing error, when they occur

needed for troubleshooting

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