Changeset - 331828e9ced1
[Not reviewed]
0 3 0
Lance Edgar (lance) - 3 years ago 2021-11-06 20:31:02
lance@edbob.org
Add support for finding past items, for new custorder

also misc. tweaks for new custorder feature
3 files changed with 46 insertions and 3 deletions:
0 comments (0 inline, 0 general)
rattail/app.py
Show inline comments
 
# -*- coding: utf-8; -*-
 
################################################################################
 
#
 
#  Rattail -- Retail Software Framework
 
#  Copyright © 2010-2021 Lance Edgar
 
#
 
#  This file is part of Rattail.
 
#
 
#  Rattail is free software: you can redistribute it and/or modify it under the
 
#  terms of the GNU General Public License as published by the Free Software
 
#  Foundation, either version 3 of the License, or (at your option) any later
 
#  version.
 
#
 
#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
 
#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 
#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
 
#  details.
 
#
 
#  You should have received a copy of the GNU General Public License along with
 
#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
 
#
 
################################################################################
 
"""
 
App Handler
 
"""
 

	
 
from __future__ import unicode_literals, absolute_import
 

	
 
import os
 
# import re
 
import tempfile
 

	
 
from sqlalchemy import orm
 

	
 
from rattail.util import load_object, pretty_quantity, progress_loop
 
from rattail.files import temp_path
 
from rattail.mail import send_email
 

	
 

	
 
