source: tailbone/tailbone/views/customers.py @ 13bba63

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

Remove 'number' column for Customers grid by default

  • Property mode set to 100644
File size: 16.3 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"""
24Customer Views
25"""
26
27from __future__ import unicode_literals, absolute_import
28
29import re
30
31import six
32import sqlalchemy as sa
33from sqlalchemy import orm
34
35import colander
36from deform import widget as dfwidget
37from pyramid.httpexceptions import HTTPNotFound
38from webhelpers2.html import HTML, tags
39
40from tailbone import grids
41from tailbone.db import Session
42from tailbone.views import MasterView, AutocompleteView
43
44from rattail.db import model
45
46
47class CustomersView(MasterView):
48    """
49    Master view for the Customer class.
50    """
51    model_class = model.Customer
52    is_contact = True
53    has_versions = True
54    supports_mobile = True
55    people_detachable = True
56
57    labels = {
58        'id': "ID",
59        'default_phone': "Phone Number",
60        'default_email': "Email Address",
61        'default_address': "Physical Address",
62        'active_in_pos': "Active in POS",
63        'active_in_pos_sticky': "Always Active in POS",
64    }
65
66    grid_columns = [
67        'id',
68        'name',
69        'phone',
70        'email',
71    ]
72
73    form_fields = [
74        'id',
75        'name',
76        'default_phone',
77        'default_address',
78        'address_street',
79        'address_street2',
80        'address_city',
81        'address_state',
82        'address_zipcode',
83        'default_email',
84        'email_preference',
85        'wholesale',
86        'active_in_pos',
87        'active_in_pos_sticky',
88        'people',
89        'groups',
90    ]
91
92    mobile_form_fields = [
93        'id',
94        'name',
95        'default_phone',
96        'default_email',
97        'default_address',
98        'email_preference',
99        'wholesale',
100        'active_in_pos',
101        'active_in_pos_sticky',
102        'people',
103        'groups',
104    ]
105
106    def configure_grid(self, g):
107        super(CustomersView, self).configure_grid(g)
108
109        # name
110        g.filters['name'].default_active = True
111        g.filters['name'].default_verb = 'contains'
112        g.set_sort_defaults('name')
113
114        # phone
115        g.set_joiner('phone', lambda q: q.outerjoin(model.CustomerPhoneNumber, sa.and_(
116            model.CustomerPhoneNumber.parent_uuid == model.Customer.uuid,
117            model.CustomerPhoneNumber.preference == 1)))
118        g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.CustomerPhoneNumber.number, d)())
119        g.set_filter('phone', model.CustomerPhoneNumber.number)#, label="Phone Number")
120        g.set_label('phone', "Phone Number")
121
122        # email
123        g.set_joiner('email', lambda q: q.outerjoin(model.CustomerEmailAddress, sa.and_(
124            model.CustomerEmailAddress.parent_uuid == model.Customer.uuid,
125            model.CustomerEmailAddress.preference == 1)))
126        g.sorters['email'] = lambda q, d: q.order_by(getattr(model.CustomerEmailAddress.address, d)())
127        g.set_filter('email', model.CustomerEmailAddress.address)#, label="Email Address")
128        g.set_label('email', "Email Address")
129
130        # email_preference
131        g.set_enum('email_preference', self.enum.EMAIL_PREFERENCE)
132
133        # person
134        g.set_joiner('person', lambda q:
135                     q.outerjoin(model.CustomerPerson,
136                                 sa.and_(
137                                     model.CustomerPerson.customer_uuid == model.Customer.uuid,
138                                     model.CustomerPerson.ordinal == 1))\
139                     .outerjoin(model.Person))
140        g.set_sorter('person', model.Person.display_name)
141        g.set_renderer('person', self.grid_render_person)
142
143        g.set_link('id')
144        g.set_link('number')
145        g.set_link('name')
146        g.set_link('person')
147        g.set_link('email')
148
149    def get_mobile_data(self, session=None):
150        # TODO: hacky!
151        return self.get_data(session=session).order_by(model.Customer.name)
152
153    def get_instance(self):
154        try:
155            instance = super(CustomersView, self).get_instance()
156        except HTTPNotFound:
157            pass
158        else:
159            if instance:
160                return instance
161
162        key = self.request.matchdict['uuid']
163
164        # search by Customer.id
165        instance = self.Session.query(model.Customer)\
166                               .filter(model.Customer.id == key)\
167                               .first()
168        if instance:
169            return instance
170
171        # search by CustomerPerson.uuid
172        instance = self.Session.query(model.CustomerPerson).get(key)
173        if instance:
174            return instance.customer
175
176        # search by CustomerGroupAssignment.uuid
177        instance = self.Session.query(model.CustomerGroupAssignment).get(key)
178        if instance:
179            return instance.customer
180
181        raise HTTPNotFound
182
183    def configure_common_form(self, f):
184        super(CustomersView, self).configure_common_form(f)
185        customer = f.model_instance
186        permission_prefix = self.get_permission_prefix()
187
188        f.set_renderer('default_email', self.render_default_email)
189        if not self.creating and customer.emails:
190            f.set_default('default_email', customer.emails[0].address)
191
192        f.set_renderer('default_phone', self.render_default_phone)
193        if not self.creating and customer.phones:
194            f.set_default('default_phone', customer.phones[0].number)
195
196        # default_address
197        if self.creating or self.editing:
198            f.remove_field('default_address')
199        else:
200            f.set_renderer('default_address', self.render_default_address)
201            f.set_readonly('default_address')
202
203        # address_*
204        if not (self.creating or self.editing):
205            f.remove_fields('address_street',
206                            'address_street2',
207                            'address_city',
208                            'address_state',
209                            'address_zipcode')
210        elif self.editing and customer.addresses:
211            addr = customer.addresses[0]
212            f.set_default('address_street', addr.street)
213            f.set_default('address_street2', addr.street2)
214            f.set_default('address_city', addr.city)
215            f.set_default('address_state', addr.state)
216            f.set_default('address_zipcode', addr.zipcode)
217
218        f.set_enum('email_preference', self.enum.EMAIL_PREFERENCE)
219        preferences = list(self.enum.EMAIL_PREFERENCE.items())
220        preferences.insert(0, ('', "(no preference)"))
221        f.set_widget('email_preference', dfwidget.SelectWidget(values=preferences))
222
223        # person
224        if self.creating:
225            f.remove_field('person')
226        else:
227            f.set_readonly('person')
228            f.set_renderer('person', self.form_render_person)
229
230        # people
231        if self.creating:
232            f.remove_field('people')
233        elif self.viewing and self.request.has_perm('{}.detach_person'.format(permission_prefix)):
234            f.set_renderer('people', self.render_people_removable)
235        else:
236            f.set_renderer('people', self.render_people)
237            f.set_readonly('people')
238
239        # groups
240        if self.creating:
241            f.remove_field('groups')
242        else:
243            f.set_renderer('groups', self.render_groups)
244            f.set_readonly('groups')
245
246    def unique_id(self, node, value):
247        query = self.Session.query(model.Customer)\
248                            .filter(model.Customer.id == value)
249        if self.editing:
250            customer = self.get_instance()
251            query = query.filter(model.Customer.uuid != customer.uuid)
252        if query.count():
253            raise colander.Invalid(node, "Customer ID must be unique")
254
255    def render_default_email(self, customer, field):
256        if customer.emails:
257            return customer.emails[0].address
258
259    def render_default_phone(self, customer, field):
260        if customer.phones:
261            return customer.phones[0].number
262
263    def render_default_address(self, customer, field):
264        if customer.addresses:
265            return six.text_type(customer.addresses[0])
266
267    def grid_render_person(self, customer, field):
268        person = getattr(customer, field)
269        if not person:
270            return ""
271        return six.text_type(person)
272
273    def form_render_person(self, customer, field):
274        person = getattr(customer, field)
275        if not person:
276            return ""
277
278        text = six.text_type(person)
279        url = self.request.route_url('people.view', uuid=person.uuid)
280        return tags.link_to(text, url)
281
282    def render_people(self, customer, field):
283        people = customer.people
284        if not people:
285            return ""
286
287        items = []
288        for person in people:
289            text = six.text_type(person)
290            route = '{}people.view'.format('mobile.' if self.mobile else '')
291            url = self.request.route_url(route, uuid=person.uuid)
292            link = tags.link_to(text, url)
293            items.append(HTML.tag('li', c=[link]))
294        return HTML.tag('ul', c=items)
295
296    def render_people_removable(self, customer, field):
297        people = customer.people
298        if not people:
299            return ""
300
301        route_prefix = self.get_route_prefix()
302        permission_prefix = self.get_permission_prefix()
303
304        view_url = lambda p, i: self.request.route_url('people.view', uuid=p.uuid)
305        actions = [
306            grids.GridAction('view', icon='zoomin', url=view_url),
307        ]
308        if self.people_detachable and self.request.has_perm('{}.detach_person'.format(permission_prefix)):
309            url = lambda p, i: self.request.route_url('{}.detach_person'.format(route_prefix),
310                                                      uuid=customer.uuid, person_uuid=p.uuid)
311            actions.append(
312                grids.GridAction('detach', icon='trash', url=url))
313
314        columns = ['first_name', 'last_name', 'display_name']
315        g = grids.Grid(
316            key='{}.people'.format(route_prefix),
317            data=customer.people,
318            columns=columns,
319            labels={'display_name': "Full Name"},
320            url=lambda p: self.request.route_url('people.view', uuid=p.uuid),
321            linked_columns=columns,
322            main_actions=actions)
323        return HTML.literal(g.render_grid())
324
325    def render_groups(self, customer, field):
326        groups = customer.groups
327        if not groups:
328            return ""
329        items = []
330        for group in groups:
331            text = "({}) {}".format(group.id, group.name)
332            url = self.request.route_url('customergroups.view', uuid=group.uuid)
333            items.append(HTML.tag('li', tags.link_to(text, url)))
334        return HTML.tag('ul', HTML.literal('').join(items))
335
336    def get_version_child_classes(self):
337        return [
338            (model.CustomerPhoneNumber, 'parent_uuid'),
339            (model.CustomerEmailAddress, 'parent_uuid'),
340            (model.CustomerMailingAddress, 'parent_uuid'),
341            (model.CustomerPerson, 'customer_uuid'),
342            (model.CustomerNote, 'parent_uuid'),
343        ]
344
345    def detach_person(self):
346        customer = self.get_instance()
347        person = self.Session.query(model.Person).get(self.request.matchdict['person_uuid'])
348        if not person:
349            return self.notfound()
350
351        if person in customer.people:
352            customer.people.remove(person)
353        else:
354            self.request.session.flash("No change; person \"{}\" not attached to customer \"{}\"".format(
355                person, customer))
356
357        return self.redirect(self.request.get_referrer())
358
359    @classmethod
360    def defaults(cls, config):
361        route_prefix = cls.get_route_prefix()
362        url_prefix = cls.get_url_prefix()
363        permission_prefix = cls.get_permission_prefix()
364        model_key = cls.get_model_key()
365        model_title = cls.get_model_title()
366
367        cls._defaults(config)
368
369        # detach person
370        if cls.people_detachable:
371            config.add_tailbone_permission(permission_prefix, '{}.detach_person'.format(permission_prefix),
372                                           "Detach a Person from a {}".format(model_title))
373            config.add_route('{}.detach_person'.format(route_prefix), '{}/{{{}}}/detach-person/{{person_uuid}}'.format(url_prefix, model_key),
374                             # request_method='POST',
375            )
376            config.add_view(cls, attr='detach_person', route_name='{}.detach_person'.format(route_prefix),
377                            permission='{}.detach_person'.format(permission_prefix))
378
379
380# # TODO: this is referenced by some custom apps, but should be moved??
381# def unique_id(value, field):
382#     customer = field.parent.model
383#     query = Session.query(model.Customer).filter(model.Customer.id == value)
384#     if customer.uuid:
385#         query = query.filter(model.Customer.uuid != customer.uuid)
386#     if query.count():
387#         raise fa.ValidationError("Customer ID must be unique")
388
389
390# TODO: this only works when creating, need to add edit support?
391# TODO: can this just go away? since we have unique_id() view method above
392def unique_id(node, value):
393    customers = Session.query(model.Customer).filter(model.Customer.id == value)
394    if customers.count():
395        raise colander.Invalid(node, "Customer ID must be unique")
396
397
398class CustomerNameAutocomplete(AutocompleteView):
399    """
400    Autocomplete view which operates on customer name.
401    """
402    mapped_class = model.Customer
403    fieldname = 'name'
404
405
406class CustomerPhoneAutocomplete(AutocompleteView):
407    """
408    Autocomplete view which operates on customer phone number.
409
410    .. note::
411       As currently implemented, this view will only work with a PostgreSQL
412       database.  It normalizes the user's search term and the database values
413       to numeric digits only (i.e. removes special characters from each) in
414       order to be able to perform smarter matching.  However normalizing the
415       database value currently uses the PG SQL ``regexp_replace()`` function.
416    """
417    invalid_pattern = re.compile(r'\D')
418
419    def prepare_term(self, term):
420        return self.invalid_pattern.sub('', term)
421
422    def query(self, term):
423        return Session.query(model.CustomerPhoneNumber)\
424            .filter(sa.func.regexp_replace(model.CustomerPhoneNumber.number, r'\D', '', 'g').like('%{0}%'.format(term)))\
425            .order_by(model.CustomerPhoneNumber.number)\
426            .options(orm.joinedload(model.CustomerPhoneNumber.customer))
427
428    def display(self, phone):
429        return "{0} {1}".format(phone.number, phone.customer)
430
431    def value(self, phone):
432        return phone.customer.uuid
433
434
435def customer_info(request):
436    """
437    View which returns simple dictionary of info for a particular customer.
438    """
439    uuid = request.params.get('uuid')
440    customer = Session.query(model.Customer).get(uuid) if uuid else None
441    if not customer:
442        return {}
443    return {
444        'uuid':                 customer.uuid,
445        'name':                 customer.name,
446        'phone_number':         customer.phone.number if customer.phone else '',
447        }
448
449
450def includeme(config):
451
452    # autocomplete
453    config.add_route('customers.autocomplete', '/customers/autocomplete')
454    config.add_view(CustomerNameAutocomplete, route_name='customers.autocomplete',
455                    renderer='json', permission='customers.list')
456    config.add_route('customers.autocomplete.phone', '/customers/autocomplete/phone')
457    config.add_view(CustomerPhoneAutocomplete, route_name='customers.autocomplete.phone',
458                    renderer='json', permission='customers.list')
459
460    # info
461    config.add_route('customer.info', '/customers/info')
462    config.add_view(customer_info, route_name='customer.info',
463                    renderer='json', permission='customers.view')
464
465    CustomersView.defaults(config)
Note: See TracBrowser for help on using the repository browser.