extended commit (see note)
- Added ``Store`` and related models. - Added ``Customer.email_preference`` field. - Added ``load-host-data`` command. - Added ``Change`` model. - Added ``rattail.db.record_changes()`` function. - Added database synchronization service (Windows only).
This commit is contained in:
parent
efcfaea44f
commit
84229d9577
|
@ -28,6 +28,7 @@
|
|||
|
||||
import sys
|
||||
|
||||
import edbob
|
||||
from edbob import commands
|
||||
|
||||
import rattail
|
||||
|
@ -124,6 +125,28 @@ class InitCommand(commands.Subcommand):
|
|||
print ' %s' % engine.url
|
||||
|
||||
|
||||
class LoadHostDataCommand(commands.Subcommand):
|
||||
"""
|
||||
Loads data from the Rattail host database, if one is configured.
|
||||
"""
|
||||
|
||||
name = 'load-host-data'
|
||||
description = "Load data from host database"
|
||||
|
||||
def run(self, args):
|
||||
from edbob.console import Progress
|
||||
from rattail.db import load
|
||||
|
||||
edbob.init_modules(['edbob.db'])
|
||||
|
||||
if 'host' not in edbob.engines:
|
||||
print "Host engine URL not configured."
|
||||
return
|
||||
|
||||
proc = load.LoadProcessor()
|
||||
proc.load_all_data(edbob.engines['host'], Progress)
|
||||
|
||||
|
||||
def main(*args):
|
||||
"""
|
||||
The primary entry point for the Rattail command system.
|
||||
|
|
|
@ -26,11 +26,60 @@
|
|||
``rattail.db`` -- Database Stuff
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.event import listen
|
||||
|
||||
import edbob
|
||||
|
||||
import rattail
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def before_flush(session, flush_context, instances):
|
||||
"""
|
||||
Listens for session flush events. This function is responsible for adding
|
||||
stub records to the 'changes' table, which will in turn be used by the
|
||||
database synchronizer.
|
||||
"""
|
||||
|
||||
def record_change(instance, deleted=False):
|
||||
if instance.__class__ is rattail.Change:
|
||||
return
|
||||
if not hasattr(instance, 'uuid'):
|
||||
return
|
||||
if not instance.uuid:
|
||||
instance.uuid = edbob.get_uuid()
|
||||
change = session.query(rattail.Change).get(
|
||||
(instance.__class__.__name__, instance.uuid))
|
||||
if not change:
|
||||
change = rattail.Change(
|
||||
class_name=instance.__class__.__name__,
|
||||
uuid=instance.uuid)
|
||||
session.add(change)
|
||||
change.deleted = deleted
|
||||
log.debug("before_flush: recorded change: %s" % repr(change))
|
||||
|
||||
for instance in session.deleted:
|
||||
log.debug("before_flush: deleted instance: %s" % repr(instance))
|
||||
record_change(instance, deleted=True)
|
||||
|
||||
for instance in session.new:
|
||||
log.debug("before_flush: new instance: %s" % repr(instance))
|
||||
record_change(instance)
|
||||
|
||||
for instance in session.dirty:
|
||||
if session.is_modified(instance, passive=True):
|
||||
log.debug("before_flush: dirty instance: %s" % repr(instance))
|
||||
record_change(instance)
|
||||
|
||||
|
||||
def record_changes(session):
|
||||
listen(session, 'before_flush', before_flush)
|
||||
|
||||
|
||||
def init(config):
|
||||
"""
|
||||
Initialize the Rattail database framework.
|
||||
|
@ -41,3 +90,6 @@ def init(config):
|
|||
|
||||
from rattail.db.extension import enum
|
||||
edbob.graft(rattail, enum)
|
||||
|
||||
if config.get('rattail.db', 'record_changes') == 'True':
|
||||
record_changes(edbob.Session)
|
||||
|
|
|
@ -45,7 +45,8 @@ from rattail import batches
|
|||
from rattail.gpc import GPCType
|
||||
|
||||
|
||||
__all__ = ['Department', 'Subdepartment', 'Brand', 'Category', 'Vendor',
|
||||
__all__ = ['Change', 'Store', 'StoreEmailAddress', 'StorePhoneNumber',
|
||||
'Department', 'Subdepartment', 'Brand', 'Category', 'Vendor',
|
||||
'VendorContact', 'VendorPhoneNumber', 'Product', 'ProductCost',
|
||||
'ProductPrice', 'Customer', 'CustomerEmailAddress',
|
||||
'CustomerPhoneNumber', 'CustomerGroup', 'CustomerGroupAssignment',
|
||||
|
@ -53,6 +54,22 @@ __all__ = ['Department', 'Subdepartment', 'Brand', 'Category', 'Vendor',
|
|||
'EmployeePhoneNumber', 'BatchColumn', 'Batch', 'LabelProfile']
|
||||
|
||||
|
||||
class Change(Base):
|
||||
"""
|
||||
Represents a changed (or deleted) record, which is pending synchronization
|
||||
to another database.
|
||||
"""
|
||||
|
||||
__tablename__ = 'changes'
|
||||
|
||||
class_name = Column(String(25), primary_key=True)
|
||||
uuid = Column(String(32), primary_key=True)
|
||||
deleted = Column(Boolean)
|
||||
|
||||
def __repr__(self):
|
||||
return "<Change: %s, %s>" % (self.class_name, self.uuid)
|
||||
|
||||
|
||||
class BatchColumn(Base):
|
||||
"""
|
||||
Represents a :class:`SilColumn` associated with a :class:`Batch`.
|
||||
|
@ -207,6 +224,84 @@ class Batch(Base):
|
|||
return q
|
||||
|
||||
|
||||
class StoreEmailAddress(EmailAddress):
|
||||
"""
|
||||
Represents an email address associated with a :class:`Store`.
|
||||
"""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'Store'}
|
||||
|
||||
|
||||
class StorePhoneNumber(PhoneNumber):
|
||||
"""
|
||||
Represents a phone (or fax) number associated with a :class:`Store`.
|
||||
"""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'Store'}
|
||||
|
||||
|
||||
class Store(Base):
|
||||
"""
|
||||
Represents a store (physical or otherwise) within the organization.
|
||||
"""
|
||||
|
||||
__tablename__ = 'stores'
|
||||
|
||||
uuid = uuid_column()
|
||||
id = Column(String(10))
|
||||
name = Column(String(100))
|
||||
|
||||
def __repr__(self):
|
||||
return "<Store: %s, %s>" % (self.id, self.name)
|
||||
|
||||
def __unicode__(self):
|
||||
return unicode(self.name or '')
|
||||
|
||||
def add_email_address(self, address, type='Info'):
|
||||
email = StoreEmailAddress(address=address, type=type)
|
||||
self.emails.append(email)
|
||||
|
||||
def add_phone_number(self, number, type='Voice'):
|
||||
phone = StorePhoneNumber(number=number, type=type)
|
||||
self.phones.append(phone)
|
||||
|
||||
Store.emails = relationship(
|
||||
StoreEmailAddress,
|
||||
backref='store',
|
||||
primaryjoin=StoreEmailAddress.parent_uuid == Store.uuid,
|
||||
foreign_keys=[StoreEmailAddress.parent_uuid],
|
||||
collection_class=ordering_list('preference', count_from=1),
|
||||
order_by=StoreEmailAddress.preference,
|
||||
cascade='save-update, merge, delete, delete-orphan')
|
||||
|
||||
Store.email = relationship(
|
||||
StoreEmailAddress,
|
||||
primaryjoin=and_(
|
||||
StoreEmailAddress.parent_uuid == Store.uuid,
|
||||
StoreEmailAddress.preference == 1),
|
||||
foreign_keys=[StoreEmailAddress.parent_uuid],
|
||||
uselist=False,
|
||||
viewonly=True)
|
||||
|
||||
Store.phones = relationship(
|
||||
StorePhoneNumber,
|
||||
backref='store',
|
||||
primaryjoin=StorePhoneNumber.parent_uuid == Store.uuid,
|
||||
foreign_keys=[StorePhoneNumber.parent_uuid],
|
||||
collection_class=ordering_list('preference', count_from=1),
|
||||
order_by=StorePhoneNumber.preference,
|
||||
cascade='save-update, merge, delete, delete-orphan')
|
||||
|
||||
Store.phone = relationship(
|
||||
StorePhoneNumber,
|
||||
primaryjoin=and_(
|
||||
StorePhoneNumber.parent_uuid == Store.uuid,
|
||||
StorePhoneNumber.preference == 1),
|
||||
foreign_keys=[StorePhoneNumber.parent_uuid],
|
||||
uselist=False,
|
||||
viewonly=True)
|
||||
|
||||
|
||||
class Brand(Base):
|
||||
"""
|
||||
Represents a brand or similar product line.
|
||||
|
@ -220,8 +315,8 @@ class Brand(Base):
|
|||
def __repr__(self):
|
||||
return "<Brand: %s>" % self.name
|
||||
|
||||
def __str__(self):
|
||||
return str(self.name or '')
|
||||
def __unicode__(self):
|
||||
return unicode(self.name or '')
|
||||
|
||||
|
||||
class Department(Base):
|
||||
|
@ -661,6 +756,7 @@ class Customer(Base):
|
|||
uuid = uuid_column()
|
||||
id = Column(String(20))
|
||||
name = Column(String(255))
|
||||
email_preference = Column(Integer)
|
||||
|
||||
def __repr__(self):
|
||||
return "<Customer: %s, %s>" % (self.id, self.name or self.person)
|
||||
|
|
151
rattail/db/load.py
Normal file
151
rattail/db/load.py
Normal file
|
@ -0,0 +1,151 @@
|
|||
#!/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.load`` -- Load Data from Host
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
import edbob
|
||||
import edbob.db
|
||||
|
||||
import rattail
|
||||
|
||||
|
||||
class LoadProcessor(edbob.Object):
|
||||
|
||||
def load_all_data(self, host_engine, progress=None):
|
||||
|
||||
edbob.init_modules(['edbob.db', 'rattail.db'])
|
||||
|
||||
self.host_session = edbob.Session(bind=host_engine)
|
||||
self.session = edbob.Session()
|
||||
|
||||
cancel = False
|
||||
for cls in self.relevant_classes():
|
||||
if not self.load_class_data(cls, progress):
|
||||
cancel = True
|
||||
break
|
||||
|
||||
self.host_session.close()
|
||||
if cancel:
|
||||
self.session.rollback()
|
||||
else:
|
||||
self.session.commit()
|
||||
self.session.close()
|
||||
return not cancel
|
||||
|
||||
def load_class_data(self, cls, progress=None):
|
||||
query = self.host_session.query(cls)
|
||||
if hasattr(self, 'query_%s' % cls.__name__):
|
||||
query = getattr(self, 'query_%s' % cls.__name__)(query)
|
||||
|
||||
count = query.count()
|
||||
if not count:
|
||||
return True
|
||||
|
||||
prog = None
|
||||
if progress:
|
||||
prog = progress("Loading %s data" % cls.__name__, count)
|
||||
|
||||
cancel = False
|
||||
for i, instance in enumerate(query, 1):
|
||||
if hasattr(self, 'merge_%s' % cls.__name__):
|
||||
getattr(self, 'merge_%s' % cls.__name__)(instance)
|
||||
else:
|
||||
self.session.merge(instance)
|
||||
self.session.flush()
|
||||
if prog and not prog.update(i):
|
||||
cancel = True
|
||||
break
|
||||
|
||||
if prog:
|
||||
prog.destroy()
|
||||
return not cancel
|
||||
|
||||
def relevant_classes(self):
|
||||
yield edbob.Person
|
||||
yield edbob.User
|
||||
yield rattail.Store
|
||||
yield rattail.Department
|
||||
yield rattail.Subdepartment
|
||||
yield rattail.Category
|
||||
yield rattail.Brand
|
||||
yield rattail.Vendor
|
||||
yield rattail.Product
|
||||
yield rattail.CustomerGroup
|
||||
yield rattail.Customer
|
||||
yield rattail.Employee
|
||||
|
||||
def query_Customer(self, q):
|
||||
q = q.options(joinedload(rattail.Customer.phones))
|
||||
q = q.options(joinedload(rattail.Customer.emails))
|
||||
q = q.options(joinedload(rattail.Customer._people))
|
||||
q = q.options(joinedload(rattail.Customer._groups))
|
||||
return q
|
||||
|
||||
def query_CustomerPerson(self, q):
|
||||
q = q.options(joinedload(rattail.CustomerPerson.person))
|
||||
return q
|
||||
|
||||
def query_Employee(self, q):
|
||||
q = q.options(joinedload(rattail.Employee.phones))
|
||||
q = q.options(joinedload(rattail.Employee.emails))
|
||||
return q
|
||||
|
||||
def query_Person(self, q):
|
||||
q = q.options(joinedload(edbob.Person.phones))
|
||||
q = q.options(joinedload(edbob.Person.emails))
|
||||
return q
|
||||
|
||||
def query_Product(self, q):
|
||||
q = q.options(joinedload(rattail.Product.costs))
|
||||
q = q.options(joinedload(rattail.Product.prices))
|
||||
return q
|
||||
|
||||
def merge_Product(self, host_product):
|
||||
# This logic is necessary due to the inter-dependency between Product
|
||||
# and ProductPrice tables. merge() will cause a flush(); however it
|
||||
# apparently will not honor the 'post_update=True' flag on the relevant
|
||||
# relationships.. I'm unclear whether this is a "bug" with SQLAlchemy,
|
||||
# but the workaround is simple enough that I'm leaving it for now.
|
||||
product = self.session.merge(host_product)
|
||||
product.regular_price_uuid = None
|
||||
product.current_price_uuid = None
|
||||
if host_product.regular_price_uuid:
|
||||
product.regular_price = self.session.merge(host_product.regular_price)
|
||||
if host_product.current_price_uuid:
|
||||
product.current_price = self.session.merge(host_product.current_price)
|
||||
|
||||
def query_Store(self, q):
|
||||
q = q.options(joinedload(rattail.Store.phones))
|
||||
q = q.options(joinedload(rattail.Store.emails))
|
||||
return q
|
||||
|
||||
def query_Vendor(self, q):
|
||||
q = q.options(joinedload(rattail.Vendor.contacts))
|
||||
q = q.options(joinedload(rattail.Vendor.phones))
|
||||
q = q.options(joinedload(rattail.Vendor.emails))
|
||||
return q
|
28
rattail/db/sync/__init__.py
Normal file
28
rattail/db/sync/__init__.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
#!/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
|
||||
"""
|
||||
|
165
rattail/db/sync/win32.py
Normal file
165
rattail/db/sync/win32.py
Normal file
|
@ -0,0 +1,165 @@
|
|||
#!/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.win32`` -- Database Synchronization for Windows
|
||||
"""
|
||||
|
||||
import sys
|
||||
import logging
|
||||
import threading
|
||||
|
||||
if sys.platform == 'win32': # docs should build for everyone
|
||||
import win32api
|
||||
import win32serviceutil
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy.orm import class_mapper
|
||||
|
||||
import edbob
|
||||
from edbob.win32 import Service
|
||||
|
||||
import rattail
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DatabaseSynchronizerService(Service):
|
||||
"""
|
||||
Implements database synchronization as a Windows service.
|
||||
"""
|
||||
|
||||
_svc_name_ = 'RattailDatabaseSynchronizer'
|
||||
_svc_display_name_ = "Rattail : Database Synchronization Service"
|
||||
_svc_description_ = ("Monitors the local Rattail database for changes, "
|
||||
"and synchronizes them to the configured remote "
|
||||
"database(s).")
|
||||
|
||||
appname = 'rattail'
|
||||
|
||||
def Initialize(self):
|
||||
"""
|
||||
Service initialization.
|
||||
"""
|
||||
|
||||
if not Service.Initialize(self):
|
||||
return False
|
||||
|
||||
edbob.init_modules(['edbob.db', 'rattail.db'])
|
||||
|
||||
keys = edbob.config.get('rattail.db', 'syncs')
|
||||
if not keys:
|
||||
return False
|
||||
|
||||
engines = {}
|
||||
for key in keys.split(','):
|
||||
key = key.strip()
|
||||
engines[key] = edbob.engines[key]
|
||||
|
||||
thread = threading.Thread(target=synchronize_changes,
|
||||
args=(engines,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def synchronize_changes(engines):
|
||||
|
||||
while True:
|
||||
local_session = edbob.Session()
|
||||
local_changes = local_session.query(rattail.Change)
|
||||
|
||||
if local_changes.count():
|
||||
|
||||
class_names = []
|
||||
for class_name in local_session.query(rattail.Change.class_name.distinct()):
|
||||
class_names.append(class_name[0])
|
||||
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):
|
||||
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:
|
||||
remote_session.delete(remote_instance)
|
||||
remote_session.flush()
|
||||
|
||||
else: # new/dirty
|
||||
local_instance = local_session.query(cls).get(change.uuid)
|
||||
for remote_session in remote_sessions:
|
||||
remote_session.merge(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()
|
||||
win32api.Sleep(3000)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
win32serviceutil.HandleCommandLine(DatabaseSynchronizerService)
|
Loading…
Reference in a new issue