class AppHandler(object):
 
    """
 
    Base class and default implementation for top-level Rattail app handler.
 

	
 
    aka. "the handler to handle all handlers"
 

	
 
    aka. "one handler to bind them all"
 
    """
 
    default_autocompleters = {
 
        'brands': 'rattail.autocomplete.brands:BrandAutocompleter',
 
        'customers': 'rattail.autocomplete.customers:CustomerAutocompleter',
 
        'customers.neworder': 'rattail.autocomplete.customers:CustomerNewOrderAutocompleter',
 
        'customers.phone': 'rattail.autocomplete.customers:CustomerPhoneAutocompleter',
 
        'employees': 'rattail.autocomplete.employees:EmployeeAutocompleter',
 
        'departments': 'rattail.autocomplete.departments:DepartmentAutocompleter',
 
        'people': 'rattail.autocomplete.people:PersonAutocompleter',
 
        'people.employees': 'rattail.autocomplete.people:PersonEmployeeAutocompleter',
 
        'people.neworder': 'rattail.autocomplete.people:PersonNewOrderAutocompleter',
 
        'products': 'rattail.autocomplete.products:ProductAutocompleter',
 
        'products.all': 'rattail.autocomplete.products:ProductAllAutocompleter',
 
        'products.neworder': 'rattail.autocomplete.products:ProductNewOrderAutocompleter',
 
        'vendors': 'rattail.autocomplete.vendors:VendorAutocompleter',
 
    }
 

	
 
    def __init__(self, config):
 
        self.config = config
 

	
 
    def get_autocompleter(self, key, **kwargs):
 
        """
 
        Returns a new :class:`~rattail.autocomplete.base.Autocompleter`
 
        instance corresponding to the given key, e.g. ``'products'``.
 

	
 
        The app handler has some hard-coded defaults for the built-in
 
        autocompleters (see ``default_autocompleters`` in the source
 
        code).  You can override any of these, and/or add your own
 
        with custom keys, via config, e.g.:
 

	
 
        .. code-block:: ini
 

	
 
           [rattail]
 
           autocomplete.products = poser.autocomplete.products:ProductAutocompleter
 
           autocomplete.otherthings = poser.autocomplete.things:OtherThingAutocompleter
 

	
 
        With the above you can then fetch your custom autocompleter with::
 

	
 
           autocompleter = app.get_autocompleter('otherthings')
 

	
 
        In any case if it can locate the class, it will create an
 
        instance of it and return that.
 

	
 
        :params key: Unique key for the type of autocompleter you
 
           need.  Often is a simple string, e.g. ``'customers'`` but
 
           sometimes there may be a "modifier" with it to get an
 
           autocompleter with more specific behavior.
 

	
 
           For instance ``'customers.phone'`` would effectively give
 
           you a customer autocompleter but which searched by phone
 
           number instead of customer name.
 

	
 
           Note that each key is still a simple string though, and that
 
           must be "unique" in the sense that only one autocompleter
 
           can be configured for each key.
 

	
 
        :returns: An :class:`~rattail.autocomplete.base.Autocompleter`
 
           instance if found, otherwise ``None``.
 
        """
 
        spec = self.config.get('rattail', 'autocomplete.{}'.format(key))
 
        if not spec:
 
            spec = self.default_autocompleters.get(key)
 
        if spec:
 
            return load_object(spec)(self.config)
 

	
 
        raise NotImplementedError("cannot locate autocompleter for key: {}".format(key))
 

	
 
    def get_auth_handler(self, **kwargs):
 
        if not hasattr(self, 'auth_handler'):
 
            spec = self.config.get('rattail', 'auth.handler',
 
                                   default='rattail.auth:AuthHandler')
 
            factory = load_object(spec)
 
            self.auth_handler = factory(self.config, **kwargs)
 
        return self.auth_handler
 

	
 
    def get_batch_handler(self, key, **kwargs):
 
        from rattail.batch import get_batch_handler
 
        return get_batch_handler(self.config, key, **kwargs)
 

	
 
    def get_board_handler(self, **kwargs):
 
        if not hasattr(self, 'board_handler'):
 
            from rattail.board import get_board_handler
 
            self.board_handler = get_board_handler(self.config, **kwargs)
 
        return self.board_handler
 

	
 
    def get_clientele_handler(self, **kwargs):
 
        if not hasattr(self, 'clientele_handler'):
 
            from rattail.clientele import get_clientele_handler
 
            self.clientele_handler = get_clientele_handler(self.config, **kwargs)
 
        return self.clientele_handler
 

	
 
    def get_employment_handler(self, **kwargs):
 
        if not hasattr(self, 'employment_handler'):
 
            from rattail.employment import get_employment_handler
 
            self.employment_handler = get_employment_handler(self.config, **kwargs)
 
        return self.employment_handler
 

	
 
    def get_feature_handler(self, **kwargs):
 
        if not hasattr(self, 'feature_handler'):
 
            from rattail.features import FeatureHandler
 
            self.feature_handler = FeatureHandler(self.config, **kwargs)
 
        return self.feature_handler
 

	
 
    def get_email_handler(self, **kwargs):
 
        if not hasattr(self, 'email_handler'):
 
            from rattail.mail import get_email_handler
 
            self.email_handler = get_email_handler(self.config, **kwargs)
 
        return self.email_handler
 

	
 
    # TODO: is it helpful to expose this? or more confusing?
 
    get_mail_handler = get_email_handler
 

	
 
    def get_membership_handler(self, **kwargs):
 
        """
 
        Returns a reference to the configured Membership Handler.
 

	
 
        See also :doc:`rattail-manual:base/handlers/other/membership`.
 
        """
 
        if not hasattr(self, 'membership_handler'):
 
            spec = self.config.get('rattail', 'membership.handler',
 
                                   default='rattail.membership:MembershipHandler')
 
            factory = load_object(spec)
 
            self.membership_handler = factory(self.config, **kwargs)
 
        return self.membership_handler
 

	
 
    def get_people_handler(self, **kwargs):
 
        """
 
        Returns a reference to the configured People Handler.
 

	
 
        See also :doc:`rattail-manual:base/handlers/other/people`.
 
        """
 
        if not hasattr(self, 'people_handler'):
 
            spec = self.config.get('rattail', 'people.handler',
 
                                   default='rattail.people:PeopleHandler')
 
            factory = load_object(spec)
 
            self.people_handler = factory(self.config, **kwargs)
 
        return self.people_handler
 

	
 
    def get_products_handler(self, **kwargs):
 
        if not hasattr(self, 'products_handler'):
 
            from rattail.products import get_products_handler
 
            self.products_handler = get_products_handler(self.config, **kwargs)
 
        return self.products_handler
 

	
 
    def get_report_handler(self, **kwargs):
 
        if not hasattr(self, 'report_handler'):
 
            from rattail.reporting import get_report_handler
 
            self.report_handler = get_report_handler(self.config, **kwargs)
 
        return self.report_handler
 

	
 
    def progress_loop(self, *args, **kwargs):
 
        return progress_loop(*args, **kwargs)
 

	
 
    def get_session(self, obj):
 
        """
 
        Returns the SQLAlchemy session with which the given object is
 
        associated.  Simple convenience wrapper around
 
        ``sqlalchemy.orm.object_session()``.
 
        """
 
        return orm.object_session(obj)
 

	
 
    def make_session(self, **kwargs):
 
        """
 
        Creates and returns a new SQLAlchemy session for the Rattail DB.
 
        """
 
        from rattail.db import Session
 
        return Session(**kwargs)
 

	
 
    def cache_model(self, session, model, **kwargs):
 
        """
 
        Convenience method which invokes
 
        :func:`rattail.db.cache.cache_model()` with the given model
 
        and keyword arguments.
 
        """
 
        from rattail.db import cache
 
        return cache.cache_model(session, model, **kwargs)
 

	
 
    def make_temp_dir(self, **kwargs):
 
        """
 
        Create a temporary directory.  This is mostly a convenience wrapper
 
        around the built-in ``tempfile.mkdtemp()``.
 
        """
 
        if 'dir' not in kwargs:
 
            workdir = self.config.workdir(require=False)
 
            if workdir:
 
                tmpdir = os.path.join(workdir, 'tmp')
 
                if not os.path.exists(tmpdir):
 
                    os.makedirs(tmpdir)
 
                kwargs['dir'] = tmpdir
 
        return tempfile.mkdtemp(**kwargs)
 

	
 
    def make_temp_file(self, **kwargs):
 
        """
 
        Reserve a temporary filename.  This is mostly a convenience wrapper
 
        around the built-in ``tempfile.mkstemp()``.
 
        """
 
        if 'dir' not in kwargs:
 
            workdir = self.config.workdir(require=False)
 
            if workdir:
 
                tmpdir = os.path.join(workdir, 'tmp')
 
                if not os.path.exists(tmpdir):
 
                    os.makedirs(tmpdir)
 
                kwargs['dir'] = tmpdir
 
        return temp_path(**kwargs)
 

	
 
    def normalize_phone_number(self, number):
 
        """
 
        Normalize the given phone number, to a "common" format that
 
        can be more easily worked with for sync logic etc.
 
        """
 
        from rattail.db.util import normalize_phone_number
 

	
 
        return normalize_phone_number(number)
 

	
 
    def phone_number_is_invalid(self, number):
 
        """
 
        This method should validate the given phone number string, and if the
 
        number is *not* considered valid, this method should return the reason
 
        as string.  If the number is valid, returns ``None``.
 
        """
 
        # strip non-numeric chars, and make sure we have 10 left
 
        normal = self.normalize_phone_number(number)
 
        if len(normal) != 10:
 
            return "Phone number must have 10 digits"
 

	
 
    def format_phone_number(self, number):
 
        """
 
        Returns a "properly formatted" string based on the given phone number.
 
        """
 
        from rattail.db.util import format_phone_number
 

	
 
        return format_phone_number(number)
 

	
 
    def render_currency(self, value, scale=2, **kwargs):
 
        """
 
        Must return a human-friendly display string for the given currency
 
        value, e.g. ``Decimal('4.20')`` becomes ``"$4.20"``.
 
        """
 
        if value is not None:
 
            if value < 0:
 
                fmt = "(${{:0,.{}f}})".format(scale)
 
                return fmt.format(0 - value)
 
            fmt = "${{:0,.{}f}}".format(scale)
 
            return fmt.format(value)
 

	
 
    def render_quantity(self, value, **kwargs):
 
        """
 
        Return a human-friendly display string for the given quantity
 
        value, e.g. ``1.000`` becomes ``"1"``.
 
        """
 
        return pretty_quantity(value, **kwargs)
 

	
 
    def render_date(self, value, **kwargs):
 
        """
 
        Must return a human-friendly display string for the given ``date``
 
        object.
 
        """
 
        if value is not None:
 
            return value.strftime('%Y-%m-%d')
 

	
 
    def render_datetime(self, value, **kwargs):
 
        """
 
        Must return a human-friendly display string for the given ``datetime``
 
        object.
 
        """
 
        if value is not None:
 
            return value.strftime('%Y-%m-%d %I:%M:%S %p')
 

	
 
    def send_email(self, key, data={}, **kwargs):
 
        """
 
        Send an email message of the given type.
rattail/batch/custorder.py
Show inline comments
 
# -*- coding: utf-8; -*-
 
################################################################################
 
#
 
#  Rattail -- Retail Software Framework
 
#  Copyright © 2010-2021 Lance Edgar
 
#
 
#  This file is part of Rattail.
 
#
 
#  Rattail is free software: you can redistribute it and/or modify it under the
 
#  terms of the GNU General Public License as published by the Free Software
 
#  Foundation, either version 3 of the License, or (at your option) any later
 
#  version.
 
#
 
#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
 
#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 
#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
 
#  details.
 
#
 
#  You should have received a copy of the GNU General Public License along with
 
#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
 
#
 
################################################################################
 
"""
 
