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.



Posted in Database by Shawn Davis, September 15, 2020