Python

Логирование изменения данных в моделях 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
OlegA

View Comments

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

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

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

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

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

    from django.contrib.postgres.fields import JSONField
    

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

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

    Модели, наследованные от 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
    
    • Вот тут неточность. Вернее вот так:

      if type(field) == models.fields.related.ForeignKey:
      
  • Зачастую эту задачу решают на уровне самой СУБД с помощью триггеров на таблицу + дополнительную таблицу в которую пишутся сведения об изменениях.

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

Recent Posts

Vue 3.4 Новая механика v-model компонента

Краткий перевод: https://vuejs.org/guide/components/v-model.html Основное использование​ v-model используется для реализации двусторонней привязки в компоненте. Начиная с Vue…

12 месяцев ago

Анонс Vue 3.4

Сегодня мы рады объявить о выпуске Vue 3.4 «🏀 Slam Dunk»! Этот выпуск включает в…

12 месяцев ago

Как принудительно пере-отобразить (re-render) компонент Vue

Vue.js — это универсальный и адаптируемый фреймворк. Благодаря своей отличительной архитектуре и системе реактивности Vue…

2 года ago

Проблемы с установкой сертификата на nginix

Недавно, у меня истек сертификат и пришлось заказывать новый и затем устанавливать на хостинг с…

2 года ago

Введение в JavaScript Temporal API

Каким бы ни было ваше мнение о JavaScript, но всем известно, что работа с датами…

2 года ago

Когда и как выбирать между медиа запросами и контейнерными запросами

Все, кто следит за последними событиями в мире адаптивного дизайна, согласятся, что введение контейнерных запросов…

2 года ago