Handler for "customer order" batches
 
"""
 

	
 
from __future__ import unicode_literals, absolute_import, division
 

	
 
import re
 
import decimal
 

	
 
import six
 
import sqlalchemy as sa
 
from sqlalchemy import orm
 

	
 
from rattail.db import model
 
from rattail.batch import BatchHandler
 
from rattail.util import OrderedDict
 

	
 

	
 
class CustomerOrderBatchHandler(BatchHandler):
 
    """
 
    Handler for all "customer order" batches, regardless of "mode".  The
 
    handler must inspect the
 
    :attr:`~rattail.db.model.batch.custorder.CustomerOrderBatch.mode` attribute
 
    of each batch it deals with, in order to determine which logic to apply.
 

	
 
    .. attribute:: has_custom_product_autocomplete
 

	
 
       If true, this flag indicates that the handler provides custom
 
       autocomplete logic for use when selecting a product while
 
       creating a new order.
 
    """
 
    batch_model_class = model.CustomerOrderBatch
 
    has_custom_product_autocomplete = False
 
    nondigits_pattern = re.compile(r'\D')
 

	
 
    def init_batch(self, batch, progress=None, **kwargs):
 
        """
 
        Assign the "local" store to the batch, if applicable.
 
        """
 
        session = self.app.get_session(batch)
 
        batch.store = self.config.get_store(session)
 

	
 
    def new_order_requires_customer(self):
 
        """
 
        Returns a boolean indicating whether a *new* "customer order"
 
        in fact requires a proper customer account, or not.  Note that
 
        in all cases a new order requires a *person* to associate
 
        with, but technically the customer is optional, unless this
 
        returns true.
 
        """
 
        return self.config.getbool('rattail.custorders',
 
                                   'new_order_requires_customer',
 
                                   default=False)
 

	
 
    def allow_contact_info_choice(self):
 
        """
 
        Returns a boolean indicating whether the user is allowed at
 
        all, to choose from existing contact info options for the
 
        customer, vs. they just have to go with whatever the handler
 
        auto-provides.
 
        """
 
        return self.config.getbool('rattail.custorders',
 
                                   'new_orders.allow_contact_info_choice',
 
                                   default=True)
 

	
 
    def should_restrict_contact_info(self):
 
        """
 
        Returns a boolean indicating whether contact info should be
 
        "restricted" - i.e. user can only choose from existing contact
 
        info and cannot override by e.g. entering a new phone number.
 
        """
 
        return self.config.getbool('rattail.custorders',
 
                                   'new_orders.restrict_contact_info',
 
                                   default=False)
 

	
 
    def product_price_may_be_questionable(self):
 
        """
 
        Returns a boolean indicating whether "any" product's price may
 
        be questionable.  So this isn't saying that a price *is*
 
        questionable but rather that it *may* be, if the user
 
        indicates it.  (That checkbox is only shown for the user if
 
        this flag is true.)
 
        """
 
        return self.config.getbool('rattail.custorders',
 
                                   'product_price_may_be_questionable',
 
                                   default=False)
 

	
 
    def assign_contact(self, batch, customer=None, person=None, **kwargs):
 
        """
 
        Assign the customer and/or person "contact" for the order.
 
        """
 
        clientele = self.app.get_clientele_handler()
 
        customer_required = self.new_order_requires_customer()
 

	
 
        # nb. person is always required
 
        if customer and not person:
 
            person = clientele.get_person(customer)
 
        if not person:
 
            raise ValueError("Must specify a person")
 

	
 
        # customer may or may not be optional
 
        if person and not customer:
 
            customer = clientele.get_customer(person)
 
        if customer_required and not customer:
 
            raise ValueError("Must specify a customer account")
 

	
 
        # assign contact
 
        batch.customer = customer
 
        batch.person = person
 

	
 
        # cache contact name
 
        batch.contact_name = self.get_contact_display(batch)
 

	
 
        # update phone/email per new contact
 
        batch.phone_number = None
 
        batch.email_address = None
 
        if customer_required:
 
            batch.phone_number = clientele.get_first_phone_number(customer)
 
            batch.email_address = clientele.get_first_email_address(customer)
 
        else:
 
            batch.phone_number = person.first_phone_number()
 
            batch.email_address = person.first_email_address()
 

	
 
        # always reset "add to customer" flags
 
        batch.clear_param('add_phone_number')
 
        batch.clear_param('add_email_address')
 

	
 
        session = self.app.get_session(batch)
 
        session.flush()
 

	
 
    def get_contact(self, batch):
 
        """
 
        Should return the contact record (i.e. Customer or Person) for
 
        the batch.
 
        """
 
        customer_required = self.new_order_requires_customer()
 

	
 
        if customer_required:
 
            return batch.customer
 
        else:
 
            return batch.person
 

	
 
    def get_contact_id(self, batch):
 
        """
 
        Should return contact ID for the batch, i.e. customer ID.
 
        """
 
        contact = self.get_contact(batch)
 
        if isinstance(contact, model.Customer):
 
            return contact.id
 

	
 
    def get_contact_display(self, batch):
 
        """
 
        Should return contact display text for the batch,
 
        i.e. customer name.
 
        """
 
        contact = self.get_contact(batch)
 
        if contact:
 
            return six.text_type(contact)
 

	
 
        pending = batch.pending_customer
 
        if pending:
 
            return six.text_type(pending)
 

	
 
    def get_contact_phones(self, batch):
 
        """
 
        Retrieve all phone records on file for the batch contact, to
 
        be presented as options for user to choose from when making a
 
        new order.
 
        """
 
        phones = []
 
        contact = self.get_contact(batch)
 
        if contact:
 
            phones = contact.phones
 

	
 
        return [self.normalize_phone(phone)
 
                for phone in phones]
 

	
 
    def normalize_phone(self, phone):
 
        """
 
        Normalize the given phone record to simple data dict, for
 
        passing around via JSON etc.
 
        """
 
        return {
 
            'uuid': phone.uuid,
 
            'type': phone.type,
 
            'number': phone.number,
 
            'preference': phone.preference,
 
            'preferred': phone.preference == 1,
 
        }
 

	
 
    def get_contact_emails(self, batch):
 
        """
 
        Retrieve all email records on file for the batch contact, to
 
        be presented as options for user to choose from when making a
 
        new order.
 

	
 
        Note that the default logic will exclude invalid email addresses.
 
        """
 
        emails = []
 
        contact = self.get_contact(batch)
 
        if contact:
 
            emails = contact.emails
 

	
 
        # exclude invalid
 
        emails = [email for email in emails
 
                  if not email.invalid]
 

	
 
        return [self.normalize_email(email)
 
                for email in emails]
 

	
 
    def normalize_email(self, email):
 
        """
 
        Normalize the given email record to simple data dict, for
 
        passing around via JSON etc.
 
        """
 
        return {
 
            'uuid': email.uuid,
 
            'type': email.type,
 
            'address': email.address,
 
            'invalid': email.invalid,
 
            'preference': email.preference,
 
            'preferred': email.preference == 1,
 
        }
 

	
 
    def get_contact_notes(self, batch):
 
        """
 
        Get extra "contact notes" which should be made visible to the
 
        user who is entering the new order.
 
        """
 
        notes = []
 

	
 
        invalid = False
 
        contact = self.get_contact(batch)
 
        if contact:
 
            invalid = [email for email in contact.emails
 
                       if email.invalid]
 
        if invalid:
 
            notes.append("Customer has one or more invalid email addresses on file.")
 

	
 
        return notes
 

	
 
    def unassign_contact(self, batch, **kwargs):
 
        """
 
        Unassign the customer and/or person "contact" for the order.
 
        """
 
        batch.customer = None
 
        batch.person = None
 

	
 
        # note that if batch already has a "pending" customer on file,
 
        # we will "restore" it as the contact info for the batch
 
        pending = batch.pending_customer
 
        if pending:
 
            batch.contact_name = pending.display_name
 
            batch.phone_number = pending.phone_number
 
            batch.email_address = pending.email_address
 
        else:
 
            batch.contact_name = None
 
            batch.phone_number = None
 
            batch.email_address = None
 

	
 
        # always reset "add to customer" flags
 
        batch.clear_param('add_phone_number')
 
        batch.clear_param('add_email_address')
 

	
 
        session = self.app.get_session(batch)
 
        session.flush()
 
        session.refresh(batch)
 

	
 
    def validate_pending_customer_data(self, batch, user, data):
 
        pass
 

	
 
    def update_pending_customer(self, batch, user, data):
 
        model = self.model
 
        people = self.app.get_people_handler()
 

	
 
        # first validate all data
 
        self.validate_pending_customer_data(batch, user, data)
 

	
 
        # clear out any contact it may have
 
        self.unassign_contact(batch)
 

	
 
        # create pending customer if needed
 
        pending = batch.pending_customer
 
        if not pending:
 
            pending = model.PendingCustomer()
 
            pending.user = user
 
            pending.status_code = self.enum.PENDING_CUSTOMER_STATUS_PENDING
 
            batch.pending_customer = pending
 

	
 
        # update pending customer info
 
        if 'first_name' in data:
 
            pending.first_name = data['first_name']
 
        if 'last_name' in data:
 
            pending.last_name = data['last_name']
 
        if 'display_name' in data:
 
            pending.display_name = data['display_name']
 
        else:
 
            pending.display_name = people.normalize_full_name(pending.first_name,
 
                                                              pending.last_name)
 
        if 'phone_number' in data:
 
            pending.phone_number = self.app.format_phone_number(data['phone_number'])
 
        if 'email_address' in data:
 
            pending.email_address = data['email_address']
 

	
 
        # also update the batch w/ contact info
 
        batch.contact_name = pending.display_name
 
        batch.phone_number = pending.phone_number
 
        batch.email_address = pending.email_address
 

	
 
    def get_case_size_for_product(self, product):
 
        if product.case_size:
 
            return product.case_size
 

	
 
        cost = product.cost
 
        if cost:
 
            return cost.case_size
 

	
 
    def get_case_price_for_row(self, row):
 
        """
 
        Calculate and return the per-case price for the given row.
 

	
 
        NB. we do not store case price, only unit price.  maybe that
 
        should change some day..
 
        """
 
        if row.unit_price is not None:
 
            case_price = row.unit_price * (row.case_quantity or 1)
 
            return case_price.quantize(decimal.Decimal('0.01'))
 

	
 
    # TODO: this method should maybe not exist?  and caller just
 
    # invokes the handler directly instead?
 
    def customer_autocomplete(self, session, term, **kwargs):
 
        """
 
        Override the Customer autocomplete, to search by phone number
 
        as well as name.
 
        """
 
        autocompleter = self.app.get_autocompleter('customers.neworder')
 
        return autocompleter.autocomplete(session, term, **kwargs)
 

	
 
    # TODO: this method should maybe not exist?  and caller just
 
    # invokes the handler directly instead?
 
    def person_autocomplete(self, session, term, **kwargs):
 
        """
 
        Override the Person autocomplete, to search by phone number as
 
        well as name.
 
        """
 
        autocompleter = self.app.get_autocompleter('people.neworder')
 
        return autocompleter.autocomplete(session, term, **kwargs)
 

	
 
    def get_customer_info(self, batch, **kwargs):
 
        """
 
        Return a data dict containing misc. info pertaining to the
 
        customer/person for the order batch.
 
        """
 
        info = {
 
            'customer_uuid': None,
 
            'person_uuid': None,
 
            'phone_number': None,
 
            'email_address': None,
 
        }
 

	
 
        if batch.customer:
 
            info['customer_uuid'] = batch.customer.uuid
 
            phone = batch.customer.first_phone()
 
            if phone:
 
                info['phone_number'] = phone.number
 
            email = batch.customer.first_email()
 
            if email:
 
                info['email_address'] = email.address
 

	
 
        if batch.person:
 
            info['person_uuid'] = batch.person.uuid
 
            if not info['phone_number']:
 
                phone = batch.person.first_phone()
 
                if phone:
 
                    info['phone_number'] = phone.number
 
                email = batch.person.first_email()
 
                if email:
 
                    info['email_address'] = email.address
 

	
 
        return info
 

	
 
    def custom_product_autocomplete(self, session, term, **kwargs):
 
        """
 
        For the given term, this should return a (possibly empty) list
 
        of products which "match" the term.  Each element in the list
 
        should be a dict with "label" and "value" keys.
 
        """
 
        raise NotImplementedError("Please define the "
 
                                  "{}.custom_product_autocomplete() "
 
                                  "method.".format(__class__.__name__))
 

	
 
    def get_past_orders(self, batch, **kwargs):
 
        """
 
        Retrieve a list of past orders for the batch contact.
 
        """
 
        session = self.app.get_session(batch)
 
        model = self.model
 
        orders = session.query(model.CustomerOrder)
 

	
 
        contact = self.get_contact(batch)
 
        if isinstance(contact, model.Customer):
 
            orders = orders.filter(model.CustomerOrder.customer == contact)
 
        else:
 
            orders = orders.filter(model.CustomerOrder.person == contact)
 

	
 
        orders = orders.order_by(model.CustomerOrder.created.desc())
 
        return orders.all()
 

	
 
    def get_past_products(self, batch, **kwargs):
 
        """
 
        Should return a (possibly empty) list of products which have
 
        been ordered in the past by the customer who is associated
 
        with the given batch.
 
        """
 
        # TODO: should crawl the rattail order history here
 
        return []
 
        session = self.app.get_session(batch)
 
        model = self.model
 
        products = OrderedDict()
 

	
 
        # track down all order items for batch contact
 
        orders = self.get_past_orders(batch)
 
        for order in orders:
 
            for item in order.items:
 
                product = item.product
 
                if product:
 
                    # we only want the first match for each product
 
                    products.setdefault(product.uuid, product)
 

	
 
        return list(products.values())
 

	
 
    def get_product_info(self, batch, product, **kwargs):
 
        """
 
        Return a data dict containing misc. info pertaining to the
 
        given product, for the order batch.
 
        """
 
        products = self.app.get_products_handler()
 
        vendor = product.cost.vendor if product.cost else None
 
        info = {
 
            'uuid': product.uuid,
 
            'upc': six.text_type(product.upc),
 
            'upc_pretty': product.upc.pretty(),
 
            'brand_name': product.brand.name if product.brand else None,
 
            'description': product.description,
 
            'size': product.size,
 
            'full_description': product.full_description,
 
            'case_quantity': self.app.render_quantity(self.get_case_size_for_product(product)),
 
            'unit_price_display': products.render_price(product.regular_price),
 
            'department_name': product.department.name if product.department else None,
 
            'vendor_name': vendor.name if vendor else None,
 
            'url': products.get_url(product),
 
            'image_url': products.get_image_url(product),
 
            'uom_choices': self.uom_choices_for_product(product),
 
        }
 

	
 
        case_price = None
 
        if product.regular_price and product.regular_price is not None:
 
            case_size = self.get_case_size_for_product(product)
 
            case_price = case_size * product.regular_price.price
 
            case_price = (case_size or 1) * product.regular_price.price
 
            case_price = case_price.quantize(decimal.Decimal('0.01'))
 
        info['case_price'] = six.text_type(case_price) if case_price is not None else None
 
        info['case_price_display'] = self.app.render_currency(case_price)
 

	
 
        key = self.config.product_key()
 
        if key == 'upc':
 
            info['key'] = info['upc_pretty']
 
        else:
 
            info['key'] = getattr(product, key, info['upc_pretty'])
 

	
 
        return info
 

	
 
    def uom_choices_for_product(self, product):
 
        """
 
        Return a list of UOM choices for the given product.
 
        """
 
        choices = []
 

	
 
        # Each
 
        if not product or not product.weighed:
 
            unit_name = self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_EACH]
 
            choices.append({'key': self.enum.UNIT_OF_MEASURE_EACH,
 
                            'value': unit_name})
 

	
 
        # Pound
 
        if not product or product.weighed:
 
            unit_name = self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_POUND]
 
            choices.append({
 
                'key': self.enum.UNIT_OF_MEASURE_POUND,
 
                'value': unit_name,
 
            })
 

	
 
        # Case
 
        case_text = None
 
        case_size = self.get_case_size_for_product(product)
 
        if case_size is None:
 
            case_text = "{} (&times; ?? {})".format(
 
                self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE],
 
                unit_name)
 
        elif case_size > 1:
 
            case_text = "{} (&times; {} {})".format(
 
                self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE],
 
                self.app.render_quantity(case_size),
 
                unit_name)
 
        if case_text:
 
            choices.append({'key': self.enum.UNIT_OF_MEASURE_CASE,
 
                            'value': case_text})
 

	
 
        return choices
 

	
 
    def why_not_add_product(self, product, batch):
 
        """
 
        This method can inspect the given product, and batch, to
 
        determine if the product may be added to the batch as a new
 
        row.  Useful to e.g. prevent one customer from ordering too
 
        many things, etc.
 

	
 
        :returns: If there is a reason not to add the product, should
 
           return that reason as a string; otherwise ``None``.
 
        """
 

	
 
    def add_product(self, batch, product, order_quantity, order_uom,
 
                    **kwargs):
 
        """
 
        Add a new row to the batch, for the given product and order
 
        quantity.
 
        """
 
        row = self.make_row()
 
        row.item_entry = product.uuid
 
        row.product = product
 
        row.order_quantity = order_quantity
 
        row.order_uom = order_uom
 
        if 'price_needs_confirmation' in kwargs:
 
            row.price_needs_confirmation = kwargs['price_needs_confirmation']
 
        self.add_row(batch, row)
 
        return row
 

	
 
    def refresh_row(self, row):
 
        if not row.product:
 
            if row.item_entry:
 
                session = orm.object_session(row)
 
                # TODO: should do more than just query for uuid here
 
                product = session.query(model.Product).get(row.item_entry)
 
                if product:
 
                    row.product = product
 
            if not row.product:
 
                row.status_code = row.STATUS_PRODUCT_NOT_FOUND
 
                return
 

	
 
        product = row.product
 
        row.product_upc = product.upc
 
        row.product_brand = six.text_type(product.brand or "")
 
        row.product_description = product.description
 
        row.product_size = product.size
 
        row.product_weighed = product.weighed
 
        row.case_quantity = self.get_case_size_for_product(product)
 

	
 
        department = product.department
 
        row.department_number = department.number if department else None
 
        row.department_name = department.name if department else None
 

	
 
        cost = product.cost
 
        row.product_unit_cost = cost.unit_cost if cost else None
 

	
 
        regprice = product.regular_price
 
        row.unit_price = regprice.price if regprice else None
 

	
 
        # we need to know if total price is updated
 
        old_total = row.total_price
 

	
 
        # maybe update total price
 
        if row.unit_price is None:
 
            row.total_price = None
 
        elif not row.unit_price:
 
            row.total_price = 0
 
        else:
 
            row.total_price = row.unit_price * row.order_quantity
 
            if row.order_uom == self.enum.UNIT_OF_MEASURE_CASE:
 
                row.total_price *= (row.case_quantity or 1)
 

	
 
        # update total price for batch too, if it changed
 
        if row.total_price != old_total:
 
            batch = row.batch
 
            batch.total_price = ((batch.total_price or 0)
 
                                 + (row.total_price or 0)
 
                                 - (old_total or 0))
 

	
 
        row.status_code = row.STATUS_OK
 

	
 
    def remove_row(self, row):
 
        batch = row.batch
 

	
 
        if not row.removed:
 
            row.removed = True
 

	
 
            if row.total_price:
 
                batch.total_price = (batch.total_price or 0) - row.total_price
 

	
 
        self.refresh_batch_status(batch)
 

	
 
    def execute(self, batch, user=None, progress=None, **kwargs):
 
        """
 
        Default behavior here will create and return a new rattail
 
        Customer Order.  It also may "add contact info" e.g. to the
 
        customer record.  Override as needed.
 
        """
 
        order = self.make_new_order(batch, user=user, progress=progress, **kwargs)
 
        self.update_contact_info(batch, user)
 
        return order
 

	
 
    def update_contact_info(self, batch, user, **kwargs):
 
        """
 
        Update contact info from the batch, onto the customer record.
 
        """
 
        if batch.get_param('add_phone_number'):
 
            self.add_phone_number(batch, user)
 
        if batch.get_param('add_email_address'):
 
            self.add_email_address(batch, user)
 

	
 
    def add_phone_number(self, batch, user, **kwargs):
 
        """
 
        Add phone number from the batch to the customer record.
 

	
 
        Note that the default behavior does *not* do that, but instead
 
        will send an email alert to configured recipient(s) with the
 
        update request.
 
        """
 
        self.app.send_email('new_phone_requested', {
 
            'user': user,
 
            'user_display': user.display_name if user else "(unknown user)",
 
            'contact': self.get_contact(batch),
 
            'contact_id': self.get_contact_id(batch),
 
            'phone_number': batch.phone_number,
 
        })
 

	
 
    def add_email_address(self, batch, user, **kwargs):
 
        """
 
        Add email address from the batch to the customer record.
 

	
 
        Note that the default behavior does *not* do that, but instead
 
        will send an email alert to configured recipient(s) with the
 
        update request.
 
        """
 
        self.app.send_email('new_email_requested', {
 
            'user': user,
 
            'user_display': user.display_name if user else "(unknown user)",
 
            'contact': self.get_contact(batch),
 
            'contact_id': self.get_contact_id(batch),
 
            'email_address': batch.email_address,
 
        })
 

	
 
    def make_new_order(self, batch, user=None, progress=None, **kwargs):
rattail/products.py
Show inline comments
 
# -*- coding: utf-8; -*-
 
################################################################################
 
#
 
#  Rattail -- Retail Software Framework
 
#  Copyright © 2010-2021 Lance Edgar
 
#
 
#  This file is part of Rattail.
 
#
 
#  Rattail is free software: you can redistribute it and/or modify it under the
 
#  terms of the GNU General Public License as published by the Free Software
 
#  Foundation, either version 3 of the License, or (at your option) any later
 
#  version.
 
#
 
#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
 
#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 
#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
 
#  details.
 
#
 
#  You should have received a copy of the GNU General Public License along with
 
#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
 
#
 
################################################################################
 
"""
 
