Changeset - 4de258d09bb5
[Not reviewed]
0 5 3
Lance Edgar (lance) - 3 years ago 2022-01-07 19:26:11
lance@edbob.org
Add vendor handler, to better organize catalog parser logic
8 files changed with 320 insertions and 14 deletions:
0 comments (0 inline, 0 general)
docs/api/index.rst
Show inline comments
 
@@ -62,7 +62,8 @@ attributes and method signatures etc.
 
   rattail/products
 
   rattail/time
 
   rattail/trainwreck/index
 
   rattail/upgrades
 
   rattail/util
 
   rattail/vendors.catalogs
 
   rattail/vendors.handler
 
   rattail/win32
docs/api/rattail/vendors.handler.rst
Show inline comments
 
new file 100644
 

	
 
``rattail.vendors.handler``
 
===========================
 

	
 
.. automodule:: rattail.vendors.handler
 
   :members:
rattail/app.py
Show inline comments
 
@@ -667,12 +667,26 @@ class AppHandler(object):
 
            spec = self.config.get('trainwreck', 'handler',
 
                                   default='rattail.trainwreck.handler:TrainwreckHandler')
 
            Handler = self.load_object(spec)
 
            self.trainwreck_handler = Handler(self.config)
 
        return self.trainwreck_handler
 

	
 
    def get_vendor_handler(self, **kwargs):
 
        """
 
        Get the configured "vendor" handler.
 

	
 
        :returns: The :class:`~rattail.vendors.handler.VendorHandler`
 
           instance for the app.
 
        """
 
        if not hasattr(self, 'vendor_handler'):
 
            spec = self.config.get('rattail', 'vendors.handler',
 
                                   default='rattail.vendors:VendorHandler')
 
            factory = self.load_object(spec)
 
            self.vendor_handler = factory(self.config, **kwargs)
 
        return self.vendor_handler
 

	
 
    def progress_loop(self, *args, **kwargs):
 
        """
 
        Run a given function for a given sequence, and optionally show
 
        a progress indicator.
 

	
 
        Default logic invokes the :func:`rattail.util.progress_loop()`
rattail/batch/vendorcatalog.py
Show inline comments
 
@@ -29,13 +29,12 @@ from __future__ import unicode_literals, absolute_import
 
import decimal
 

	
 
from sqlalchemy import orm
 

	
 
from rattail.db import model
 
from rattail.batch import BatchHandler
 
from rattail.vendors.catalogs import require_catalog_parser
 

	
 

	
 
class VendorCatalogHandler(BatchHandler):
 
    """
 
    Handler for vendor catalog batches.
 
    """
 
@@ -60,13 +59,13 @@ class VendorCatalogHandler(BatchHandler):
 
        # all vendor catalogs must come from data file
 
        return True
 

	
 
    def setup(self, batch, progress=None):
 
        self.vendor = batch.vendor
 
        self.products = {'upc': {}, 'vendor_code': {}}
 
        session = orm.object_session(batch)
 
        session = self.app.get_session(batch)
 
        products = session.query(model.Product)\
 
                          .options(orm.joinedload(model.Product.brand))\
 
                          .options(orm.joinedload(model.Product.costs))
 

	
 
        def cache(product, i):
 
            if product.upc:
 
@@ -109,27 +108,24 @@ class VendorCatalogHandler(BatchHandler):
 
        """
 
        if not batch.filename:
 
            raise ValueError("batch does not have a filename: {}".format(batch))
 
        if not batch.parser_key:
 
            raise ValueError("batch does not have a parser_key: {}".format(batch))
 

	
 
        session = orm.object_session(batch)
 
        session = self.app.get_session(batch)
 
        path = batch.filepath(self.config)
 
        parser = require_catalog_parser(batch.parser_key)
 
        # TODO: should add `config` kwarg to CatalogParser constructor
 
        parser.config = self.config
 
        parser.app = self.app
 
        parser.model = self.model
 
        parser.enum = self.enum
 
        vendor_handler = self.app.get_vendor_handler()
 
        parser = vendor_handler.get_catalog_parser(batch.parser_key,
 
                                                   require=True)
 
        parser.session = session
 
        parser.vendor = batch.vendor
 
        batch.effective = parser.parse_effective_date(path)
 

	
 
        def append(row, i):
 
            self.add_row(batch, row)
 
            if i % 1000 == 0: # pragma: no cover
 
            if i % 500 == 0: # pragma: no cover
 
                session.flush()
 

	
 
        data = list(parser.parse_rows(path, progress=progress))
 
        self.progress_loop(append, data, progress,
 
                           message="Adding initial rows to batch")
 

	
