Saving Model History
Model History
The Django admin includes a simple log of changes that have occurred to records. It does not log changes to fields, which is what we'll include here.
So history incorporates things like:
- Who added or updated a record.
- When the record was added and last updated.
- What fields changed during the last update.
The first two are relatively easy, but the last is non-trivial.
The Ground Work
Basic Fields
added_by
, added_dt
, modified_by
, and modified_dt
are simple enough to add to any model.
When implementing custom views, you'll need to handle this data manually either in form_valid()
or within the save()
method of a custom form.
SuperDjango DB provides the AddedByModel
and ModifiedByModel
as a shortcut to consistent implementation and SuperDjango UI automatically calls the audit()
method when a record is saved.
Capturing History
Saving a more robust history provides additional challenges. We'll need a model to capture changes. SuperDjango provides abstract HistoryModel
, but you can certainly create your own:
class RecordHistory(models.Model):
absolute_url = models.CharField(
_("URL"),
blank=True,
help_text=_("The URL of the model instance."),
max_length=1024,
null=True
)
added_dt = models.DateTimeField(
_("added date/time"),
auto_now_add=True,
help_text=_("Date and time the action was taken.")
)
content_type = models.ForeignKey(
ContentType,
help_text=_("The internal content type of the object."),
on_delete=models.CASCADE,
related_name="%(app_label)s_%(class)s_record_history",
verbose_name=_('content type')
)
object_id = models.CharField(
_('object id'),
help_text=_("The object (record) ID."),
max_length=256
)
object_label = models.CharField(
_("object label"),
blank=True,
help_text=_("The string representation of the object, i.e. it's title, label, etc."),
max_length=128
)
user = models.ForeignKey(
AUTH_USER_MODEL,
blank=True,
help_text=_("The user that performed the action."),
null=True,
on_delete=models.SET_NULL,
related_name="%(app_label)s_%(class)s_record_history",
verbose_name=_("user")
)
user_name = models.CharField(
_("user name"),
blank=True,
help_text=_("The name of the user that performed the action."),
max_length=128
)
# create, delete, detail (view), update, or other custom verbs. No choices are defined to allow custom verbs.
CREATE = "create"
DELETE = "delete"
DETAIL = "detail"
UPDATE = "update"
verb = models.CharField(
_("verb"),
help_text=_("The verb (action) taken on a record."),
max_length=128
)
verb_display = models.CharField(
_("verb display"),
blank=True,
help_text=_("The display value of the verb."),
max_length=128,
null=True
)
verbose_name = models.CharField(
_("verbose name"),
blank=True,
help_text=_("The verbose name of the model."),
max_length=256
)
@classmethod
def log(cls, record, user, verb, fields=None, url=None, verb_display=None):
"""Create a new history entry.
:param record: The model instance.
:param user: The user (instance) performing the action.
:param verb: The action taken.
:type verb: str
:param fields: A list of changed fields.
:type fields: list[superdjango.db.history.utils.FieldChange]
:param url: The URL of the model instance. Typically that of the detail view. If omitted, an attempt will be
made to acquire the URL from ``get_absolute_url()``.
:type url: str
:param verb_display: The human0friendly name of the action taken.
:type verb_display: str
:returns: The log entry instance.
.. note::
By default, nothing is done with ``fields``. When you extend the class, you may save the fields to the
extending model, or iterate over them to save each change to a separate model that refers back to the new
history instance. See ``log_field_changes()``.
"""
object_label = force_str(str(record))
verbose_name = record._meta.verbose_name
if url is None and verb not in DELETE_VERBS:
try:
url = record.get_absolute_url()
except AttributeError:
pass
kwargs = {
'absolute_url': url,
'content_type': ContentType.objects.get_for_model(record),
'object_id': record.pk,
'object_label': object_label,
'user': user,
'user_name': get_user_name(user),
'verb': verb,
'verb_display': verb_display,
'verbose_name': verbose_name,
}
# noinspection PyUnresolvedReferences
history = cls(**kwargs)
history.save()
return history
There is obviously more that could be done here, especially in terms of helper methods, but you get the idea.
Each time a record is saved, you'll need to write some code in the view or form that calls the log()
method.
What about field changes?
History, Field Changes and SuperDjango UI
Here I will to take a look at how we implement history support in with SuperDjango and specifically the UI and contrib.history
.
First, SuperDjango's contrib.history
app creates an implementation of HistoryModel
:
class History(HistoryModel):
"""An entry in the record history timeline."""
field_changes = models.TextField(
_("field changes"),
blank=True,
help_text=_("Fields that changed as part of the history."),
null=True
)
class Meta:
get_latest_by = "added_dt"
ordering = ["-added_dt"]
verbose_name = _("History Entry")
verbose_name_plural = _("History Entries")
@classmethod
def log_field_changes(cls, instance, verb, fields=None):
"""Log changes to fields to the ``field_changes`` field of the history record."""
if fields is None:
return
a = list()
for change in fields:
# Attempt to make text fields or long character fields look good.
if len(str(change.new_value)) > 128 or len(str(change.old_value)) > 128:
b = list()
diff = difflib.Differ()
for i in diff.compare(str(change.old_value).splitlines(), str(change.new_value).splitlines()):
b.append(i.strip())
a.append(" ".join(b))
else:
a.append(str(change))
instance.field_changes = "\n\n".join(a)
instance.save(update_fields=["field_changes"])
The log_field_changes()
method is automatically called by log()
on the base class. We could have created another model to capture field_changes
one at time, but this implementation simply records the changes as text, which is probably fine unless you are working on a high-security app with lots of audit requirements. In such cases, a more detailed account could be facilitated by creating a copy of contrib.history
and customizing as needed.
SuperDjango UI automatically incorporates history when the history_callback
is set on a Model UI. The parameters of this callable should be identical to the HistoryModel.log()
method.
The save_record()
delete_reord()
methods will call save_history()
, which in turn calls the history_callback
. Field changes are automatically acquired and provided in case the HistoryModel
implementation supports that functionality.
Conclusion
There are a lot of moving parts to deal with when incorporating record history and especially changes to fields. Study and use the code mentioned above to implement as is, or create your own implementation.