source: tailbone/tailbone/views/customers.py @ 6a57e51

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

Add unique_id() validator method to Customer view

  • Property mode set to 100644
File size: 16.5 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    has_versions = True
53    supports_mobile = True
54    people_detachable = True
55
56    labels = {
57        'id': "ID",
58        'default_phone': "Phone Number",
59        'default_email': "Email Address",
60        'default_address': "Physical Address",
61        'active_in_pos': "Active in POS",
62        'active_in_pos_sticky': "Always Active in POS",
63    }
64
65    grid_columns = [
66        'id',
67        'number',
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 objectify(self, form, data=None):
256        if data is None:
257            data = form.validated
258        customer = super(CustomersView, self).objectify(form, data)
259        customer = self.objectify_contact(customer, data)
260        return customer
261
262    def render_default_email(self, customer, field):
263        if customer.emails:
264            return customer.emails[0].address
265
266    def render_default_phone(self, customer, field):
267        if customer.phones:
268            return customer.phones[0].number
269
270    def render_default_address(self, customer, field):
271        if customer.addresses:
272            return six.text_type(customer.addresses[0])
273
274    def grid_render_person(self, customer, field):
275        person = getattr(customer, field)
276        if not person:
277            return ""
278        return six.text_type(person)
279
280    def form_render_person(self, customer, field):
281        person = getattr(customer, field)
282        if not person:
283            return ""
284
285        text = six.text_type(person)
286        url = self.request.route_url('people.view', uuid=person.uuid)
287        return tags.link_to(text, url)
288
289    def render_people(self, customer, field):
290        people = customer.people
291        if not people:
292            return ""
293
294        items = []
295        for person in people:
296            text = six.text_type(person)
297            route = '{}people.view'.format('mobile.' if self.mobile else '')
298            url = self.request.route_url(route, uuid=person.uuid)
299            link = tags.link_to(text, url)
300            items.append(HTML.tag('li', c=[link]))
301        return HTML.tag('ul', c=items)
302
303    def render_people_removable(self, customer, field):
304        people = customer.people
305        if not people:
306            return ""
307
308        route_prefix = self.get_route_prefix()
309        permission_prefix = self.get_permission_prefix()
310
311        view_url = lambda p, i: self.request.route_url('people.view', uuid=p.uuid)
312        actions = [
313            grids.GridAction('view', icon='zoomin', url=view_url),
314        ]
315        if self.people_detachable and self.request.has_perm('{}.detach_person'.format(permission_prefix)):
316            url = lambda p, i: self.request.route_url('{}.detach_person'.format(route_prefix),
317                                                      uuid=customer.uuid, person_uuid=p.uuid)
318            actions.append(
319                grids.GridAction('detach', icon='trash', url=url))
320
321        columns = ['first_name', 'last_name', 'display_name']
322        g = grids.Grid(
323            key='{}.people'.format(route_prefix),
324            data=customer.people,
325            columns=columns,
326            labels={'display_name': "Full Name"},
327            url=lambda p: self.request.route_url('people.view', uuid=p.uuid),
328            linked_columns=columns,
329            main_actions=actions)
330        return HTML.literal(g.render_grid())
331
332    def render_groups(self, customer, field):
333        groups = customer.groups
334        if not groups:
335            return ""
336        items = []
337        for group in groups:
338            text = "({}) {}".format(group.id, group.name)
339            url = self.request.route_url('customergroups.view', uuid=group.uuid)
340            items.append(HTML.tag('li', tags.link_to(text, url)))
341        return HTML.tag('ul', HTML.literal('').join(items))
342
343    def get_version_child_classes(self):
344        return [
345            (model.CustomerPhoneNumber, 'parent_uuid'),
346            (model.CustomerEmailAddress, 'parent_uuid'),
347            (model.CustomerMailingAddress, 'parent_uuid'),
348            (model.CustomerPerson, 'customer_uuid'),
349        ]
350
351    def detach_person(self):
352        customer = self.get_instance()
353        person = self.Session.query(model.Person).get(self.request.matchdict['person_uuid'])
354        if not person:
355            return self.notfound()
356
357        if person in customer.people:
358            customer.people.remove(person)
359        else:
360            self.request.session.flash("No change; person \"{}\" not attached to customer \"{}\"".format(
361                person, customer))
362
363        return self.redirect(self.request.get_referrer())
364
365    @classmethod
366    def defaults(cls, config):
367        route_prefix = cls.get_route_prefix()
368        url_prefix = cls.get_url_prefix()
369        permission_prefix = cls.get_permission_prefix()
370        model_key = cls.get_model_key()
371        model_title = cls.get_model_title()
372
373        cls._defaults(config)
374
375        # detach person
376        if cls.people_detachable:
377            config.add_tailbone_permission(permission_prefix, '{}.detach_person'.format(permission_prefix),
378                                           "Detach a Person from a {}".format(model_title))
379            config.add_route('{}.detach_person'.format(route_prefix), '{}/{{{}}}/detach-person/{{person_uuid}}'.format(url_prefix, model_key),
380                             # request_method='POST',
381            )
382            config.add_view(cls, attr='detach_person', route_name='{}.detach_person'.format(route_prefix),
383                            permission='{}.detach_person'.format(permission_prefix))
384
385
386# # TODO: this is referenced by some custom apps, but should be moved??
387# def unique_id(value, field):
388#     customer = field.parent.model
389#     query = Session.query(model.Customer).filter(model.Customer.id == value)
390#     if customer.uuid:
391#         query = query.filter(model.Customer.uuid != customer.uuid)
392#     if query.count():
393#         raise fa.ValidationError("Customer ID must be unique")
394
395
396# TODO: this only works when creating, need to add edit support?
397# TODO: can this just go away? since we have unique_id() view method above
398def unique_id(node, value):
399    customers = Session.query(model.Customer).filter(model.Customer.id == value)
400    if customers.count():
401        raise colander.Invalid(node, "Customer ID must be unique")
402
403
404class CustomerNameAutocomplete(AutocompleteView):
405    """
406    Autocomplete view which operates on customer name.
407    """
408    mapped_class = model.Customer
409    fieldname = 'name'
410
411
412class CustomerPhoneAutocomplete(AutocompleteView):
413    """
414    Autocomplete view which operates on customer phone number.
415
416    .. note::
417       As currently implemented, this view will only work with a PostgreSQL
418       database.  It normalizes the user's search term and the database values
419       to numeric digits only (i.e. removes special characters from each) in
420       order to be able to perform smarter matching.  However normalizing the
421       database value currently uses the PG SQL ``regexp_replace()`` function.
422    """
423    invalid_pattern = re.compile(r'\D')
424
425    def prepare_term(self, term):
426        return self.invalid_pattern.sub('', term)
427
428    def query(self, term):
429        return Session.query(model.CustomerPhoneNumber)\
430            .filter(sa.func.regexp_replace(model.CustomerPhoneNumber.number, r'\D', '', 'g').like('%{0}%'.format(term)))\
431            .order_by(model.CustomerPhoneNumber.number)\
432            .options(orm.joinedload(model.CustomerPhoneNumber.customer))
433
434    def display(self, phone):
435        return "{0} {1}".format(phone.number, phone.customer)
436
437    def value(self, phone):
438        return phone.customer.uuid
439
440
441def customer_info(request):
442    """
443    View which returns simple dictionary of info for a particular customer.
444    """
445    uuid = request.params.get('uuid')
446    customer = Session.query(model.Customer).get(uuid) if uuid else None
447    if not customer:
448        return {}
449    return {
450        'uuid':                 customer.uuid,
451        'name':                 customer.name,
452        'phone_number':         customer.phone.number if customer.phone else '',
453        }
454
455
456def includeme(config):
457
458    # autocomplete
459    config.add_route('customers.autocomplete', '/customers/autocomplete')
460    config.add_view(CustomerNameAutocomplete, route_name='customers.autocomplete',
461                    renderer='json', permission='customers.list')
462    config.add_route('customers.autocomplete.phone', '/customers/autocomplete/phone')
463    config.add_view(CustomerPhoneAutocomplete, route_name='customers.autocomplete.phone',
464                    renderer='json', permission='customers.list')
465
466    # info
467    config.add_route('customer.info', '/customers/info')
468    config.add_view(customer_info, route_name='customer.info',
469                    renderer='json', permission='customers.view')
470
471    CustomersView.defaults(config)
Note: See TracBrowser for help on using the repository browser.