# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 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/>.
#
################################################################################
"""
Data Changes Interface
"""
import logging
from packaging.version import parse as parse_version
import sqlalchemy as sa
from sqlalchemy.orm import object_mapper, RelationshipProperty
from sqlalchemy.orm.session import Session
from sqlalchemy.event import listen
from rattail.db.model import Setting, Change, DataSyncChange
try:
from rattail.db.continuum import versioning_manager
except ImportError: # pragma: no cover
versioning_manager = None
log = logging.getLogger(__name__)
[docs]
def record_changes(session, recorder=None, config=None):
"""
Turn on the "record changes" feature.
With this enabled, all relevant data changes which occur in the
sesion will be recorded.
:param session: A
:class:`sqlalchemy:sqlalchemy.orm.session.Session` class, or
instance thereof.
"""
if isinstance(recorder, ChangeRecorder):
pass
elif callable(recorder):
recorder = recorder(config)
elif recorder is None:
if config:
app = config.get_app()
spec = config.get('rattail.db.changes.recorder', usedb=False)
if spec:
recorder = app.load_object(spec)(config)
if not recorder:
recorder = ChangeRecorder(config)
else:
raise ValueError(f"recorder not valid: {recorder}")
listen(session, 'before_flush', recorder)
session.rattail_record_changes = True
session.rattail_change_recorder = recorder
[docs]
class ChangeRecorder:
"""
Listener for session ``before_flush`` events.
This class is responsible for adding stub records to the ``changes`` table,
which will in turn be used by the database synchronizer to manage change
data propagation.
"""
ignored_classes = (
Setting,
Change,
DataSyncChange,
)
# once upon a time we supposedly needed to specify `passive=True` when
# invoking `session.is_modified()` but that has apparently been deprecated
# for some time now. we likely should just require SA>=0.8 instead of
# maintaining this logic?
is_modified_kw = {}
if parse_version(sa.__version__) < parse_version('0.8'):
is_modified_kw['passive'] = True
def __init__(self, config):
self.config = config
if self.config:
self.app = self.config.get_app()
self.model = self.app.model
def __call__(self, session, flush_context, instances):
"""
Method invoked when session ``before_flush`` event occurs.
"""
# TODO: what a mess, need to look into this again at some point...
# # TODO: Not sure if our event replaces the one registered by Continuum,
# # or what. But this appears to be necessary to keep that system
# # working when we enable ours...
# if versioning_manager:
# versioning_manager.before_flush(session, flush_context, instances)
for obj in session.deleted:
if not self.ignore_object(obj):
self.process_deleted_object(session, obj)
for obj in session.new:
if not self.ignore_object(obj):
self.process_new_object(session, obj)
for obj in session.dirty:
if not self.ignore_object(obj) and session.is_modified(obj, **self.is_modified_kw):
# Orphaned objects which really are pending deletion show up in
# session.dirty instead of session.deleted, hence this check.
# https://groups.google.com/d/msg/sqlalchemy/H4nQTHphc0M/Xr8-Cgra0Z4J
if self.is_deletable_orphan(obj):
self.process_deleted_object(session, obj)
else:
self.process_dirty_object(session, obj)
[docs]
def ignore_object(self, obj):
"""
Return ``True`` if changes for the given object should be ignored.
"""
app = self.config.get_app()
model = self.app.model
# ignore certain classes per declaration
if isinstance(obj, self.ignored_classes):
return True
# TODO: is there a smarter way to check?
# definitely don't care about changes to any version tables
if obj.__class__.__name__.endswith('Version'):
return True
# ignore LabelProfile objects which are *not* marked "sync me"
if isinstance(obj, model.LabelProfile) and not obj.sync_me:
return True
return False # i.e. don't ignore
[docs]
def process_new_object(self, session, obj):
"""
Record changes as appropriate, for the given 'new' object.
"""
self.record_rattail_change(session, obj, type_='new')
[docs]
def process_dirty_object(self, session, obj):
"""
Record changes as appropriate, for the given 'dirty' object.
"""
self.record_rattail_change(session, obj, type_='dirty')
[docs]
def process_deleted_object(self, session, obj):
"""
Record changes as appropriate, for the given 'deleted' object.
"""
model = self.app.model
# TODO: should perhaps find a "cleaner" way to handle these..?
# mark Person as dirty, when contact info is removed
if isinstance(obj, (model.PersonEmailAddress, model.PersonPhoneNumber, model.PersonMailingAddress)):
self.record_rattail_change(session, obj.person, type_='dirty')
# mark Employee as dirty, when department info is removed
if isinstance(obj, (model.EmployeeStore, model.EmployeeDepartment)):
self.record_rattail_change(session, obj.employee, type_='dirty')
# mark Product as dirty, when cost info is removed
if isinstance(obj, model.ProductCost):
self.record_rattail_change(session, obj.product, type_='dirty')
self.record_rattail_change(session, obj, type_='deleted')
[docs]
def is_deletable_orphan(self, instance):
"""
Determine if an object is an orphan and pending deletion.
"""
mapper = object_mapper(instance)
for property_ in mapper.iterate_properties:
if isinstance(property_, RelationshipProperty):
relationship = property_
# Does this relationship refer back to the instance class?
backref = relationship.backref or relationship.back_populates
if backref:
# Does the other class mapper's relationship wish to delete orphans?
# other_relationship = relationship.mapper.relationships[backref]
# Sometimes backrefs are tuples; first element is name.
if isinstance(backref, tuple):
backref = backref[0]
other_relationship = relationship.mapper.get_property(backref)
if other_relationship.cascade.delete_orphan:
# Is this instance an orphan?
if getattr(instance, relationship.key) is None:
return True
return False
[docs]
def record_rattail_change(self, session, instance, type_='dirty'):
"""
Record a change record in the database.
If ``instance`` represents a change in which we are interested, then
this method will create (or update) a :class:`rattail.db.model.Change`
record.
:returns: ``True`` if a change was recorded, or ``False`` if it was
ignored.
"""
model = self.app.model
# TODO: this check is now redundant due to `ignore_object()`
# No need to record changes for changes.
if isinstance(instance, (model.Change, model.DataSyncChange)):
return False
# No need to record changes for batch data.
if isinstance(instance, (model.BatchMixin, model.BatchRowMixin)):
return False
# no need to record changes for email attempts
if isinstance(instance, model.EmailAttempt):
return False
# Ignore instances which don't use UUID.
if not hasattr(instance, 'uuid'):
return False
# Provide an UUID value, if necessary.
self.ensure_uuid(instance)
# Record the change.
self.record_change(session, quiet=True,
class_name=instance.__class__.__name__,
instance_uuid=instance.uuid,
deleted=type_ == 'deleted')
log.debug("recorded change for %s %s %s: %s",
type_, instance.__class__.__name__, instance.uuid, instance)
return True
[docs]
def record_change(self, session, quiet=False, **kwargs):
"""
Record a change, by creating a new change record in the session.
"""
model = self.app.model
session.add(model.Change(**kwargs))
if not quiet:
log.debug("recorded {} for {} with key: {}".format(
'deletion' if kwargs.get('deleted') else 'change',
kwargs.get('class_name'),
kwargs.get('object_key', kwargs.get('instance_uuid'))))
[docs]
def ensure_uuid(self, instance):
"""
Ensure the given instance has a UUID value.
This uses the following logic:
* If the instance already has a UUID, nothing will be done.
* If the instance contains a foreign key to another table, then that
relationship will be traversed and the foreign object's UUID will be used
to populate that of the instance.
* Otherwise, a new UUID will be generated for the instance.
"""
if instance.uuid:
return
mapper = object_mapper(instance)
if not mapper.columns['uuid'].foreign_keys:
instance.uuid = self.app.make_uuid()
return
for prop in mapper.iterate_properties:
if (isinstance(prop, RelationshipProperty)
and len(prop.remote_side) == 1
and list(prop.remote_side)[0].key == 'uuid'):
foreign_instance = getattr(instance, prop.key)
if foreign_instance:
self.ensure_uuid(foreign_instance)
instance.uuid = foreign_instance.uuid
return
instance.uuid = self.app.make_uuid()
log.error("unexpected scenario; generated new UUID for instance: {0}".format(repr(instance)))
if parse_version(sa.__version__) < parse_version('1.4'): # pre-1.4
from sqlalchemy.orm.interfaces import SessionExtension
class ChangeRecorderExtension(SessionExtension):
"""
Session extension for recording changes.
.. note::
This is only used when the installed SQLAlchemy version is old enough
not to support the new event interfaces.
"""
def __init__(self, recorder):
self.recorder = recorder
def before_flush(self, session, flush_context, instances):
self.recorder(session, flush_context, instances)