Логирование изменения данных в моделях Django
В этой статье я бы хотел рассказать как можно реализовать автоматический контроль над изменениями данных в проектах построенных с использованием 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 проектах. Надеюсь она вам пригодиться при реализации подобной задачи.
Спасибо, очень полезная информация
Спасибо за полезную статью, как раз изучаю модели Django.
Работает!!!
У меня в модели есть поле m2m, которое при изменении не пишется в JsonField. Как это можно исправить?
За статью большое спасибо!
на что может ругаться Error binding parameter probably unsupported type?
Спасибо, все просто понятно и заработало!
Дополнение к статье:
Если вы используете Django 3.1+ то вам нужно удалить строку
и вместо него использовать стандартный models.JSONField
В этом случае работает на любой поддерживаемой БД. Я запустил даже на sqlite
Спасибо за хороший инструмент. Заметил потенциальное место для проблем с производительностью.
Модели, наследованные от ChangeloggableMixin при каждом __init__ сохраняют в self._original_values значения ВСЕХ полей модели. Поэтому для всех models.ForeignKey делается отдельный запрос в БД для получения связанного объекта даже если он нам не нужен.
В итоге в моем случае вот такой простейший пример:
приводил к 11ти запросам в БД, хотя должен был выполняться единственным запросом.
Для устранения проблемы, я модифицировал ChangeloggableMixin чтобы он использовал id связанного объекта, вместо самого объекта ради которого выполняются лишние запросы в БД.
Вот тут неточность. Вернее вот так:
Зачастую эту задачу решают на уровне самой СУБД с помощью триггеров на таблицу + дополнительную таблицу в которую пишутся сведения об изменениях.
Большое пасибо
Это будет работать, но столько узких мест, не рекомендовал бы использовать в таком виде.