Changeset - 84229d95776a
[Not reviewed]
0 4 3
Lance Edgar (lance) - 12 years ago 2012-09-17 13:54:31
lance@edbob.org
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).
7 files changed with 519 insertions and 3 deletions:
0 comments (0 inline, 0 general)
rattail/commands.py
Show inline comments
 
@@ -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.
rattail/db/__init__.py
Show inline comments
 
@@ -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)
rattail/db/extension/model.py
Show inline comments
 
@@ -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)
rattail/db/load.py
Show inline comments
 
new file 100644
 
#!/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
rattail/db/sync/__init__.py
Show inline comments
 
new file 100644
 
#!/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
 
"""
 

	
rattail/db/sync/win32.py
Show inline comments
 
new file 100644
 
#!/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)
setup.py
Show inline comments
 
@@ -113,6 +113,7 @@ rattail = rattail.db.extension:RattailExtension
 

	
 
[rattail.commands]
 
filemon = rattail.commands:FileMonitorCommand
 
load-host-data = rattail.commands:LoadHostDataCommand
 

	
 
[rattail.batches.providers]
 
print_labels = rattail.batches.providers.labels:PrintLabels
0 comments (0 inline, 0 general)