Initial support for basic WooCommerce integration

can export products to WooCommerce, plus maintain local cache, and track Woo ID
for each rattail product
This commit is contained in:
Lance Edgar 2021-01-20 21:52:29 -06:00
commit 950a153342
24 changed files with 2214 additions and 0 deletions
rattail_woocommerce/woocommerce/importing

View file

@ -0,0 +1,27 @@
# -*- 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/>.
#
################################################################################
"""
Importing data into WooCommerce
"""
from . import model

View file

@ -0,0 +1,193 @@
# -*- 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/>.
#
################################################################################
"""
WooCommerce model importers
"""
from woocommerce import API as WooAPI
from rattail import importing
class ToWooCommerce(importing.Importer):
pass
class ProductImporter(ToWooCommerce):
"""
WooCommerce product data importer
"""
model_name = 'Product'
key = 'sku'
supported_fields = [
'id',
'name',
'slug',
'permalink',
'date_created',
'date_created_gmt',
'date_modified',
'date_modified_gmt',
'type',
'status',
'featured',
'catalog_visibility',
'description',
'short_description',
'sku',
'price',
'regular_price',
'sale_price',
'date_on_sale_from',
'date_on_sale_from_gmt',
'date_on_sale_to',
'date_on_sale_to_gmt',
'price_html',
'on_sale',
'purchasable',
'total_sales',
'tax_status',
'tax_class',
'manage_stock',
'stock_quantity',
'stock_status',
'backorders',
'backorders_allowed',
'backordered',
'sold_individually',
'weight',
'reviews_allowed',
'parent_id',
'purchase_note',
'menu_order',
]
caches_local_data = True
# TODO: would be nice to just set this here, but command args will always
# overwrite, b/c that defaults to 200 even when not specified by user
#batch_size = 100
def setup(self):
super(ProductImporter, self).setup()
self.establish_api()
self.to_be_created = []
self.to_be_updated = []
def datasync_setup(self):
super(ProductImporter, self).datasync_setup()
self.establish_api()
self.batch_size = 1
def establish_api(self):
kwargs = {
'url': self.config.require('woocommerce', 'url'),
'consumer_key': self.config.require('woocommerce', 'api_consumer_key'),
'consumer_secret': self.config.require('woocommerce', 'api_consumer_secret'),
'version': 'wc/v3',
'timeout': 30,
}
self.api = WooAPI(**kwargs)
def cache_local_data(self, host_data=None):
"""
Fetch existing products from WooCommerce.
"""
cache = {}
page = 1
while True:
response = self.api.get('products', params={'per_page': 100,
'page': page})
for product in response.json():
data = self.normalize_local_object(product)
normal = self.normalize_cache_object(product, data)
key = self.get_cache_key(product, normal)
cache[key] = normal
# TODO: this seems a bit hacky, is there a better way?
link = response.headers.get('Link')
if link and 'rel="next"' in link:
page += 1
else:
break
return cache
def get_single_local_object(self, key):
assert self.key == ('id',)
woo_id = key[0]
# note, we avoid negative id here b/c that trick is used elsewhere
if woo_id > 0:
response = self.api.get('products/{}'.format(key[0]))
return response.json()
def normalize_local_object(self, api_product):
return dict(api_product)
def create_object(self, key, host_data):
data = dict(host_data)
data.pop('id', None)
if self.batch_size == 1: # datasync
if self.dry_run:
return data
response = self.api.post('products', data)
return response.json()
# collect data to be posted later
self.to_be_created.append(data)
return data
def update_object(self, api_product, host_data, local_data=None, all_fields=False):
if self.batch_size == 1: # datasync
if self.dry_run:
return host_data
response = self.api.post('products/{}'.format(api_product['id']), host_data)
return response.json()
# collect data to be posted later
self.to_be_updated.append(host_data)
return host_data
def flush_create_update(self):
if not self.dry_run and self.batch_size > 1: # not datasync
self.post_products_batch()
def post_products_batch(self):
"""
Push pending create/update data batch to WooCommerce API.
"""
assert not self.dry_run
response = self.api.post('products/batch', {'create': self.to_be_created,
'update': self.to_be_updated})
# clear the pending lists, since we've now pushed that data
self.to_be_created = []
self.to_be_updated = []
return response
def delete_object(self, api_product):
if self.dry_run:
return True
raise NotImplementedError

View file