rattail/vendors/__init__.py
Show inline comments
 
# -*- coding: utf-8; -*-
 
################################################################################
 
#
 
#  Rattail -- Retail Software Framework
 
#  Copyright © 2010-2022 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/>.
 
#
 
################################################################################
 
"""
 
Vendor stuff
 
"""
 

	
 
from __future__ import unicode_literals, absolute_import
 

	
 
from .handler import VendorHandler
rattail/vendors/catalogs.py
Show inline comments
 
@@ -23,12 +23,13 @@
 
"""
 
Vendor Catalogs
 
"""
 

	
 
from __future__ import unicode_literals, absolute_import
 

	
 
import warnings
 
from decimal import Decimal
 

	
 
import six
 

	
 
from rattail.exceptions import RattailError
 
from rattail.util import load_entry_points
 
@@ -133,41 +134,53 @@ class CatalogParserNotFound(RattailError):
 
        self.key = key
 

	
 
    def __str__(self):
 
        return "Vendor catalog parser with key {} cannot be located.".format(self.key)
 

	
 

	
 
def get_catalog_parsers():
 
def get_catalog_parsers(): # pragma: no cover
 
    """
 
    Returns a dictionary of installed vendor catalog parser classes.
 
    """
 
    warnings.warn("function is deprecated, please use "
 
                  "VendorHandler.get_all_catalog_parsers() instead",
 
                  DeprecationWarning)
 
    return load_entry_points('rattail.vendors.catalogs.parsers')
 

	
 

	
 
def get_catalog_parser(key):
 
def get_catalog_parser(key): # pragma: no cover
 
    """
 
    Fetch a vendor catalog parser by key.  If the parser class can be located,
 
    this will return an instance thereof; otherwise returns ``None``.
 
    """
 
    warnings.warn("function is deprecated, please use "
 
                  "VendorHandler.get_catalog_parser() instead",
 
                  DeprecationWarning)
 
    parser = get_catalog_parsers().get(key)
 
    if parser:
 
        return parser()
 
    return None
 

	
 

	
 
def require_catalog_parser(key):
 
def require_catalog_parser(key): # pragma: no cover
 
    """
 
    Fetch a vendor catalog parser by key.  If the parser class can be located,
 
    this will return an instance thereof; otherwise raises an exception.
 
    """
 
    warnings.warn("function is deprecated, please use "
 
                  "VendorHandler.get_catalog_parser() instead",
 
                  DeprecationWarning)
 
    parser = get_catalog_parser(key)
 
    if not parser:
 
        raise CatalogParserNotFound(key)
 
    return parser
 

	
 

	
 
def iter_catalog_parsers():
 
def iter_catalog_parsers(): # pragma: no cover
 
    """
 
    Returns an iterator over the installed vendor catalog parsers.
 
    """
 
    warnings.warn("function is deprecated, please use "
 
                  "VendorHandler.get_all_catalog_parsers() instead",
 
                  DeprecationWarning)
 
    parsers = get_catalog_parsers()
 
    return parsers.values()
rattail/vendors/handler.py
Show inline comments
 
new file 100644
 
# -*- coding: utf-8; -*-
 
################################################################################
 
#
 
#  Rattail -- Retail Software Framework
 
#  Copyright © 2010-2022 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/>.
 
#
 
################################################################################
 
"""
 
Vendors Handler
 
"""
 

	
 
from __future__ import unicode_literals, absolute_import
 

	
 
from rattail.app import GenericHandler
 
from rattail.util import load_entry_points
 

	
 

	
 
