Логирование изменения данных в моделях Django

Spread the love

В этой статье я бы хотел рассказать как можно реализовать автоматический контроль над изменениями данных в проектах построенных с использованием Django.

Спонсор поста АРЕНДА ВЫДЕЛЕННОГО ВИРТУАЛЬНОГО СЕРВЕРА VDS (VPS)

Когда обычного хостинга становится недостаточно, а выделенный сервер все еще не по карману, VDS станет той самой панацеей, которую вы ищите. Возьмите в аренду виртуальный сервер и ощутите стабильность работы ваших онлайн проектов при сравнительно небольших вложениях. https://greendc.ru/services/vds/

Задача состоит в том что бы создать такую систему которая позволяло бы автоматически получать информацию о том какие данные изменились, а так же кто и когда их изменил. Допустим, если в таблице которую мы хотим контролировать, изменилось какое нибудь поле (на скриншете были изменены данные в таблицах TicketComment и User), нужно что бы создалась запись об этом в соответствующей таблице (в нашем случае она будет называть ChangeLog). В этой запись должна быть информация о том что поменялось и о том кто поменял.

Общая идея реализации заключается в создание сигналов Django которые можно подключать к выбранным таблицам (которые мы хотим контролировать) и они будут записывать в соответствующую таблицу информацию об изменяемых данных и дополнительные необходимые данные.

Более подробно план состоит в следующем:

  • создадим соответствующую модель данных
  • сразу опишем класс admin для удобства просмотра и контроля над данными
  • далее создадим класс mixin который будет подключаться к логируемым таблицам и через который мы будем получать изменяемые данные
  • так как нам нужном будет получит ip адрес и имя зарегистрированного пользователя который отправил запрос на изменение создадим отдельный middleware, через который мы сможем получить эти значения.

Для этих целей у нас будет отдельное приложение, которые будет называться changelog.

И так создадим модель для логируемых данных. В ней мы создадим соотвествующие поля и отдельный классовый метод add через который мы будет создать новые записи в этой таблица

changelog/models.py

from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from django.contrib.postgres.fields import JSONField

ACTION_CREATE = 'create'
ACTION_UPDATE = 'update'
ACTION_DELETE = 'delete'

class ChangeLog(models.Model):
	TYPE_ACTION_ON_MODEL = (
        (ACTION_CREATE, _('Создание')),
        (ACTION_UPDATE, _('Изменение')),
        (ACTION_DELETE, _('Удаление')),
    )

	changed = models.DateTimeField(auto_now=True, verbose_name=u'Дата/время изменения')
	model = models.CharField(max_length=255, verbose_name=u'Таблица', null=True)
	record_id = models.IntegerField(verbose_name=u'ID записи', null=True)
	user = models.ForeignKey(
		settings.AUTH_USER_MODEL, verbose_name=u'Автор изменения',
		on_delete=models.CASCADE, null=True)
	action_on_model = models.CharField(
		choices=TYPE_ACTION_ON_MODEL, max_length=50, verbose_name=u'Действие', null=True)
	data = JSONField(verbose_name=u'Изменяемые данные модели', default=dict)
	ipaddress = models.CharField(max_length=15, verbose_name=u'IP адресс', null=True)

	class Meta:
		ordering = ('changed', )
		verbose_name = _('Change log')
		verbose_name_plural = _('Change logs')
	
	def __str__(self):
		return f'{self.id}'

        @classmethod
	def add(cls, instance, user, ipaddress, action_on_model, data, id=None):
		"""Создание записи в журнале регистрации изменений"""
		log = ChangeLog.objects.get(id=id) if id else ChangeLog()
		log.model = instance.__class__.__name__
		log.record_id = instance.pk
		if user:
			log.user = user
		log.ipaddress = ipaddress
		log.action_on_model = action_on_model
		log.data = data
		log.save()
		return log.pk

Опишем используемые поля:

  • changed — дата и время создание новой записи
  • model — таблица в которой изменились данные
  • record_id — id измененой записи
  • user — пользователь который поменял данные
  • action_on_model — действие которое было совершено (Создание/Изменение/Удаление)
  • data — измененные данные
  • ipaddress — ip адрес с которого пришел запрос

Далее создадим класс ChangeLogAdmin для джанговской админки.

changelog/admin.py

from django.contrib import admin

from .models import ChangeLog

@admin.register(ChangeLog)
class ChangeLogAdmin(admin.ModelAdmin):
	list_display = ('changed', 'model', 'user', 'record_id', 'data',
		'ipaddress', 'action_on_model',)
	readonly_fields = ('user', )
	list_filter = ('model', 'action_on_model',)

