From 0298e633841f05e2a3535a7f530506594d63c01a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 3 Mar 2020 21:45:11 -0600 Subject: [PATCH] Add initial Rattail -> CORE-POS export logic only allows "update" for Vendor model so far. more to come after testing... --- rattail_corepos/commands.py | 17 ++++ rattail_corepos/corepos/importing/model.py | 91 ++++++++++++++++++-- rattail_corepos/corepos/importing/rattail.py | 83 ++++++++++++++++++ rattail_corepos/datasync/corepos.py | 67 +++++++++++++- setup.py | 1 + 5 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 rattail_corepos/corepos/importing/rattail.py diff --git a/rattail_corepos/commands.py b/rattail_corepos/commands.py index a2e147b..f0998d0 100644 --- a/rattail_corepos/commands.py +++ b/rattail_corepos/commands.py @@ -30,6 +30,23 @@ from rattail import commands from rattail.util import load_object +class ExportCore(commands.ImportSubcommand): + """ + Export data from Rattail to CORE-POS + """ + name = 'export-corepos' + description = __doc__.strip() + default_handler_spec = 'rattail_corepos.corepos.importing.rattail:FromRattailToCore' + + def get_handler_factory(self, **kwargs): + if self.config: + spec = self.config.get('rattail.exporting', 'corepos.handler', + default=self.default_handler_spec) + else: + spec = self.default_handler_spec + return load_object(spec) + + class ImportCOREPOS(commands.ImportSubcommand): """ Import data from a CORE POS database diff --git a/rattail_corepos/corepos/importing/model.py b/rattail_corepos/corepos/importing/model.py index 1fcf469..52d2ce9 100644 --- a/rattail_corepos/corepos/importing/model.py +++ b/rattail_corepos/corepos/importing/model.py @@ -1,26 +1,76 @@ # -*- coding: utf-8; -*- """ CORE-POS model importers + +.. warning:: + As of this writing, most classes in this module are "direct DB" importers, + which will write directly to MySQL. They are meant to be used in dry-run + mode only, and/or for sample data import to a dev system etc. They are + *NOT* meant for production use, as they will completely bypass any CORE + business rules logic which may exist. + + However some of the importers will be refactored, so they directly use the + CORE API for *writing* data (although they may continue to read directly + from the DB). The hope is that *all* existing importers can be refactored + to write via API instead of DB, so for now all importers are left intact + until they can be dealt with (hopefully refactored, but if not, then + removed). """ -from __future__ import unicode_literals, absolute_import +from sqlalchemy.orm.exc import NoResultFound from rattail import importing -from corepos.db import model as corepos -from corepos.trans.db import model as coretrans +from corepos.db.office_op import model as corepos +from corepos.db.office_trans import model as coretrans +from corepos.api import CoreWebAPI class ToCore(importing.ToSQLAlchemy): """ - Base class for all CORE (operational) model importers + Base class for all CORE "operational" model importers. + + Note that this class inherits from + :class:`~rattail:rattail.importing.sqlalchemy.ToSQLAlchemy` even though our + goal is to *not* write directly to the CORE DB. However, (for now) + importers will need to override methods to ensure API is used instead, + where applicable. But even once all have been refactored to write via API, + we *still* may want to keep using ``ToSQLAlchemy`` for the sake of reading + "cached local" data. (May depend on how robust the API is.) """ # TODO: should we standardize on the 'id' primary key? (can we even?) # key = 'id' + def setup(self): + self.establish_api() + + def establish_api(self): + url = self.config.require('corepos.api', 'url') + self.api = CoreWebAPI(url) + + # TODO: this looks an awful lot like it belongs in rattail proper + def get_single_local_object(self, key): + """ + Fetch a particular record from CORE, via SQLAlchemy. This is used by + the Rattail -> CORE datasync consumer. + """ + query = self.session.query(self.model_class) + for i, field in enumerate(self.key): + query = query.filter(getattr(self.model_class, field) == key[i]) + + for option in self.cache_query_options(): + query = query.options(option) + + try: + return query.one() + except NoResultFound: + pass + class ToCoreTrans(importing.ToSQLAlchemy): - pass + """ + Base class for all CORE "transaction" model importers + """ ######################################## @@ -39,7 +89,36 @@ class SubdepartmentImporter(ToCore): class VendorImporter(ToCore): model_class = corepos.Vendor - key = 'id' + key = 'vendorID' + supported_fields = [ + 'vendorID', + 'vendorName', + 'vendorAbbreviation', + 'discountRate', + ] + # TODO: this importer is in a bit of an experimental state at the moment. + # we only allow "update" b/c it will use the API instead of direct DB + allow_create = False + allow_delete = False + + def normalize_local_object(self, vendor): + return { + 'vendorID': str(vendor.id), + 'vendorName': vendor.name, + 'vendorAbbreviation': vendor.abbreviation, + 'discountRate': vendor.discount_rate, + } + + def update_object(self, vendor, data, local_data=None): + """ + Push an update for the vendor, via the CORE API. + """ + if self.dry_run: + return data + + vendorID = data.pop('vendorID') + self.api.set_vendor(vendorID, **data) + return data class VendorContactImporter(ToCore): diff --git a/rattail_corepos/corepos/importing/rattail.py b/rattail_corepos/corepos/importing/rattail.py new file mode 100644 index 0000000..0e1a996 --- /dev/null +++ b/rattail_corepos/corepos/importing/rattail.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2020 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 . +# +################################################################################ +""" +Rattail -> CORE-POS data export +""" + +import logging + +from rattail import importing +from rattail.db import model +from rattail.util import OrderedDict +from rattail_corepos.corepos import importing as corepos_importing +from rattail_corepos.corepos.importing.corepos import ToCoreHandler + + +log = logging.getLogger(__name__) + + +class FromRattailToCore(importing.FromRattailHandler, ToCoreHandler): + """ + Rattail -> CORE-POS export handler + """ + host_title = "Rattail" + local_title = "CORE-POS" + direction = 'export' + + def get_importers(self): + importers = OrderedDict() + importers['Vendor'] = VendorImporter + return importers + + +class FromRattail(importing.FromSQLAlchemy): + """ + Base class for Rattail -> CORE-POS exporters. + """ + + +class VendorImporter(FromRattail, corepos_importing.model.VendorImporter): + """ + Vendor data exporter + """ + host_model_class = model.Vendor + key = 'vendorID' + supported_fields = [ + 'vendorID', + 'vendorName', + 'vendorAbbreviation', + 'discountRate', + ] + + def normalize_host_object(self, vendor): + if not vendor.id or not vendor.id.isdigit(): + log.warning("vendor %s has incompatible ID value: %s", + vendor.uuid, vendor.id) + return + + return { + 'vendorID': vendor.id, + 'vendorName': vendor.name, + 'vendorAbbreviation': vendor.abbreviation, + 'discountRate': float(vendor.special_discount), + } diff --git a/rattail_corepos/datasync/corepos.py b/rattail_corepos/datasync/corepos.py index 17a0aa9..7ea713b 100644 --- a/rattail_corepos/datasync/corepos.py +++ b/rattail_corepos/datasync/corepos.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -27,7 +27,7 @@ DataSync for CORE POS from corepos.db.office_op import Session as CoreSession, model as corepos from rattail.db import model -from rattail.datasync import DataSyncWatcher +from rattail.datasync import DataSyncWatcher, NewDataSyncImportConsumer class CoreOfficeOpWatcher(DataSyncWatcher): @@ -130,3 +130,66 @@ class COREPOSProductWatcher(DataSyncWatcher): session.close() return changes + + +class FromRattailToCore(NewDataSyncImportConsumer): + """ + Rattail -> CORE POS datasync consumer + """ + handler_spec = 'rattail_corepos.corepos.importing.rattail:FromRattailToCore' + + def begin_transaction(self): + self.corepos_session = CoreSession() + + def rollback_transaction(self): + self.corepos_session.rollback() + self.corepos_session.close() + + def commit_transaction(self): + self.corepos_session.commit() + self.corepos_session.close() + + def process_changes(self, session, changes): + """ + Process all the given changes, coming from Rattail. + """ + # TODO: this probably doesn't accomplish anything here? + if self.runas_username: + session.set_continuum_user(self.runas_username) + + # update all importers with current Rattail/CORE sessions + for importer in self.importers.values(): + importer.host_session = session + importer.session = self.corepos_session + # also establish the API client for each! + importer.establish_api() + + # next pass syncs all Vendor changes + types = [ + 'Vendor', + 'VendorPhoneNumber', + 'VendorEmailAddress', + ] + for change in [c for c in changes if c.payload_type in types]: + vendor = self.get_host_vendor(session, change) + if vendor: + # TODO: what about "deletions" - not sure what happens yet + self.process_change(session, self.importers['Vendor'], + host_object=vendor) + # self.process_change(session, self.importers['VendorContact'], + # host_object=vendor) + + def get_host_vendor(self, session, change): + + if change.payload_type == 'Vendor': + return session.query(model.Vendor).get(change.payload_key) + + if change.payload_type == 'VendorPhoneNumber': + phone = session.query(model.VendorPhoneNumber).get(change.payload_key) + if phone: + return phone.vendor + + if change.payload_type == 'VendorEmailAddress': + email = session.query(model.VendorEmailAddress).get(change.payload_key) + if email: + return email.vendor diff --git a/setup.py b/setup.py index d06a976..d5c0e0e 100644 --- a/setup.py +++ b/setup.py @@ -112,6 +112,7 @@ setup( ], 'rattail.commands': [ + 'export-corepos = rattail_corepos.commands:ExportCore', 'corepos-import-square = rattail_corepos.commands:CoreImportSquare', 'import-corepos = rattail_corepos.commands:ImportCOREPOS', ],