# Imports
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.utils.encoding import force_str
from django.utils.translation import gettext_lazy as _
from superdjango.conf import SUPERDJANGO
from superdjango.shortcuts import get_user_name
from .constants import CREATE_VERBS, DELETE_VERBS, DETAIL_VERBS, UPDATE_VERBS
# Exports
__all__ = (
"HistoryModel",
)
# Constants
AUTH_USER_MODEL = settings.AUTH_USER_MODEL
# Models
[docs]class HistoryModel(models.Model):
"""An abstract model for implementing a running record history.
.. code-block:: python
from superdjango.db.history.models import HistoryModel
class LogEntry(HistoryModel):
class Meta:
get_latest_by = "added_dt"
ordering = ["-added_dt"]
verbose_name = _("Log Entry")
verbose_name_plural = _("Log Entries")
"""
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
)
class Meta:
abstract = True
def __str__(self):
return self.get_message()
@property
def action(self):
"""An alias for ``get_verb_display()``."""
return self.get_verb_display()
[docs] @classmethod
def get_for(cls, record, limit=10):
"""Get history entries for a given record.
:param record: The record (model instance) for which history is to be acquired.
:param limit: The max number of history records to return.
:type limit: int
:rtype: django.db.models.QuerySet
"""
content_type = ContentType.objects.get_for_model(record)
qs = cls.objects.filter(content_type=content_type, object_id=record.pk)
if limit:
return qs[:limit]
return qs
[docs] def get_message(self):
"""Get the message that describes the action.
:rtype: str
.. note::
The added date/time of the entry is *not* localized for the current user's timezone. To support this, you'll
need to implement the message in a template.
"""
template = '%(user_name)s %(verb)s "%(object_label)s" %(verbose_name)s at %(added_dt)s.'
# noinspection PyUnresolvedReferences
added_dt = self.added_dt.strftime(SUPERDJANGO.DATETIME_MASK)
# noinspection PyUnresolvedReferences
verbose_name = self.verbose_name.lower()
context = {
'added_dt': added_dt,
'object_label': self.object_label,
'user_name': self.performed_by,
'verb': self.get_verb_display(),
'verbose_name': verbose_name,
}
return _(template % context)
[docs] def get_object(self):
"""Get the model instance that was the subject of the entry.
:returns: The model instance. If the object was deleted, ``None`` is returned.
"""
try:
# noinspection PyUnresolvedReferences
return self.content_type.get_object_for_this_type(pk=self.object_id)
except ObjectDoesNotExist:
return None
[docs] def get_verb_display(self):
"""Get the human-friendly verb.
:rtype: str
"""
if self.verb_display:
return self.verb_display
elif self.is_create:
return "added"
elif self.is_delete:
return "removed"
elif self.is_detail:
return "viewed"
elif self.is_update:
return "updated"
else:
return self.verb
[docs] def get_url(self):
"""Get the URL of the model instance.
The URL may not be available. Additionally, if the action represents a delete, no URL is returned.
:rtype: str | None
"""
if self.is_delete:
return None
return self.absolute_url
@property
def is_create(self):
"""Indicates the action is an addition.
:rtype: bool
"""
return self.verb in CREATE_VERBS
@property
def is_delete(self):
"""Indicates the action is a delete.
:rtype: bool
"""
return self.verb in DELETE_VERBS
@property
def is_detail(self):
"""Indicates the action is a detail.
:rtype: bool
"""
return self.verb in DETAIL_VERBS
@property
def is_update(self):
"""Indicates the action was an update.
:rtype: bool
"""
return self.verb in UPDATE_VERBS
[docs] @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()``.
"""
if hasattr(record, "get_display_name") and callable(record.get_display_name):
object_label = record.get_display_name()
else:
object_label = force_str(str(record))
# noinspection PyProtectedMember
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()
cls.log_field_changes(history, verb, fields=fields)
return history
[docs] @classmethod
def log_field_changes(cls, instance, verb, fields=None):
"""Log changes to fields.
:param instance: The history record, NOT the original model instance.
:param verb: The action taken. This allows verbs such as create or delete to be ignored.
:type verb: str
:param fields: A list of changed fields.
:type fields: list[superdjango.db.history.utils.FieldChange]
"""
pass
@property
def message(self):
"""An alias for ``get_message()``."""
return self.get_message()
@property
def performed_by(self):
"""Get the name of the user that performed the action.
:rtype: str
"""
# noinspection PyUnresolvedReferences
if self.user_id:
return get_user_name(self.user)
return self.user_name