@ -0,0 +1,205 @@
# -*- 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/>.
#
################################################################################
"""
Rattail -> WooCommerce importing
"""
from rattail import importing
from rattail.db import model
from rattail.util import OrderedDict
from rattail_woocommerce.db.model import WooCacheProduct
from rattail_woocommerce.woocommerce import importing as woocommerce_importing
class FromRattailToWooCommerce(importing.FromRattailHandler):
"""
Rattail -> WooCommerce import handler
"""
local_title = "WooCommerce"
direction = 'export'
# necessary to save new WooCacheProduct records along the way
# (since they really are created immediately in Woo via API)
commit_host_partial = True
@property
def host_title(self):
return self.config.app_title(default="Rattail")
def get_importers(self):
importers = OrderedDict()
importers['Product'] = ProductImporter
return importers
class FromRattail(importing.FromSQLAlchemy):
"""
Base class for WooCommerce -> Rattail importers
"""
class ProductImporter(FromRattail, woocommerce_importing.model.ProductImporter):
"""
Product data importer
"""
host_model_class = model.Product
key = 'id'
supported_fields = [
'id',
'name',
'sku',
'regular_price',
'sale_price',
'date_on_sale_from_gmt',
'date_on_sale_to_gmt',
]
def setup(self):
super(ProductImporter, self).setup()
self.init_woo_id_counter()
self.establish_cache_importer()
def datasync_setup(self):
super(ProductImporter, self).datasync_setup()
self.init_woo_id_counter()
self.establish_cache_importer()
def init_woo_id_counter(self):
self.next_woo_id = 1
def establish_cache_importer(self):
from rattail_woocommerce.importing.woocommerce import WooCacheProductImporter
# we'll use this importer to update our local cache
self.cache_importer = WooCacheProductImporter(config=self.config)
self.cache_importer.session = self.host_session
def normalize_host_object(self, product):
woo_id = product.woocommerce_id
if not woo_id:
# note, we set to negative to ensure it won't exist but is unique.
# but we will not actually try to push this value to Woo
woo_id = -self.next_woo_id
self.next_woo_id += 1
regular_price = ''
if product.regular_price and product.regular_price.price:
regular_price = '{:0.2f}'.format(product.regular_price.price)
sale_price = ''
if product.sale_price and product.sale_price.price:
sale_price = '{:0.2f}'.format(product.sale_price.price)
date_on_sale_from_gmt = '1900-01-01T00:00:00'
if product.sale_price and product.sale_price.starts:
dt = localtime(self.config, product.sale_price.starts,
from_utc=True, zoneinfo=False)
date_on_sale_from_gmt = dt.isoformat()
date_on_sale_to_gmt = '1900-01-01T00:00:00'
if product.sale_price and product.sale_price.starts:
dt = localtime(self.config, product.sale_price.starts,
from_utc=True, zoneinfo=False)
date_on_sale_to_gmt = dt.isoformat()
return {
'id': woo_id,
'name': product.description,
'sku': product.item_id,
'regular_price': regular_price,
'sale_price': sale_price,
'date_on_sale_from_gmt': date_on_sale_from_gmt,
'date_on_sale_to_gmt': date_on_sale_to_gmt,
}
def create_object(self, key, host_data):
# push create to API as normal
api_product = super(ProductImporter, self).create_object(key, host_data)
# also create local cache record, if running in datasync
if self.batch_size == 1: # datasync
self.update_woocache(api_product)
return api_product
def update_object(self, api_product, host_data, local_data=None, **kwargs):
# push update to API as normal
api_product = super(ProductImporter, self).update_object(api_product, host_data,
local_data=local_data,
**kwargs)
# also update local cache record, if running in datasync
if self.batch_size == 1:
self.update_woocache(api_product)
return api_product
def post_products_batch(self):
# first post batch to API as normal
response = super(ProductImporter, self).post_products_batch()
data = response.json()
def create_cache(api_product, i):
self.update_woocache(api_product)
self.progress_loop(create_cache, data.get('create', []),
message="Updating cache for created items")
self.host_session.flush()
def update_cache(api_product, i):
# re-fetch the api_product to make sure we have right info. for
# some reason at least one field is represented differently, when
# we fetch the record vs. how it appears in the batch response.
api_product = self.api.get('products/{}'.format(api_product['id']))\
.json()
self.update_woocache(api_product)
self.progress_loop(update_cache, data.get('update', []),
message="Updating cache for updated items")
self.host_session.flush()
return response
def update_woocache(self, api_product):
# normalize data and process importer update
normal = self.cache_importer.normalize_host_object(api_product)
key = self.cache_importer.get_key(normal)
cache_product = self.cache_importer.get_local_object(key)
if cache_product:
cache_normal = self.cache_importer.normalize_local_object(cache_product)
cache_product = self.cache_importer.update_object(
cache_product, normal, local_data=cache_normal)
else:
cache_product = self.cache_importer.create_object(key, normal)
# update cached woo_id too, if we can
self.host_session.flush()
if cache_product and cache_product.product:
product = cache_product.product
if product.woocommerce_id != api_product['id']:
product.woocommerce_id = api_product['id']