class VendorHandler(GenericHandler):
 
    """
 
    Base class and default implementation for vendor handlers.
 
    """
 

	
 
    def choice_uses_dropdown(self):
 
        """
 
        Returns boolean indicating whether a vendor choice should be
 
        presented to the user via a dropdown (select) element, vs.  an
 
        autocomplete field.  The latter is the default because
 
        potentially the vendor list can be quite large, so we avoid
 
        loading them all in the dropdown unless so configured.
 

	
 
        :returns: Boolean; if true then a dropdown should be used;
 
           otherwise (false) autocomplete is used.
 
        """
 
        return self.config.getbool('rattail', 'vendors.choice_uses_dropdown',
 
                                   default=False)
 

	
 
    def get_vendor(self, session, key, **kwargs):
 
        """
 
        Locate and return the vendor corresponding to the given key.
 

	
 
        The key can be a UUID value, but most often it will instead be
 
        a "generic" key specific to this purpose.  Any generic key can
 
        be defined within the settings, pointing to a valid vendor.
 
        
 
        For instance, we can define a key of ``'poser.acme'`` to
 
        denote the hypothetical "Acme Distribution" vendor, and we add
 
        a namespace unique to our app just to be safe.
 

	
 
        We then create a setting in the DB pointing to our *actual*
 
        vendor by way of its UUID:
 

	
 
        .. code-block:: sql
 

	
 
           INSERT INTO SETTING (name, value) 
 
           VALUES ('rattail.vendor.poser.acme',
 
                   '7e6d69a2700911ec93533ca9f40bc550');
 

	
 
        From then on we could easily fetch the vendor by this key.
 
        This is mainly useful to allow catalog and invoice parsers to
 
        "loosely" associate with a particular vendor by way of this
 
        key, which could be shared across organizations etc.
 
        """
 
        from rattail.db.api.vendors import get_vendor
 
        return get_vendor(session, key)
 

	
 
    def get_all_catalog_parsers(self):
 
        """
 
        Should return *all* catalog parsers known to exist.
 

	
 
        Note that this returns classes and not instances.
 

	
 
        :returns: List of
 
           :class:`~rattail.vendors.catalogs.CatalogParser` classes.
 
        """
 
        Parsers = list(
 
            load_entry_points('rattail.vendors.catalogs.parsers').values())
 
        Parsers.sort(key=lambda Parser: Parser.display)
 
        return Parsers
 
        
 
    def get_supported_catalog_parsers(self):
 
        """
 
        Should return only those catalog parsers which are "supported"
 
        by the current app.  Usually "supported" just means what we
 
        want to expose to the user.
 

	
 
        Note that this returns classes and not instances.
 

	
 
        :returns: List of
 
           :class:`~rattail.vendors.catalogs.CatalogParser` classes.
 
        """
 
        Parsers = self.get_all_catalog_parsers()
 
        
 
        supported_keys = self.config.getlist(
 
            'rattail', 'vendors.supported_catalog_parsers')
 
        if supported_keys is None:
 
            supported_keys = self.config.getlist(
 
                'tailbone', 'batch.vendorcatalog.supported_parsers')
 
        if supported_keys:
 
            Parsers = [Parser for Parser in Parsers
 
                       if Parser.key in supported_keys]
 
            
 
        return Parsers
 

	
 
    def get_catalog_parser(self, key, require=False):
 
        """
 
        Retrieve the catalog parser for the given parser key.
 

	
 
        Note that this returns an instance, not the class.
 

	
 
        :param key: Unique key indicating which parser to get.
 

	
 
        :returns: A :class:`~rattail.vendors.catalogs.CatalogParser`
 
           instance.
 
        """
 
        from rattail.vendors.catalogs import CatalogParserNotFound
 

	
 
        for Parser in self.get_all_catalog_parsers():
 
            if Parser.key == key:
 
                return Parser(self.config)
 

	
 
        if require:
 
            raise CatalogParserNotFound(key)
tests/vendors/test_handler.py
Show inline comments
 
new file 100644
 
# -*- coding: utf-8; -*-
 

	
 
from __future__ import unicode_literals, absolute_import
 

	
 
from unittest import TestCase
 

	
 
import sqlalchemy as sa
 

	
 
from rattail.vendors import handler as mod
 
from rattail.vendors.catalogs import CatalogParserNotFound
 