Далее начинается самое интересное. При изменение данных которые мы будет отлавливать через соотвествующие сигналы на нужно определить какие именно данные были изменены. Этот механизм мы реализуем внутри миксина ChangeloggableMixin, который мы будем подключать к контролируемой таблице. Сам механизм реализуется достаточно просто. У нас есть поле _original_values в котором будет находится оригинальное значение и затем при изменение данных нам нужно определить что именно изменилось. В нашем примере это реализуется в методе get_changed_fields. Он возвращает словарь в котором будет записано имя измененного поля (в качестве ключа) и его новое значение. При необходимости легко добавить старое значение поля.

changelog/mixins.py

from django.db import models


class ChangeloggableMixin(models.Model):
	"""Значения полей сразу после инициализации объекта"""
	_original_values = None

	class Meta:
		abstract = True

	def __init__(self, *args, **kwargs):
		super(ChangeloggableMixin, self).__init__(*args, **kwargs)
		
		self._original_values = {
			field.name: getattr(self, field.name)
			for field in self._meta.fields if field.name not in ['added', 'changed'] and hasattr(self, field.name)
		}
		
	def get_changed_fields(self):
		"""
		Получаем измененные данные
		"""
		result = {}
		for name, value in self._original_values.items():
			if value != getattr(self, name):
				temp = {}
				temp[name] = getattr(self, name)
				result.update(temp)
		return result
		

Далее создадим middleware для получения имени пользователя и его ip адрес.

changelog/middleware.py

class Singleton(object):
	"""Синглтон"""

	def __new__(cls):
		if not hasattr(cls, 'instance'):
			cls.instance = super(Singleton, cls).__new__(cls)
		return cls.instance

class LoggedInUser(Singleton):
	"""Синглтон для хранения пользователя,
	от имени которого выполняется запрос"""
	__metaclass__ = Singleton

	request = None
	user = None
	address = None

	def set_data(self, request):
		self.request = id(request)
		if request.user.is_authenticated:
			self.user = request.user
			self.address = request.META.get('REMOTE_ADDR')

	@property
	def current_user(self):
		return self.user

	@property
	def have_user(self):
		return not self.user is None

class LoggedInUserMiddleware(object):
	def __init__(self, get_response):
		self.get_response = get_response

	def __call__(self, request):
		"""
		Инициализирует синглтон LoggedInUser
		"""
		logged_in_user = LoggedInUser()
		logged_in_user.set_data(request)

		response = self.get_response(request)
		
		return response
	
	

Наш класс LoggedInUser через который мы будем получать нужные значение мы реализуем через шаблон Singleton, так как мы будет его вызывать, через middleware LoggedInUserMiddleware во время запроса а затем его же в соответствующем сигнале. В первом вызове мы получаем соответсвующие данные через request, а во втором вызове мы используем их.

Далее нужно записать LoggedInUserMiddleware в файл настроек settings.py

MIDDLEWARE = [
        'django.middleware.security.SecurityMiddleware',
        ...
        'django.contrib.auth.middleware.AuthenticationMiddleware',
        'django.contrib.messages.middleware.MessageMiddleware',
        'changelog.middleware.LoggedInUserMiddleware',
    ]

Далее создадим функции которые будут подключаться через сигналы к контролируемым таблица. В нашем случае это будет journal_save_handler для контроля на созданием новой записи и редактирования существующей и journal_delete_handler для контроля над удаляемыми данными.

changelog/signals.py

import time
import json
import datetime

from changelog.middleware import LoggedInUser
from changelog.models import ChangeLog, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from changelog.mixins import ChangeloggableMixin


def journal_save_handler(sender, instance, created, **kwargs):
	if isinstance(instance, ChangeloggableMixin):
		loggedIn = LoggedInUser()
		last_saved = get_last_saved(loggedIn.request, instance)
		changed = merge(last_saved['changed'], instance.get_changed_fields())
		if changed:
			changed = json.loads(json_dumps(changed))
			if created:
				ChangeLog.add(instance, loggedIn.current_user, loggedIn.address, ACTION_CREATE, changed, id=last_saved['id'])
			else:
				ChangeLog.add(instance, loggedIn.current_user, loggedIn.address, ACTION_UPDATE, changed, id=last_saved['id'])

def journal_delete_handler(sender, instance, using, **kwargs):
	if isinstance(instance, ChangeloggableMixin):
		loggedIn = LoggedInUser()
		last_saved = get_last_saved(loggedIn.request, instance)
		ChangeLog.add(instance, loggedIn.current_user, loggedIn.address, ACTION_DELETE, {}, id=last_saved['id'])


def json_dumps(value):
	return json.dumps(value, default=json_handler)

def json_handler(x):
	if isinstance(x, datetime.datetime):
		return x.isoformat()
	return repr(x)

