Comparable frames Keep track of changes to documents.

This snippet presents a set of classes for implementing a change log for your project. The mechanics of the change log are simple; each time a document in the database is changed we compare it to its pre-change state and record any differences along with the time of the change and the person responsible. In environments where multiple users update data a change log can prove essential in tracking down the cause of unexpected events in a timely manner.

Our change log implementation will require us to define two classes;

ChangeLogEntry used to store details of changes to documents and

ComparableFrame a Frame-like base class for collections we want to track changes for.

The user field stores a reference (ObjectId) to the user who made the change. The creation of a User class/collection is left up to you.

The ..._sticky_label fields store human identifiable references as opposed to their counterparts which store ObjectIds, this helps retain an audit trail when referenced documents are either updated (e.g the name of a category) or deleted.

The ComparableFrame class

Any documents we want to track changes for must inherit from ComparableFrame.

class ComparableFrame(Frame):
"""
A Frame-like base class that provides support for tracking changes to
documents.
Some important rules for creating comparable frames:
- Override the `__str__` method of the class to return a human friendly
identity as this method is called when generating a sticky label for the
class.
- Define which fields are references and which `Frame` class they reference
in the `_compared_refs` dictionary if you don't you'll only be able to see
that the ID has changed there will be nothing human identifiable.
"""
# A set of fields that should be exluded from comparisons/tracking
_uncompared_fields = {'_id'}
# A map of reference fields and the frames they reference
_compared_refs = {}
@property
def comparable(self):
"""Return a dictionary that can be compared"""
document_dict = self.compare_safe(self._document)
# Remove uncompared fields
self._remove_keys(document_dict, self._uncompared_fields)
# Remove any empty values
clean_document_dict = {}
for k, v in document_dict.items():
if not v and not isinstance(v, (int, float)):
continue
clean_document_dict[k] = v
# Convert any referenced fields to Frames
for ref_field, ref_cls in self._compared_refs.items():
ref = getattr(self, ref_field)
if not ref:
continue
# Check for fields which contain a list of references
if isinstance(ref, list):
if isinstance(ref[0], Frame):
continue
# Dereference the list of reference IDs
setattr(
clean_document_dict,
ref_field,
ref_cls.many(In(Q._id, ref))
)
else:
if isinstance(ref, Frame):
continue
# Dereference the reference ID
setattr(
clean_document_dict,
ref_field,
ref_cls.byId(ref)
)
return clean_document_dict
def logged_delete(self, user):
"""Delete the document and log the event in the change log"""
self.delete()
# Log the change
entry = ChangeLogEntry({
'type': 'DELETED',
'documents': [self],
'user': user
})
entry.insert()
return entry
def logged_insert(self, user):
"""Create and insert the document and log the event in the change log"""
# Insert the frame's document
self.insert()
# Log the insert
entry = ChangeLogEntry({
'type': 'ADDED',
'documents': [self],
'user': user
})
entry.insert()
return entry
def logged_update(self, user, data, *fields):
"""
Update the document with the dictionary of data provided and log the
event in the change log.
"""
# Get a copy of the frames comparable data before the update
original = self.comparable
# Update the frame
_fields = fields
if len(fields) == 0:
_fields = data.keys()
for field in _fields:
if field in data:
setattr(self, field, data[field])
self.update(*fields)
# Create an entry and perform a diff
entry = ChangeLogEntry({
'type': 'UPDATED',
'documents': [self],
'user': user
})
entry.add_diff(original, self.comparable)
# Check there's a change to apply/log
if not entry.is_diff:
return
entry.insert()
return entry
@classmethod
def compare_safe(cls, value):
"""Return a value that can be safely compared"""
# Date
if type(value) == date:
return str(value)
# Lists
elif isinstance(value, (list, tuple)):
return [cls.compare_safe(v) for v in value]
# Dictionaries
elif isinstance(value, dict):
return {k: cls.compare_safe(v) for k, v in value.items()}
return value

The ComparableFrame class provides:

A comparable property which must return a dictionary version of the document that can be compared.

The logged_insert, logged_update and logged_delete methods for performing insert, update and delete operations that are logged in the ChangeLogEntry collection.

An _uncompared_fields class attribute that defines a set of fields not to include in the output of the comparable property.

Reference fields are converted to Frame instances when generating a comparable dictionary for a document so that a human identifiable version of the reference can be stored. The referenced document is converted to a string and so it's important that associated Frame class overrides the __str__ method to output a human friendly reference.

Tracking changes

To demonstrate the change log in action we'll define a collections/classes to hold information about meetings and users:

The output doesn't look all that interesting in the console so here's one I made earlier, a screenshot taken from an existing project:

The code within this article is available in the MongoFrames repository within the snippets directory.

MongoFrames was created by Anthony Blackshaw - a founder of and developer at getme. The project is maintained by the crew at getme along with our contributors. The project's documentation (this site) is managed using ContentTools. The code is licensed under MIT.