From 4de258d09bb564f70dce801faa28b548726eff72 2022-01-07 19:26:11 From: Lance Edgar Date: 2022-01-07 19:26:11 Subject: [PATCH] Add vendor handler, to better organize catalog parser logic --- diff --git a/docs/api/index.rst b/docs/api/index.rst index 5ea81b98d4c66148c600ff345e0e4c96bccc684c..977dd71547d9cc6ad17bd60bf85b567552297b91 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -65,4 +65,5 @@ attributes and method signatures etc. rattail/upgrades rattail/util rattail/vendors.catalogs + rattail/vendors.handler rattail/win32 diff --git a/docs/api/rattail/vendors.handler.rst b/docs/api/rattail/vendors.handler.rst new file mode 100644 index 0000000000000000000000000000000000000000..7cf6e94cf25dc765a565bcfe8ca40883c6f3512c --- /dev/null +++ b/docs/api/rattail/vendors.handler.rst @@ -0,0 +1,6 @@ + +``rattail.vendors.handler`` +=========================== + +.. automodule:: rattail.vendors.handler + :members: diff --git a/rattail/app.py b/rattail/app.py index a546cdf84ee4030fb0a8a107ee5341490f8756c6..57dfaef6c7aaed4d28ad68477cd977320b639774 100644 --- a/rattail/app.py +++ b/rattail/app.py @@ -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 diff --git a/rattail/batch/vendorcatalog.py b/rattail/batch/vendorcatalog.py index 22643a5229020adf8b5c702940772a5e2025573f..eac086a6d23e5bf644f2db5493d51641d6738c3a 100644 --- a/rattail/batch/vendorcatalog.py +++ b/rattail/batch/vendorcatalog.py @@ -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)) diff --git a/rattail/vendors/__init__.py b/rattail/vendors/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..e21ad1f6188372e4f762c5d3b5f6d3bb5ed94b75 100644 --- a/rattail/vendors/__init__.py +++ b/rattail/vendors/__init__.py @@ -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 . +# +################################################################################ +""" +Vendor stuff +""" + +from __future__ import unicode_literals, absolute_import + +from .handler import VendorHandler diff --git a/rattail/vendors/catalogs.py b/rattail/vendors/catalogs.py index 02485c24afff2bfb14f242b3e09e7dcc4a0abb69..bb3d4a08edd21b8138fa6f469fc0a14369fd146a 100644 --- a/rattail/vendors/catalogs.py +++ b/rattail/vendors/catalogs.py @@ -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() diff --git a/rattail/vendors/handler.py b/rattail/vendors/handler.py new file mode 100644 index 0000000000000000000000000000000000000000..2f4652ff6e20bfe858d39bc61b6615700c84b254 --- /dev/null +++ b/rattail/vendors/handler.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/tests/vendors/test_handler.py b/tests/vendors/test_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..c08f35ddb416ffbb4e1bd3f519dc9f3cc266e006 --- /dev/null +++ b/tests/vendors/test_handler.py @@ -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)