source: tailbone/tailbone/views/customers.py @ 1908092

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

Declare "is contact" for the Customers view

removes some duplicated code. also this adds CustomerNote? to version history

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