rattail/rattail/db/sync/__init__.py
Lance Edgar 1aca3390b4 Changed some logging instances from INFO to DEBUG.
I was just getting tired of the noise.
2013-05-16 07:03:17 -07:00

289 lines
10 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.db.sync`` -- Database Synchronization
"""
import sys
import time
import logging
if sys.platform == 'win32':
import win32api
import sqlalchemy.exc
from sqlalchemy.orm import class_mapper
import edbob
import rattail
log = logging.getLogger(__name__)
def get_sync_engines():
edbob.init_modules(['edbob.db'])
keys = edbob.config.get('rattail.db', 'syncs')
if not keys:
return None
engines = {}
for key in keys.split(','):
key = key.strip()
engines[key] = edbob.engines[key]
log.debug("get_sync_engines: Found engine keys: %s" % ','.join(engines.keys()))
return engines
def dependency_sort(x, y):
map_x = class_mapper(getattr(edbob, x))
map_y = class_mapper(getattr(edbob, y))
dep_x = []
table_y = map_y.tables[0].name
for column in map_x.columns:
for key in column.foreign_keys:
if key.column.table.name == table_y:
return 1
dep_x.append(key)
dep_y = []
table_x = map_x.tables[0].name
for column in map_y.columns:
for key in column.foreign_keys:
if key.column.table.name == table_x:
return -1
dep_y.append(key)
if dep_x and not dep_y:
return 1
if dep_y and not dep_x:
return -1
return 0
class Synchronizer(object):
"""
Default implementation of database synchronization logic. Subclass this if
you have special processing needs.
"""
def synchronize_changes(self, engines):
log.info("synchronize_changes: Using engine keys: %s" % ','.join(engines.keys()))
while True:
try:
local_session = edbob.Session()
local_changes = local_session.query(rattail.Change)
if local_changes.count():
log.debug("synchronize_changes: found %d changes" % local_changes.count())
# First we must determine which types of instances are in need of
# syncing. The order will matter because of class dependencies.
# However the dependency_sort() call doesn't *quite* take care of
# everything - notably the Product/ProductPrice situation. Since
# those classes are mutually dependent, we start with a hackish
# lexical sort and hope for the best...
q = local_session.query(rattail.Change.class_name.distinct())
q = q.order_by('class_name')
class_names = [x[0] for x in q]
class_names.sort(cmp=dependency_sort)
remote_sessions = []
for remote_engine in engines.itervalues():
remote_sessions.append(
edbob.Session(bind=remote_engine))
for class_name in class_names:
for change in local_changes.filter_by(class_name=class_name):
log.debug("synchronize_changes: processing change: %s" % change)
cls = getattr(edbob, change.class_name)
if change.deleted:
for remote_session in remote_sessions:
remote_instance = remote_session.query(cls).get(change.uuid)
if remote_instance:
self.delete_instance(remote_session, remote_instance)
remote_session.flush()
else: # new/dirty
local_instance = local_session.query(cls).get(change.uuid)
if local_instance:
for remote_session in remote_sessions:
self.merge_instance(remote_session, local_instance)
remote_session.flush()
local_session.delete(change)
local_session.flush()
for remote_session in remote_sessions:
remote_session.commit()
remote_session.close()
local_session.commit()
local_session.close()
self.sleep(3)
except sqlalchemy.exc.OperationalError, error:
if error.connection_invalidated:
# Presumably a database server restart; give it a moment
# and try again.
self.sleep(5)
else:
raise
def sleep(self, seconds):
if sys.platform == 'win32':
win32api.Sleep(seconds * 1000)
else:
time.sleep(seconds)
def merge_instance(self, session, instance):
"""
Merge ``instance`` into ``session``.
This method checks for other "special" methods based on the class of
``instance``. If such a method is found, it is invoked; otherwise a
simple merge is done.
"""
cls = instance.__class__.__name__
if hasattr(self, 'merge_%s' % cls):
return getattr(self, 'merge_%s' % cls)(session, instance)
return session.merge(instance)
def merge_Product(self, session, source_product):
"""
This method is somewhat of a hack, in order to properly handle
:class:`rattail.Product` instances and the interdependent nature of the
related :class:`rattail.ProductPrice` instances.
"""
target_product = session.merge(source_product)
# I'm not 100% sure I understand this correctly, but here's my
# thinking: First we clear the price relationships in case they've
# actually gone away; then we re-establish any which are currently
# valid.
# Setting the price relationship attributes to ``None`` isn't enough to
# force the ORM to notice a change, since the UUID field is ultimately
# what it's watching. So we must explicitly use that field here.
target_product.regular_price_uuid = None
target_product.current_price_uuid = None
# If the source instance has currently valid price relationships, then
# we re-establish them. We must merge the source price instance in
# order to be certain it will exist in the target session, and avoid
# foreign key errors. However we *still* must also set the UUID fields
# because again, the ORM is watching those... This was noticed to be
# the source of some bugs where successive database syncs were
# effectively "toggling" the price relationship. Setting the UUID
# field explicitly seems to solve it.
if source_product.regular_price_uuid:
target_product.regular_price = session.merge(source_product.regular_price)
target_product.regular_price_uuid = target_product.regular_price.uuid
if source_product.current_price_uuid:
target_product.current_price = session.merge(source_product.current_price)
target_product.current_price_uuid = target_product.current_price.uuid
return target_product
def delete_instance(self, session, instance):
"""
Delete ``instance`` using ``session``.
This method checks for other "special" methods based on the class of
``instance``. If such a method is found, it is invoked before the
instance is officially deleted from ``session``.
"""
cls = instance.__class__.__name__
if hasattr(self, 'delete_%s' % cls):
getattr(self, 'delete_%s' % cls)(session, instance)
session.delete(instance)
def delete_Department(self, session, department):
# Remove association from Subdepartment records.
q = session.query(rattail.Subdepartment)
q = q.filter(rattail.Subdepartment.department == department)
for subdepartment in q:
subdepartment.department = None
# Remove association from Product records.
q = session.query(rattail.Product)
q = q.filter(rattail.Product.department == department)
for product in q:
product.department = None
def delete_Subdepartment(self, session, subdepartment):
# Remove association from Product records.
q = session.query(rattail.Product)
q = q.filter(rattail.Product.subdepartment == subdepartment)
for product in q:
product.subdepartment = None
def delete_Vendor(self, session, vendor):
# Remove associated ProductCost records.
q = session.query(rattail.ProductCost)
q = q.filter(rattail.ProductCost.vendor == vendor)
for cost in q:
session.delete(cost)
def synchronize_changes(engines):
"""
This function will instantiate a ``Synchronizer`` class according to
configuration. (The default class is :class:`Synchronizer`.) This
instance is then responsible for implementing the sync logic.
.. highlight:: ini
If you need to override the default synchronizer class, put something like
the following in your config file::
[rattail.db]
sync.synchronizer_class = myapp.sync:MySynchronizer
"""
cls = edbob.config.get('rattail.db', 'sync.synchronizer_class')
if cls:
cls = edbob.load_spec(cls)
else:
cls = Synchronizer
sync = cls()
sync.synchronize_changes(engines)