_last_saved = {}

def get_last_saved(request, instance):
	last_saved = _last_saved[request] if request in _last_saved else None
	if not last_saved or last_saved['instance'].__class__ != instance.__class__ or last_saved['instance'].id != instance.id:
		last_saved = {
		  'instance' : instance, 
		  'changed' : {}, 
		  'id' : None, 
		  'timestamp' : time.time()
		}
		_last_saved[request] = last_saved
	return last_saved

def merge(o1, o2):
	for key in o2:
		val2 = o2[key]
		if isinstance(val2, dict) and key in o1:
			val1 = o1[key]
			for k in val2:
				val1[k] = val2[k]
		else:
			o1[key] = val2
	return o1

Теперь вся системы логирования готова. Осталось подключить ее к какой нибудь таблице следующим образом:

from django.db.models.signals import post_delete, post_save
...
from changelog.mixins import ChangeloggableMixin
from changelog.signals import journal_save_handler, journal_delete_handler

...
class TicketComment(ChangeloggableMixin, models.Model):
    ....

post_save.connect(journal_save_handler, sender=TicketComment)
post_delete.connect(journal_delete_handler, sender=TicketComment)

Заключение

В данный статье я описал простую но эффективную систему контроля над изменяемыми данными в Django проектах. Надеюсь она вам пригодиться при реализации подобной задачи.

Была ли вам полезна эта статья?
[16 / 4.4]

Spread the love
Подписаться
Уведомление о
guest
11 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
irmaseo.ru
4 лет назад

Спасибо, очень полезная информация

Катя
4 лет назад

Спасибо за полезную статью, как раз изучаю модели Django.

Сергей
Сергей
4 лет назад

Работает!!!

Вадим
Вадим
4 лет назад

У меня в модели есть поле m2m, которое при изменении не пишется в JsonField. Как это можно исправить?
За статью большое спасибо!

Николай
Николай
3 лет назад

на что может ругаться Error binding parameter probably unsupported type?

soqoor
soqoor
3 лет назад

Спасибо, все просто понятно и заработало!

Дополнение к статье:
Если вы используете Django 3.1+ то вам нужно удалить строку

from django.contrib.postgres.fields import JSONField

и вместо него использовать стандартный models.JSONField
В этом случае работает на любой поддерживаемой БД. Я запустил даже на sqlite

Александр
Александр
2 лет назад

Спасибо за хороший инструмент. Заметил потенциальное место для проблем с производительностью.

Модели, наследованные от ChangeloggableMixin при каждом __init__ сохраняют в self._original_values значения ВСЕХ полей модели. Поэтому для всех models.ForeignKey делается отдельный запрос в БД для получения связанного объекта даже если он нам не нужен.

В итоге в моем случае вот такой простейший пример:

book = Books.object.get(id=1)
print(book.id)

приводил к 11ти запросам в БД, хотя должен был выполняться единственным запросом.

Для устранения проблемы, я модифицировал ChangeloggableMixin чтобы он использовал id связанного объекта, вместо самого объекта ради которого выполняются лишние запросы в БД.

class ChangeloggableMixin(models.Model):
    """Значения полей сразу после инициализации объекта"""
    _original_values = None

    class Meta:
        abstract = True

    def __init__(self, *args, **kwargs):
        super(ChangeloggableMixin, self).__init__(*args, **kwargs)

        self._original_values = {}
        for field in self._meta.fields:

            if type(field) == fields.related.ForeignKey:
                self._original_values[field.name] = (
                    getattr(self, f'{field.name}_id')
                )

            else:
                self._original_values[field.name] = getattr(self, field.name)


    def get_changed_fields(self):
        """
        Получаем измененные данные
        """
        result = {}

        for name, value in self._original_values.items():

            if value != getattr(self, name):

                temp = {}
                temp[name] = getattr(self, name)

                # Дополнительная проверка для полей Foreign Key
                if self._meta.get_field(name).get_internal_type() == (
                    'ForeignKey'
                ):
                    if value != getattr(self, f'{name}_id'):
                        result.update(temp)

                # Для остальных полей просто выдаем результат
                else:
                    result.update(temp)

        return result
Станислав
Станислав
11 месяцев назад

Вот тут неточность. Вернее вот так:

if type(field) == models.fields.related.ForeignKey:
Дмитрий
Дмитрий
2 лет назад

Зачастую эту задачу решают на уровне самой СУБД с помощью триггеров на таблицу + дополнительную таблицу в которую пишутся сведения об изменениях.

Lolja4you
Lolja4you
1 год назад

Большое пасибо

Анонимно
Анонимно
1 год назад

Это будет работать, но столько узких мест, не рекомендовал бы использовать в таком виде.