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:
Lance Edgar 2012-09-17 11:54:31 -07:00
parent efcfaea44f
commit 84229d9577
7 changed files with 519 additions and 3 deletions

View file

@ -28,6 +28,7 @@
import sys import sys
import edbob
from edbob import commands from edbob import commands
import rattail import rattail
@ -124,6 +125,28 @@ class InitCommand(commands.Subcommand):
print ' %s' % engine.url 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): def main(*args):
""" """
The primary entry point for the Rattail command system. The primary entry point for the Rattail command system.

View file

@ -26,11 +26,60 @@
``rattail.db`` -- Database Stuff ``rattail.db`` -- Database Stuff
""" """
import logging
from sqlalchemy.event import listen
import edbob import edbob
import rattail 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): def init(config):
""" """
Initialize the Rattail database framework. Initialize the Rattail database framework.
@ -41,3 +90,6 @@ def init(config):
from rattail.db.extension import enum from rattail.db.extension import enum
edbob.graft(rattail, enum) edbob.graft(rattail, enum)
if config.get('rattail.db', 'record_changes') == 'True':
record_changes(edbob.Session)

View file

@ -45,7 +45,8 @@ from rattail import batches
from rattail.gpc import GPCType 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', 'VendorContact', 'VendorPhoneNumber', 'Product', 'ProductCost',
'ProductPrice', 'Customer', 'CustomerEmailAddress', 'ProductPrice', 'Customer', 'CustomerEmailAddress',
'CustomerPhoneNumber', 'CustomerGroup', 'CustomerGroupAssignment', 'CustomerPhoneNumber', 'CustomerGroup', 'CustomerGroupAssignment',
@ -53,6 +54,22 @@ __all__ = ['Department', 'Subdepartment', 'Brand', 'Category', 'Vendor',
'EmployeePhoneNumber', 'BatchColumn', 'Batch', 'LabelProfile'] '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): class BatchColumn(Base):
""" """
Represents a :class:`SilColumn` associated with a :class:`Batch`. Represents a :class:`SilColumn` associated with a :class:`Batch`.
@ -207,6 +224,84 @@ class Batch(Base):
return q 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): class Brand(Base):
""" """
Represents a brand or similar product line. Represents a brand or similar product line.
@ -220,8 +315,8 @@ class Brand(Base):
def __repr__(self): def __repr__(self):
return "<Brand: %s>" % self.name return "<Brand: %s>" % self.name
def __str__(self): def __unicode__(self):
return str(self.name or '') return unicode(self.name or '')
class Department(Base): class Department(Base):
@ -661,6 +756,7 @@ class Customer(Base):
uuid = uuid_column() uuid = uuid_column()
id = Column(String(20)) id = Column(String(20))
name = Column(String(255)) name = Column(String(255))
email_preference = Column(Integer)
def __repr__(self): def __repr__(self):
return "<Customer: %s, %s>" % (self.id, self.name or self.person) return "<Customer: %s, %s>" % (self.id, self.name or self.person)

151
rattail/db/load.py Normal file
View 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

View 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
View 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)

View file

@ -113,6 +113,7 @@ rattail = rattail.db.extension:RattailExtension
[rattail.commands] [rattail.commands]
filemon = rattail.commands:FileMonitorCommand filemon = rattail.commands:FileMonitorCommand
load-host-data = rattail.commands:LoadHostDataCommand
[rattail.batches.providers] [rattail.batches.providers]
print_labels = rattail.batches.providers.labels:PrintLabels print_labels = rattail.batches.providers.labels:PrintLabels