Products Handler
 
"""
 

	
 
from __future__ import unicode_literals, absolute_import
 

	
 
import six
 
import sqlalchemy as sa
 
from sqlalchemy import orm
 

	
 
from rattail import pod
 
from rattail.util import load_object
 
from rattail.app import GenericHandler
 
from rattail.gpc import GPC
 
from rattail.barcodes import upce_to_upca
 

	
 

	
 
class ProductsHandler(GenericHandler):
 
    """
 
    Base class and default implementation for product handlers.
 

	
 
    A products handler of course should get the final say in how products are
 
    handled.  This means everything from pricing, to whether or not a
 
    particular product can be deleted, etc.
 
    """
 

	
 
    def find_products_by_key(self, session, value, **kwargs):
 
        """
 
        Locate any products where the "key" matches the given value.
 

	
 
        By default this search is as "thorough" as possible and may
 
        return multiple results in some cases where you might not
 
        expect them.  Please pass the :param:`only` param if you need
 
        a more focused search etc.
 

	
 
        :param session: Session for the Rattail database.
 

	
 
        :param value: Value to search for.  Can be a GPC object or
 
           string.
 

	
 
        :param only: You can optionally direct the logic to search
 
           only for certain types of keys, by passing a list here,
 
           e.g.  ``['upc', 'sku']``, or can just pass a single string
 
           (e.g. ``'upc'``) if only one key is needed.  Otherwise by
 
           default the logic will search for "all" possible keys.
 

	
 
        :param vendor: You can optionally specify a Vendor, if your
 
           search needs to include a SKU lookup.  Only matches for
 
           that vendor will be returned.  Note that by default there
 
           is no vendor which means "any" SKU might match.
 

	
 
        :param include_keys: You can optionally request that the
 
           return value include indications of which key was matched,
 
           for each product result.
 

	
 
        :returns: If :param:`include_keys` is true, then will return a
 
           list of 2-tuples representing each match.  The first
 
           element of the tuple will be the "key" field pseudonym
 
           which was matched on; the second element will be the
 
           product.
 

	
 
           But if ``include_keys`` is false, then will return just a
 
           simple list of the products, i.e. won't include the keys.
 

	
 
           Either way of course the list might be empty.
 
        """
 
        from rattail.db.api.products import (get_product_by_upc,
 
                                             get_product_by_item_id,
 
                                             get_product_by_scancode,
 
                                             get_product_by_code,
 
                                             get_product_by_vendor_code)
 

	
 
        model = self.model
 
        only = kwargs.get('only')
 
        if isinstance(only, six.string_types):
 
            only = [only]
 
        vendor = kwargs.get('vendor')
 
        include_keys = kwargs.get('include_keys', False)
 
        products = []
 

	
 
        # don't bother if we're given empty value
 
        if not value:
 
            return products
 

	
 
        # TODO: most of the function calls below are only good for "at
 
        # most one" result.  in some cases there may be more than one
 
        # match in fact, which will raise an error.  need to refactor
 
        # somehow to account for that..for now just pass `only` param
 
        # to avoid the problematic keys for your situation.
 

	
 
        # maybe look for 'uuid' match
 
        if not only or 'uuid' in only:
 
            product = session.query(model.Product).get(value)
 
            if product:
 
                products.append(('uuid', product))
 

	
 
        # maybe look for 'upc' match
 
        if not only or 'upc' in only:
 

	
 
            # if value is a GPC we kind of only have one thing to try
 
            if isinstance(value, GPC):
 
                product = get_product_by_upc(session, value)
 
                if product:
 
                    products.append(('upc', product))
 

	
 
            else: # not GPC, so must convert
 

	
 
                if value.isdigit():
 

	
 
                    # we first assume the value *does* include check digit
 
                    provided = GPC(value, calc_check_digit=False)
 
                    product = get_product_by_upc(session, provided)
 
                    if product:
 
                        products.append(('upc', product))
 

	
 
                    # but we can also calculate a check digit and try that
 
                    checked = GPC(value, calc_check_digit='upc')
 
                    product = get_product_by_upc(session, checked)
 
                    if product:
 
                        products.append(('upc', product))
 

	
 
                    # one last trick is to expand UPC-E to UPC-A and then reattempt
 
                    # the lookup, *with* check digit (since it would be known)
 
                    if len(value) in (6, 8):
 
                        checked = GPC(upce_to_upca(value), calc_check_digit='upc')
 
                        product = get_product_by_upc(session, checked)
 
                        if product:
 
                            products.append(('upc', product))
 

	
 
        # maybe look for 'item_id' match
 
        if not only or 'item_id' in only:
 
            product = get_product_by_item_id(session, value)
 
            if product:
 
                products.append(('item_id', product))
 

	
 
        # maybe look for 'scancode' match
 
        if not only or 'scancode' in only:
 
            product = get_product_by_scancode(session, value)
 
            if product:
 
                products.append(('scancode', product))
 

	
 
        # maybe look for 'altcode' match
 
        if not only or 'altcode' in only:
 
            product = get_product_by_code(session, value)
 
            if product:
 
                products.append(('altcode', product))
 

	
 
        # maybe look for 'sku' match
 
        if not only or 'sku' in only:
 
            product = get_product_by_vendor_code(session, value,
 
                                                 vendor=vendor)
 
            if product:
 
                products.append(('sku', product))
 

	
 
        # maybe strip keys out of the result
 
        if not include_keys:
 
            products = [tup[1] for tup in products]
 

	
 
        return products
 

	
 
    def get_url(self, product, **kwargs):
 
        """
 
        Return the Tailbone "view" URL for the given product.
 
        """
 
        base_url = self.config.base_url()
 
        if base_url:
 
            return '{}/products/{}'.format(base_url, product.uuid)
 

	
 
    def get_image_url(self, product=None, upc=None, **kwargs):
 
        """
 
        Return the preferred image URL for the given UPC or product.
 
        """
 
        base_url = self.config.base_url()
 

	
 
        # we prefer the "image on file" if available
 
        if base_url and product and product.image:
 
            return '{}/products/{}/image'.format(base_url, product.uuid)
 

	
 
        # and if this product is a pack item, then we prefer the unit
 
        # item image as fallback, if available
 
        if base_url and product and product.is_pack_item():
 
            unit = product.unit
 
            if unit and unit.image:
 
                return '{}/products/{}/image'.format(base_url, unit.uuid)
 

	
 
        # fallback to the POD image, if available and so configured
 
        if self.config.getbool('tailbone', 'products.show_pod_image',
 
                               default=False):
 
            if product and not upc:
 
                upc = product.upc
 
            if upc:
 
                return self.get_pod_image_url(upc)
 

	
 
        if base_url:
 
            return '{}/tailbone/img/product.png'.format(base_url)
 

	
 
    def get_pod_image_url(self, upc, **kwargs):
 
        """
 
        Return the POD image URL for the given UPC.
 
        """
 
        if upc:
 
            return pod.get_image_url(self.config, upc)
 

	
 
    def render_price(self, price, html=False, **kwargs):
 
        """
 
        Render the given ``price`` object as text.
 

	
 
        :returns: String containing the rendered price, or ``None`` if
 
           nothing was applicable.
 
        """
 
        if price.price is not None and price.pack_price is not None:
 
            if price.multiple > 1:
 
                return "{} / {}  ({} / {})".format(
 
                    self.app.render_currency(price.price),
 
                    price.multiple,
 
                    self.app.render_currency(price.pack_price),
 
                    price.pack_multiple)
 
            return "{}  ({} / {})".format(
 
                self.app.render_currency(price.price),
 
                self.app.render_currency(price.pack_price),
 
                price.pack_multiple)
 
        if price.price is not None:
 
            if price.multiple is not None and price.multiple > 1:
 
                return "{} / {}".format(
 
                    self.app.render_currency(price.price),
 
                    price.multiple)
 
            return self.app.render_currency(price.price)
 
        if price.pack_price is not None:
 
            return "{} / {}".format(
 
                self.app.render_currency(price.pack_price),
 
                price.pack_multiple)
 

	
 
    def get_uom_sil_codes(self, session, uppercase=False, **kwargs):
 
        """
 
        This should return a dict, keys of which are UOM abbreviation strings,
 
        and values of which are corresponding SIL code strings.
 

	
 
        :param session: Reference to current Rattail DB session.
 
        :param uppercase: Set to ``True`` to cause all UOM abbreviations to be
 
           upper-cased when adding to the map.
 
        :returns: Dictionary containing all known UOM / SIL code mappings.
 
        """
 
        model = self.model
 

	
 
        def normalize(uom):
 
            if uom.sil_code:
 
                return uom.sil_code
 

	
 
        def make_key(uom, normal):
 
            key = uom.abbreviation
 
            if uppercase:
 
                key = key.upper()
 
            return key
 

	
 
        return self.cache_model(session, model.UnitOfMeasure,
 
                                normalizer=normalize,
 
                                key=make_key)
 

	
 
    def get_uom_sil_code(self, session, uom, uppercase=False, **kwargs):
 
        """
 
        This should return a SIL code which corresponds to the given UOM
 
        abbreviation string.  Useful when you just need one out of the blue,
 
        but if you need multiple codes looked up then you're probably better
 
        off using :meth:`get_uom_sil_codes()` for efficiency.
 

	
 
        :param session: Reference to current Rattail DB session.
 
        :param uppercase: Set to ``True`` to cause the UOM abbreviation to be
 
           upper-cased before performing the lookup.  This effectively makes
 
           the search case-insensitive.
 
        :param uom:  Unit of measure as abbreviated string, e.g. ``'LB'``.
 
        :returns: SIL code for the UOM, as string (e.g. ``'49'``), or ``None``
 
           if no matching code was found.
 
        """
 
        model = self.model
 
        query = session.query(model.UnitOfMeasure)
 
        if uppercase:
 
            query = query.filter(sa.func.upper(model.UnitOfMeasure.abbreviation) == uom.upper())
 
        else:
 
            query = query.filter(model.UnitOfMeasure.abbreviation == uom)
 
        try:
 
            match = query.one()
 
        except orm.exc.NoResultFound:
 
            pass
 
        else:
 
            return match.sil_code
 

	
 
    def collect_wild_uoms(self, **kwargs):
 
        """
 
        Collect all UOM abbreviations "from the wild" and ensure each is
 
        represented within the Rattail Units of Measure table.
 

	
 
        Note that you should not need to override this method.  Please override
 
        :meth:`find_wild_uoms()` instead.
 
        """
 
        session = self.make_session()
 
        model = self.model
 

	
 
        wild_uoms = self.find_wild_uoms(session, **kwargs)
 

	
 
        known_uoms = self.cache_model(session, model.UnitOfMeasure,
 
                                      key='abbreviation')
 

	
 
        for wild_uom in wild_uoms:
 
            if wild_uom not in known_uoms:
 
                uom = model.UnitOfMeasure()
 
                uom.abbreviation = wild_uom
 
                session.add(uom)
 

	
 
        session.commit()
 
        session.close()
 

	
 
    def find_wild_uoms(self, session, **kwargs):
 
        """
 
        Query some database(s) in order to discover all UOM abbreviations which
 
        exist "in the wild".
 

	
 
        You are encouraged to override this method as needed.  Note that
 
        certain POS integration packages may provide some common logic which
 
        may be used for this.
 

	
 
        :param session: Reference to current Rattail DB session.
 

	
 
        :returns: A list of strings, e.g. ``['OZ', 'LB', ...]``.
 
        """
 
        return []
 

	
 

	
 
def get_products_handler(config, **kwargs):
 
    """
 
    Create and return the configured :class:`ProductsHandler` instance.
 
    """
 
    spec = config.get('rattail', 'products.handler')
 
    if spec:
 
        factory = load_object(spec)
 
    else:
 
        factory = ProductsHandler
 
    return factory(config, **kwargs)
0 comments (0 inline, 0 general)