Add vendor handler, to better organize catalog parser logic

This commit is contained in:
Lance Edgar 2022-01-07 19:26:11 -06:00
parent 1ac0139fd3
commit 4de258d09b
8 changed files with 320 additions and 14 deletions

View file

@ -65,4 +65,5 @@ attributes and method signatures etc.
rattail/upgrades
rattail/util
rattail/vendors.catalogs
rattail/vendors.handler
rattail/win32

View file

@ -0,0 +1,6 @@
``rattail.vendors.handler``
===========================
.. automodule:: rattail.vendors.handler
:members:

View file

@ -670,6 +670,20 @@ class AppHandler(object):
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

View file

@ -32,7 +32,6 @@ 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):
@ -63,7 +62,7 @@ class VendorCatalogHandler(BatchHandler):
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))
@ -112,21 +111,18 @@ class VendorCatalogHandler(BatchHandler):
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))

View file

@ -0,0 +1,29 @@
# -*- 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

View file

@ -26,6 +26,7 @@ Vendor Catalogs
from __future__ import unicode_literals, absolute_import
import warnings
from decimal import Decimal
import six
@ -136,38 +137,50 @@ class CatalogParserNotFound(RattailError):
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()

137
rattail/vendors/handler.py vendored Normal file
View file

@ -0,0 +1,137 @@
# -*- 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)

110
tests/vendors/test_handler.py vendored Normal file
View file

@ -0,0 +1,110 @@
# -*- 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)