from rattail.config import make_config
 
from rattail.db import Session
 

	
 

	
 
class TestVendorHandler(TestCase):
 

	
 
    def setUp(self):
 
        self.config = self.make_config()
 
        self.handler = self.make_handler()
 

	
 
    def make_config(self):
 
        return make_config([], extend=False)
 

	
 
    def make_handler(self):
 
        return mod.VendorHandler(self.config)
 

	
 
    def test_choice_uses_dropdown(self):
 
        
 
        # do not use dropdown by default
 
        result = self.handler.choice_uses_dropdown()
 
        self.assertFalse(result)
 

	
 
        # but do use dropdown if so configured
 
        self.config.setdefault('rattail', 'vendors.choice_uses_dropdown',
 
                               'true')
 
        result = self.handler.choice_uses_dropdown()
 
        self.assertTrue(result)
 

	
 
    def test_get_vendor(self):
 
        engine = sa.create_engine('sqlite://')
 
        model = self.config.get_model()
 
        model.Base.metadata.create_all(bind=engine)
 
        session = Session(bind=engine)
 
        app = self.config.get_app()
 

	
 
        # no vendor if none exist yet!
 
        result = self.handler.get_vendor(session, 'acme')
 
        self.assertIsNone(result)
 

	
 
        # let's make the vendor and make sure uuid fetch works
 
        uuid = app.make_uuid()
 
        acme = model.Vendor(uuid=uuid, name="Acme")
 
        session.add(acme)
 
        result = self.handler.get_vendor(session, uuid)
 
        self.assertIs(result, acme)
 

	
 
        # if we search by key it still does not work
 
        result = self.handler.get_vendor(session, 'acme')
 
        self.assertIsNone(result)
 

	
 
        # but we can configure the key reference, then it will
 
        setting = model.Setting(name='rattail.vendor.acme', value=uuid)
 
        session.add(setting)
 
        result = self.handler.get_vendor(session, 'acme')
 
        self.assertIs(result, acme)
 

	
 
    def test_get_all_catalog_parsers(self):
 

	
 
        # some are always installed; make sure they come back
 
        Parsers = self.handler.get_all_catalog_parsers()
 
        self.assertTrue(len(Parsers))
 

	
 
    def test_get_supported_catalog_parsers(self):
 

	
 
        # by default all parsers are considered supported, so these
 
        # calls should effectively yield the same result
 
        all_parsers = self.handler.get_all_catalog_parsers()
 
        supported = self.handler.get_supported_catalog_parsers()
 
        self.assertEqual(len(all_parsers), len(supported))
 

	
 
        # now pretend only one is supported, using legacy setting
 
        self.config.setdefault('tailbone', 'batch.vendorcatalog.supported_parsers',
 
                               'rattail.contrib.generic')
 
        supported = self.handler.get_supported_catalog_parsers()
 
        self.assertEqual(len(supported), 1)
 
        Parser = supported[0]
 
        self.assertEqual(Parser.key, 'rattail.contrib.generic')
 

	
 
        # now pretend two are supported, using preferred setting
 
        self.config.setdefault('rattail', 'vendors.supported_catalog_parsers',
 
                               'rattail.contrib.generic, rattail.contrib.kehe')
 
        supported = self.handler.get_supported_catalog_parsers()
 
        self.assertEqual(len(supported), 2)
 
        keys = [Parser.key for Parser in supported]
 
        self.assertEqual(keys, ['rattail.contrib.generic', 'rattail.contrib.kehe'])
 

	
 
    def test_get_catalog_parser(self):
 
        
 
        # generic parser comes back fine
 
        parser = self.handler.get_catalog_parser('rattail.contrib.generic')
 
        self.assertIsNotNone(parser)
 
        self.assertEqual(parser.key, 'rattail.contrib.generic')
 

	
 
        # unknown key returns nothing
 
        parser = self.handler.get_catalog_parser('this_should_not_exist')
 
        self.assertIsNone(parser)
 

	
 
        # and can raise an error if we require
 
        self.assertRaises(CatalogParserNotFound, self.handler.get_catalog_parser,
 
                          'this_should_not_exist', require=True)
0 comments (0 inline, 0 general)