├── testapp ├── testapp │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── wsgi.py │ ├── urls.py │ └── settings.py └── manage.py ├── models_logging ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── delete_changes.py ├── migrations │ ├── __init__.py │ ├── 0009_alter_change_index_together.py │ ├── 0006_auto_20211020_2036.py │ ├── 0007_migrate_old_fields.py │ ├── 0004_auto_20171124_1445.py │ ├── 0008_change_extras.py │ ├── 0002_auto_20161012_2025.py │ ├── 0005_auto_20200804_1305.py │ ├── 0003_auto_20170726_1552.py │ └── 0001_initial.py ├── apps.py ├── templates │ └── models_logging │ │ ├── change_form.html │ │ ├── revert_changes_confirmation.html │ │ ├── revert_revision_confirmation.html │ │ └── object_history.html ├── middleware.py ├── setup.py ├── settings.py ├── __init__.py ├── signals.py ├── utils.py ├── helpers.py ├── models.py └── admin.py ├── requirements.txt ├── LICENSE ├── .gitignore ├── setup.py └── README.md /testapp/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /models_logging/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /models_logging/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testapp/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /models_logging/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django>=3.1,<5 2 | python-dateutil 3 | -------------------------------------------------------------------------------- /testapp/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | -------------------------------------------------------------------------------- /models_logging/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class LoggingConfig(AppConfig): 6 | name = 'models_logging' 7 | verbose_name = _('Models logging') 8 | default_auto_field = "django.db.models.AutoField" 9 | 10 | def ready(self): 11 | from .setup import models_register 12 | self.registered_models = models_register() 13 | -------------------------------------------------------------------------------- /models_logging/templates/models_logging/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/change_form.html' %} 2 | {% load i18n admin_urls admin_modify %} 3 | 4 | {% block submit_buttons_bottom %} 5 | {% submit_row %} 6 |

7 | 8 | {% trans "Revert" %} 9 | 10 |

11 | {% endblock %} -------------------------------------------------------------------------------- /testapp/testapp/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testapp project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testapp.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /models_logging/migrations/0009_alter_change_index_together.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.11 on 2024-01-31 12:07 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("contenttypes", "0002_remove_content_type_name"), 10 | ("models_logging", "0008_change_extras"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterIndexTogether( 15 | name="change", 16 | index_together={("content_type", "object_id")}, 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /models_logging/migrations/0006_auto_20211020_2036.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.24 on 2021-10-20 20:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('models_logging', '0005_auto_20200804_1305'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='change', 15 | name='object_id', 16 | field=models.TextField(help_text='Primary key of the model under version control.'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /models_logging/migrations/0007_migrate_old_fields.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | import models_logging.models 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('models_logging', '0006_auto_20211020_2036'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name='change', 13 | name='changed_data', 14 | field=models.JSONField( 15 | blank=True, 16 | encoder=models_logging.models.get_encoder, 17 | null=True 18 | ), 19 | ) 20 | ] 21 | -------------------------------------------------------------------------------- /models_logging/migrations/0004_auto_20171124_1445.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-11-24 11:45 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('models_logging', '0003_auto_20170726_1552'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='change', 17 | name='object_id', 18 | field=models.IntegerField(help_text='Primary key of the model under version control.'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /models_logging/migrations/0008_change_extras.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-11-23 15:04 2 | 3 | from django.db import migrations, models 4 | 5 | import models_logging.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("models_logging", "0007_migrate_old_fields"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="change", 17 | name="extras", 18 | field=models.JSONField( 19 | blank=True, 20 | default=dict, 21 | encoder=models_logging.models.get_encoder, 22 | null=True, 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /models_logging/migrations/0002_auto_20161012_2025.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.2 on 2016-10-12 17:25 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('models_logging', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='changes', 18 | name='revision', 19 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='models_logging.Revision', verbose_name='to revision'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /testapp/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testapp.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /testapp/testapp/urls.py: -------------------------------------------------------------------------------- 1 | """testapp URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /models_logging/migrations/0005_auto_20200804_1305.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2020-08-04 13:05 2 | from django.db import migrations, models 3 | import django.db.models.deletion 4 | import models_logging.models 5 | from models_logging.settings import LOGGING_USER_MODEL 6 | 7 | operations = [ 8 | migrations.AlterField( 9 | model_name='change', 10 | name='user', 11 | field=models.ForeignKey(blank=True, help_text='The user who created this changes.', null=True, on_delete=django.db.models.deletion.SET_NULL, to=LOGGING_USER_MODEL, verbose_name='User'), 12 | ), 13 | migrations.AlterField( 14 | model_name='revision', 15 | name='comment', 16 | field=models.TextField(blank=True, help_text='A text comment on this revision.', verbose_name='comment'), 17 | ), 18 | migrations.RemoveField( 19 | model_name='change', 20 | name='comment', 21 | ), 22 | ] 23 | 24 | class Migration(migrations.Migration): 25 | 26 | dependencies = [ 27 | ('models_logging', '0004_auto_20171124_1445'), 28 | ] 29 | 30 | operations = operations 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 legion-an 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | .idea 63 | /manage.py 64 | example 65 | 66 | venv/ 67 | /testapp/db.sqlite3 68 | /testapp/testapp/settings_local.py 69 | -------------------------------------------------------------------------------- /models_logging/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.module_loading import import_string 3 | 4 | try: 5 | from django.utils.deprecation import MiddlewareMixin 6 | except ImportError: 7 | MiddlewareMixin = object 8 | 9 | from . import _local 10 | from .settings import MERGE_CHANGES 11 | from .utils import create_revision_with_changes 12 | 13 | 14 | class LoggingStackMiddleware(MiddlewareMixin): 15 | 16 | def process_request(self, request): 17 | _local.stack_changes = {} 18 | _local.request = request 19 | _local.merge_changes_allowed = MERGE_CHANGES_ALLOWED 20 | 21 | def process_response(self, request, response): 22 | if MERGE_CHANGES and _local.stack_changes: 23 | self.create_revision(_local) 24 | return response 25 | 26 | def create_revision(self, _local): 27 | # this method for overriding and call create_revision_with_changes async maybe 28 | 29 | create_revision_with_changes(_local.stack_changes.values()) 30 | _local.stack_changes = {} 31 | 32 | 33 | MERGE_CHANGES_ALLOWED = False 34 | for middleware in settings.MIDDLEWARE: 35 | middleware_cls = import_string(middleware)(object) 36 | 37 | if isinstance(middleware_cls, LoggingStackMiddleware): 38 | MERGE_CHANGES_ALLOWED = True 39 | break 40 | -------------------------------------------------------------------------------- /models_logging/management/commands/delete_changes.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from models_logging.models import Change 6 | 7 | 8 | class Command(BaseCommand): 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument( 12 | '--ctype', type=str, help='ids by comma of content_types which will be deleted' 13 | ) 14 | parser.add_argument( 15 | '--ctype-exclude', type=str, help='ids by comma of content_types which will be excluded from deletion' 16 | ) 17 | parser.add_argument( 18 | '--date_lte', type=str, help='The changes started before that date will be removed, format (yyyy.mm.dd)', 19 | ) 20 | 21 | def handle(self, *args, **options): 22 | content_type = options['ctype'] 23 | date_lte = options['date_lte'] 24 | exclude = options['exclude'] 25 | 26 | changes = Change.objects.all() 27 | if content_type: 28 | changes = changes.filter(content_type__id__in=content_type.split(',')) 29 | if exclude: 30 | changes = changes.exclude(content_type__id__in=exclude.split(',')) 31 | if date_lte: 32 | changes = changes.filter(date_created__lte=datetime.strptime(date_lte, '%Y.%m.%d')) 33 | 34 | changes.delete() 35 | -------------------------------------------------------------------------------- /models_logging/setup.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_init, post_save, pre_delete, pre_save 2 | from django.apps.registry import apps 3 | 4 | from .settings import MODELS_FOR_LOGGING, MODELS_FOR_EXCLUDE 5 | from .signals import init_model_attrs, save_model, delete_model, update_model_attrs 6 | 7 | 8 | def models_register(): 9 | registered_models = [] 10 | if MODELS_FOR_LOGGING: 11 | for app in MODELS_FOR_LOGGING: 12 | item = app.split('.') 13 | if item[-1] in [app_config.label for app_config in apps.get_app_configs()]: 14 | # If item is an app, register all models 15 | for v in apps.get_app_config(item[-1]).models.values(): 16 | if '%s.%s' % (app, v.__name__) not in MODELS_FOR_EXCLUDE: 17 | registered_models.append(v) 18 | else: 19 | # If the item is a single model, register it 20 | registered_models.append(apps.get_registered_model(item[-2], item[-1])) 21 | 22 | for model in registered_models: 23 | post_init.connect(init_model_attrs, sender=model) 24 | pre_save.connect(update_model_attrs, sender=model) 25 | post_save.connect(save_model, sender=model) 26 | pre_delete.connect(delete_model, sender=model) 27 | 28 | return registered_models 29 | -------------------------------------------------------------------------------- /models_logging/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | LOGGING_USER_MODEL = getattr(settings, 'LOGGING_USER_MODEL', None) or getattr(settings, 'AUTH_USER_MODEL', None) 5 | 6 | MODELS_FOR_LOGGING = getattr(settings, 'LOGGING_MODELS', None) 7 | MODELS_FOR_EXCLUDE = getattr(settings, 'LOGGING_EXCLUDE', []) 8 | 9 | 10 | REVERT_IS_ALLOWED = getattr(settings, 'LOGGING_REVERT_IS_ALLOWED', True) 11 | CAN_DELETE_REVISION = getattr(settings, 'LOGGING_CAN_DELETE_REVISION', False) 12 | CAN_DELETE_CHANGES = getattr(settings, 'LOGGING_CAN_DELETE_CHANGES', False) 13 | CAN_CHANGE_CHANGES = getattr(settings, 'LOGGING_CAN_CHANGE_CHANGES', False) 14 | CHANGES_REVISION_LIMIT = getattr(settings, 'LOGGING_CHANGES_REVISION_LIMIT', 100) 15 | MERGE_CHANGES = getattr(settings, 'LOGGING_MERGE_CHANGES', True) 16 | 17 | ADDED = 'added' 18 | CHANGED = 'changed' 19 | DELETED = 'deleted' 20 | 21 | MIDDLEWARES = settings.MIDDLEWARE 22 | 23 | # TODO: Is not completed feature, do not use it! 24 | # It will prevent error in database if User is not in the same database (because of ForeignKey) 25 | LOGGING_DATABASE = getattr(settings, 'LOGGING_DATABASE', 'default') 26 | 27 | JSON_ENCODER_PATH = getattr(settings, 'LOGGING_JSON_ENCODER', 'models_logging.utils.ExtendedEncoder') 28 | 29 | GET_CHANGE_EXTRAS_PATH = getattr( 30 | settings, 31 | 'LOGGING_GET_CHANGE_EXTRAS_FUNC', 32 | 'models_logging.helpers.get_change_extras' 33 | ) 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | files = ["templates/models_logging/*", "migrations/*", "management/commands/*"] 4 | 5 | setup( 6 | name='django-models-logging', 7 | version='2.4', 8 | packages=['models_logging'], 9 | url='https://github.com/legion-an/django-models-logging', 10 | package_data={'models_logging' : files}, 11 | license='MIT', 12 | author='legion', 13 | author_email='legion.andrey.89@gmail.com', 14 | description='Add logging of models from save, delete signals', 15 | keywords=[ 16 | 'django logging', 17 | 'django history', 18 | 'django logging models', 19 | 'django history models', 20 | ], 21 | install_requires=[ 22 | "django>=3.1,<5", 23 | "python-dateutil", 24 | ], 25 | classifiers=[ 26 | 'Development Status :: 5 - Production/Stable', 27 | 'Environment :: Web Environment', 28 | 'Framework :: Django', 29 | 'Framework :: Django :: 1.8', 30 | 'Framework :: Django :: 1.9', 31 | 'Framework :: Django :: 1.10', 32 | 'Framework :: Django :: 1.11', 33 | 'Framework :: Django :: 2.0', 34 | 'Framework :: Django :: 3.0', 35 | 'Framework :: Django :: 4.0', 36 | 'Intended Audience :: Developers', 37 | 'License :: OSI Approved :: MIT License', 38 | 'Operating System :: OS Independent', 39 | 'Programming Language :: Python', 40 | 'Programming Language :: Python :: 3', 41 | 'Topic :: Internet :: WWW/HTTP', 42 | 'Topic :: Software Development :: Libraries :: Python Modules', 43 | ], 44 | long_description='https://github.com/legion-an/django-models-logging/blob/master/README.md' 45 | ) 46 | -------------------------------------------------------------------------------- /models_logging/templates/models_logging/revert_changes_confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base_site.html' %} 2 | {% load i18n admin_urls static %} 3 | 4 | {% block breadcrumbs %} 5 | 13 | {% endblock %} 14 | 15 | {% block content %} 16 |

{% blocktrans with escaped_object=object %}Are you sure you want to revert the {{ object_name }}?{% endblocktrans %}

17 |
18 | {% if object.action == 'Deleted' %} 19 | {% trans 'Object was deleted in this change, if you revert this change, object will be created again' %} 20 | {% elif object.action == 'Added' %} 21 | {% trans 'Object was added in this change, if you revert this change, object will be deleted' %} 22 | {% endif %} 23 | {% for data in changed_data %} 24 |

{{ data.field }} = {{ data.values.old }}

25 | {% endfor %} 26 |
27 | 28 | 29 |
{% csrf_token %} 30 |
31 | 32 | 33 | {% trans "No, take me back" %} 34 |
35 |
36 | {% endblock %} -------------------------------------------------------------------------------- /models_logging/__init__.py: -------------------------------------------------------------------------------- 1 | from threading import local 2 | from typing import Union, Dict, TYPE_CHECKING 3 | 4 | from django.core.handlers.wsgi import WSGIRequest 5 | 6 | if TYPE_CHECKING: 7 | from models_logging.models import Change 8 | 9 | default_app_config = 'models_logging.apps.LoggingConfig' 10 | 11 | 12 | class _Local(local): 13 | """ 14 | :param stack_changes: all changes grouped by (object_id, content_type_id) 15 | it's created for grouping changes that called by multiple using of obj.save() per 1 request|operation 16 | """ 17 | def __init__(self): 18 | self.request: "WSGIRequest" = None 19 | self.ignore_changes = False 20 | self.stack_changes: Dict[(Union[str, int], int), "Change"] = {} 21 | self.merge_changes_allowed = False 22 | 23 | def ignore(self, sender, instance) -> bool: 24 | if isinstance(self.ignore_changes, (tuple, list)) and sender in self.ignore_changes: 25 | return True 26 | elif self.ignore_changes is True: 27 | return True 28 | return False 29 | 30 | @property 31 | def user_id(self): 32 | if self.request: 33 | from models_logging.models import Change 34 | 35 | user = self.request.user 36 | return user.pk if user and isinstance(user, Change.user_field_model()) and user.is_authenticated else None 37 | 38 | def put_change_to_stack(self, change: "Change"): 39 | key = (change.object_id, change.content_type.pk) 40 | existing_change = self.stack_changes.get(key) 41 | if existing_change: 42 | # If the object changes several times during the request, we need to properly track the changed fields 43 | for k, v in change.changed_data.items(): 44 | existing_change.changed_data[k] = v 45 | else: 46 | self.stack_changes[key] = change 47 | 48 | 49 | _local = _Local() 50 | -------------------------------------------------------------------------------- /models_logging/signals.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | 3 | from . import _local 4 | from .helpers import model_to_dict, get_changed_data, init_change 5 | from .settings import ADDED, CHANGED, DELETED, MERGE_CHANGES, LOGGING_DATABASE 6 | 7 | 8 | def init_model_attrs(sender, instance, **kwargs): 9 | if not _local.ignore(sender, instance): 10 | model_dict = model_to_dict(instance) 11 | # for rest_framework 12 | if not instance.pk: 13 | model_dict = {k: None for k, v in model_dict.items()} 14 | 15 | instance.__attrs = model_dict 16 | 17 | 18 | def save_model(sender, instance, using, **kwargs): 19 | if not _local.ignore(sender, instance): 20 | diffs = get_changed_data(instance) 21 | if diffs: 22 | action = ADDED if kwargs.get('created') else CHANGED 23 | _create_changes(instance, action) 24 | 25 | 26 | def delete_model(sender, instance, using, **kwargs): 27 | if not _local.ignore(sender, instance): 28 | _create_changes(instance, DELETED) 29 | 30 | 31 | def _create_changes(object, action): 32 | changed_data = get_changed_data(object, action) 33 | 34 | change = init_change( 35 | object, 36 | changed_data, 37 | action, 38 | ContentType.objects.get_for_model(object._meta.model) 39 | ) 40 | 41 | if MERGE_CHANGES and _local.merge_changes_allowed: 42 | _local.put_change_to_stack(change) 43 | else: 44 | change.save(using=LOGGING_DATABASE) 45 | 46 | 47 | def update_model_attrs(signal, sender, instance, **kwargs): 48 | if not _local.ignore(sender, instance): 49 | # if there are deferred fields which are changed still, we need to get old values from the DB 50 | if instance.get_deferred_fields(): 51 | new_values = model_to_dict(instance) 52 | if missed_fields := (set(new_values).difference(instance.__attrs)): 53 | instance.refresh_from_db(fields=missed_fields) 54 | for k in missed_fields: 55 | # Update __attrs with fields from the DB (old values) 56 | instance.__attrs[k] = getattr(instance, k) 57 | # set new values again 58 | setattr(instance, k, new_values[k]) 59 | -------------------------------------------------------------------------------- /models_logging/templates/models_logging/revert_revision_confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base_site.html' %} 2 | {% load i18n admin_urls static %} 3 | 4 | {% block breadcrumbs %} 5 | 13 | {% endblock %} 14 | 15 | {% block content %} 16 |

{% blocktrans with escaped_object=object %}Are you sure you want to revert the {{ object_name }}?{% endblocktrans %}

17 |
18 | {% trans 'Revision contains many of changes, take a look very attentively at all changes' %}
19 | {% trans 'Atantion! Only' %} {{ limit }} {% trans 'from' %} {{ changes_count }} 20 | {% trans 'showed in this table. But reverting will be in all changes. All changes you can see in Changes admin' %} 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {% for change in changes %} 31 | 32 | 33 | 34 | 35 | 36 | 41 | 42 | {% endfor %} 43 |
{% trans 'Change' %}{% trans 'Action' %}{% trans 'ContentType' %}{% trans 'Object_id' %}{% trans 'Changes of object' %}
{{ change }}{{ change.get_action_display }}{{ change.content_type }}{{ change.object_id }} 37 | {% for data in change.changed_data %} 38 |

{{ data.field }} - ({{ data.values.old }} -> {{ data.values.new }})

39 | {% endfor %} 40 |
44 | 45 |
46 | 47 | 48 |
{% csrf_token %} 49 |
50 | 51 | 52 | {% trans "No, take me back" %} 53 |
54 |
55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /models_logging/templates/models_logging/object_history.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/object_history.html" %} 2 | {% load i18n %} 3 | 4 | 5 | {% block content %} 6 |
7 | 8 |

{% blocktrans %}Choose a date from the list below to revert to a previous version of this object.{% endblocktrans %}

9 | 10 |
11 | {% if changes %} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for change in changes %} 24 | 25 | 32 | 40 | 43 | 46 | 51 | 52 | {% endfor %} 53 | 54 |
{% trans 'Date/time' %}{% trans 'User' %}{% trans 'Object' %}{% trans 'Action' %}{% trans 'Changed data' %}
26 | {% if changes_admin %} 27 | {{ change.date_created|date:"DATETIME_FORMAT"}} 28 | {% else %} 29 | {{ change.date_created|date:"DATETIME_FORMAT"}} 30 | {% endif %} 31 | 33 | {% if change.user %} 34 | {{ change.user }} 35 | {% if change.user.get_full_name %} ({{ change.user.get_full_name }}){% endif %} 36 | {% else %} 37 | — 38 | {% endif %} 39 | 41 | {{ change.object_repr }} 42 | 44 | {{ change.action }} 45 | 47 | {% for field, values in change.changed_data.items %} 48 |

{{ field }}: {{ values.old }} -> {{ values.new }}

49 | {% endfor %} 50 |
55 | {% else %} 56 |

{% trans "This object doesn't have a change history. It probably wasn't added via this admin site." %}

57 | {% endif %} 58 |
59 |
60 | {% endblock %} 61 | -------------------------------------------------------------------------------- /models_logging/migrations/0003_auto_20170726_1552.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-07-26 12:52 3 | from __future__ import unicode_literals 4 | import json 5 | from dateutil import parser 6 | from decimal import Decimal 7 | 8 | from django.db import migrations, models 9 | from django.core.serializers.json import DjangoJSONEncoder 10 | 11 | from models_logging.settings import ADDED, CHANGED, DELETED 12 | 13 | 14 | old_actions = { 15 | 'Changed': CHANGED, 16 | 'Added': ADDED, 17 | 'Deleted': DELETED 18 | } 19 | 20 | 21 | def migrate_changed_data(apps, schema_editor): 22 | def _get_val(val): 23 | if 'None' in val: 24 | return None 25 | if val.isdigit(): 26 | return int(val) 27 | try: 28 | return Decimal(val) 29 | except Exception: 30 | pass 31 | try: 32 | return parser.parse(val) 33 | except Exception: 34 | pass 35 | 36 | return val 37 | 38 | Change = apps.get_model('models_logging', 'Change') 39 | chunk = Change.objects.count() // 100 40 | perc = 0 41 | for count, ch in enumerate(Change.objects.using(schema_editor.connection.alias).all()): 42 | if count % chunk == 0: 43 | print('{}%'.format(perc)) 44 | perc += 1 45 | comment = ch.comment.split(':\n') 46 | 47 | action = old_actions[ch.action] 48 | if action == DELETED: 49 | Change.objects.filter(id=ch.id).update(action=action) 50 | continue 51 | elif comment[0].startswith('Recover'): 52 | continue 53 | 54 | data = [] 55 | try: 56 | for i in comment[1].split(')\n"'): 57 | d = i.split('" (') 58 | field = d[0].replace('"', '') 59 | vals = d[1].split('->') 60 | old = vals[0].lstrip('(').strip(' ') 61 | new = vals[1].rstrip(')').strip(' ') 62 | data.append({'field': field, 'values': {'old': _get_val(old), 'new': _get_val(new)}}) 63 | except Exception: 64 | # some changes we must skip ;( 65 | pass 66 | changed_data = json.dumps(data, cls=DjangoJSONEncoder) 67 | Change.objects.filter(id=ch.id).update(action=action, changed_data=changed_data) 68 | 69 | 70 | class Migration(migrations.Migration): 71 | dependencies = [ 72 | ('models_logging', '0002_auto_20161012_2025'), 73 | ] 74 | 75 | operations = [ 76 | migrations.RenameModel( 77 | old_name='Changes', 78 | new_name='Change', 79 | ), 80 | migrations.AddField( 81 | model_name='change', 82 | name='changed_data', 83 | field=models.TextField(blank=True, help_text='The old data of changed fields.', null=True), 84 | ), 85 | migrations.AlterField( 86 | model_name='change', 87 | name='action', 88 | field=models.CharField(choices=[('added', 'Added'), ('changed', 'Changed'), ('deleted', 'Deleted')], 89 | help_text='added|changed|deleted', max_length=7, verbose_name='Action'), 90 | ), 91 | migrations.RemoveField( 92 | model_name='change', 93 | name='serialized_data', 94 | ), 95 | migrations.RunPython(migrate_changed_data, migrations.RunPython.noop), 96 | ] 97 | -------------------------------------------------------------------------------- /models_logging/utils.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | from django.contrib.contenttypes.models import ContentType 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.core.serializers.json import DjangoJSONEncoder 6 | from django.db.models.fields.files import FieldFile 7 | 8 | from models_logging import _local, settings 9 | from models_logging.helpers import create_revision_with_changes, init_change 10 | from models_logging.models import Change 11 | 12 | try: 13 | from django.contrib.gis.geos import Point 14 | 15 | GEOS_POINT = True 16 | except ImproperlyConfigured: 17 | GEOS_POINT = False 18 | 19 | 20 | class ExtendedEncoder(DjangoJSONEncoder): 21 | 22 | def default(self, o): 23 | if GEOS_POINT and isinstance(o, Point): 24 | return {'type': o.geom_type, 'coordinates': [*o.coords]} 25 | if isinstance(o, FieldFile): 26 | return getattr(o, 'name', None) 27 | return super(ExtendedEncoder, self).default(o) 28 | 29 | 30 | @contextmanager 31 | def ignore_changes(models=None): 32 | """ 33 | 34 | :param models: tuple or list of django models or bool if you want to ignore all changes 35 | :return: 36 | """ 37 | if models: 38 | assert isinstance(models, (tuple, list, bool)) 39 | _local.ignore_changes = models or True 40 | yield 41 | _local.ignore_changes = False 42 | 43 | 44 | @contextmanager 45 | def create_merged_changes(): 46 | """ 47 | for using merged changes in some script outside django (ex. celery) 48 | @task 49 | def some_task(): 50 | with create_merged_changes(): 51 | some logic 52 | 53 | first clean _local.stack_changes 54 | in your logic, changes appended to _local.stack_changes 55 | at the end all changes will be added in database 56 | :return: 57 | """ 58 | _local.stack_changes = {} 59 | _local.merge_changes_allowed = True 60 | 61 | yield 62 | 63 | create_revision_with_changes(_local.stack_changes.values()) 64 | 65 | _local.stack_changes = {} 66 | _local.merge_changes_allowed = False 67 | 68 | 69 | def create_changes_for_update(queryset, **fields): 70 | def _get_values(qs): 71 | return {item["pk"]: item for item in qs.values("pk", *fields)} 72 | 73 | old_values = _get_values(queryset) 74 | rows = queryset.update(**fields) 75 | new_values = _get_values(queryset.model.objects.filter(id__in=old_values.keys())) 76 | 77 | content_type = ContentType.objects.get_for_model(queryset.model) 78 | 79 | changes = [] 80 | for pk, item in old_values.items(): 81 | changed_data = { 82 | field: {"old": old_value, "new": new_values[pk][field]} 83 | for field, old_value in item.items() if field != 'pk' 84 | } 85 | changes.append( 86 | init_change( 87 | item, 88 | changed_data, 89 | settings.CHANGED, 90 | content_type, 91 | f"Update of {queryset.model.__name__} (pk={pk})", 92 | ) 93 | ) 94 | 95 | if settings.MERGE_CHANGES and _local.merge_changes_allowed: 96 | for change in changes: 97 | _local.put_change_to_stack(change) 98 | else: 99 | Change.objects.using(settings.LOGGING_DATABASE).bulk_create(changes) 100 | 101 | return rows 102 | -------------------------------------------------------------------------------- /models_logging/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.2 on 2016-10-12 11:01 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | from models_logging.settings import LOGGING_USER_MODEL 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | ('contenttypes', '0002_remove_content_type_name'), 17 | migrations.swappable_dependency(LOGGING_USER_MODEL), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='Changes', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('date_created', models.DateTimeField(auto_now_add=True, db_index=True, help_text='The date and time this changes was.', verbose_name='Date created')), 26 | ('comment', models.TextField(help_text='A text comment on this changes.', verbose_name='Comment')), 27 | ('object_id', models.CharField(help_text='Primary key of the model under version control.', max_length=191)), 28 | ('db', models.CharField(help_text='The database the model under version control is stored in.', max_length=191)), 29 | ('serialized_data', models.TextField(blank=True, help_text='The serialized form of this version of the model.', null=True)), 30 | ('object_repr', models.TextField(help_text='A string representation of the object.')), 31 | ('action', models.CharField(help_text='added|changed|deleted', max_length=7, verbose_name='Action')), 32 | ('content_type', models.ForeignKey(help_text='Content type of the model under version control.', on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), 33 | ], 34 | options={ 35 | 'verbose_name_plural': 'All changes', 36 | 'ordering': ('-pk',), 37 | 'verbose_name': 'Changes of object', 38 | }, 39 | ), 40 | migrations.CreateModel( 41 | name='Revision', 42 | fields=[ 43 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 44 | ('date_created', models.DateTimeField(auto_now_add=True, db_index=True, help_text='The date and time this revision was created.', verbose_name='date created')), 45 | ('comment', models.TextField(blank=True, help_text='A text comment on this revision.', verbose_name='comment', default='')), 46 | ], 47 | options={ 48 | 'verbose_name_plural': 'Revisions', 49 | 'ordering': ('-pk',), 50 | 'verbose_name': 'Revision', 51 | }, 52 | ), 53 | migrations.AddField( 54 | model_name='changes', 55 | name='revision', 56 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='models_logging.Revision', verbose_name='to revision'), 57 | ), 58 | migrations.AddField( 59 | model_name='changes', 60 | name='user', 61 | field=models.ForeignKey( 62 | blank=True, 63 | help_text='A user who performed a change', 64 | null=True, 65 | on_delete=django.db.models.deletion.SET_NULL, 66 | to=LOGGING_USER_MODEL, 67 | verbose_name='User' 68 | ) 69 | ), 70 | ] 71 | -------------------------------------------------------------------------------- /models_logging/helpers.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import Union, List 3 | 4 | from django.db.models.base import ModelBase 5 | from django.utils.encoding import force_str 6 | from django.utils.module_loading import import_string 7 | 8 | from models_logging import settings, _local 9 | from models_logging.models import Change, Revision 10 | 11 | 12 | def model_to_dict(instance, action=None): 13 | opts = instance._meta 14 | ignore_fields = set(getattr(instance, "LOGGING_IGNORE_FIELDS", [])) 15 | only_fields = getattr(instance, "LOGGING_ONLY_FIELDS", []) 16 | if action != settings.DELETED: 17 | ignore_fields.update(instance.get_deferred_fields()) 18 | 19 | fnames = [ 20 | f.attname for f in opts.fields 21 | if f.name not in ignore_fields and f.attname not in ignore_fields and not only_fields or f.name in only_fields 22 | ] 23 | 24 | data = {} 25 | for f in fnames: 26 | fvalue = getattr(instance, f, None) 27 | if isinstance(fvalue, (list, dict)): 28 | fvalue = copy.deepcopy(fvalue) 29 | 30 | data[f] = fvalue 31 | return data 32 | 33 | 34 | def get_changed_data(obj, action=settings.CHANGED): 35 | d1 = model_to_dict(obj, action) 36 | if action == settings.DELETED: 37 | return {k: {"old": v} for k, v in d1.items()} 38 | d2 = obj.__attrs 39 | return { 40 | k: {"old": d2[k] if action == settings.CHANGED else None, "new": v} 41 | for k, v in d1.items() 42 | if v != d2[k] 43 | } 44 | 45 | 46 | def create_revision_with_changes(changes: List[Change]): 47 | """ 48 | 49 | :param changes: _local.stack_changes 50 | :return: 51 | """ 52 | comment = ", ".join([c.object_repr for c in changes]) 53 | rev = Revision.objects.using(settings.LOGGING_DATABASE).create( 54 | comment="Changes: %s" % comment 55 | ) 56 | for change in changes: 57 | change.revision = rev 58 | Change.objects.using(settings.LOGGING_DATABASE).bulk_create(changes) 59 | 60 | 61 | def get_change_extras(object, action): 62 | """ 63 | Result of this function will be stored in `Change.extras` field. 64 | Can be used to store additional info from `_local.request` for example: 65 | { 66 | "correlation_id": _local.request.correlation_id, # from django_guid 67 | "ip": _local.request.META.get('REMOTE_ADDR') 68 | } 69 | :param: object - instance of changed model OR DICT if changes are created with `create_changes_for_update` 70 | """ 71 | return {} 72 | 73 | 74 | def init_change( 75 | object: Union[dict, ModelBase], 76 | changed_data, 77 | action, 78 | content_type, 79 | object_repr=None, 80 | ) -> Change: 81 | """ 82 | :param object - django model or dict if it's called from create_changes_for_update 83 | """ 84 | object_repr = object_repr or force_str(object) 85 | object_pk = object["pk"] if isinstance(object, dict) else object.pk 86 | 87 | if isinstance(object, Change.user_field_model()) and object_pk == _local.user_id and action == settings.DELETED: 88 | _local.request = None 89 | 90 | return Change( 91 | db=settings.LOGGING_DATABASE, 92 | object_repr=object_repr, 93 | action=action, 94 | user_id=_local.user_id, 95 | changed_data=changed_data, 96 | object_id=object_pk, 97 | content_type=content_type, 98 | extras=CHANGE_EXTRAS_FUNC(object, settings.CHANGED), 99 | ) 100 | 101 | 102 | CHANGE_EXTRAS_FUNC = import_string(settings.GET_CHANGE_EXTRAS_PATH) 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Models Logging 2 | 3 | This package is for logging every changes in your models in this format: 4 | 5 | ```yaml 6 | { 7 | field_name: { 8 | "old": `old_value`, "new": `new_value` | `None` if this is delete action 9 | } 10 | } 11 | ``` 12 | 13 | Example 14 | 15 | ```json 16 | { 17 | "price": { 18 | "old": "2425", 19 | "new": "645" 20 | }, 21 | "name": { 22 | "old": "ProductName", 23 | "new": "NewProductNmae" 24 | } 25 | } 26 | ``` 27 | 28 | # USING 29 | 30 | Add 'models_logging' at the end of INSTALLED_APPS in settings.py! 31 | This is important because models connect to save and init signals when apps is ready 32 | 33 | 1. Install using pip - `pip install django-models-logging` 34 | 35 | 2. 36 | 37 | ```python 38 | INSTALLED_APPS = ( 39 | ..., 40 | 'models_logging', 41 | ) 42 | ``` 43 | 44 | If request.user is not represented by AUTH_USER_MODEL in your application then you can set up a custom Users model: 45 | 46 | ```python 47 | LOGGING_USER_MODEL = 'yourapp.Users' 48 | # By default, LOGGING_USER_MODEL = AUTH_USER_MODEL 49 | ``` 50 | 51 | 3. make migrations 52 | 4. add the models you want to log in settings.py, format: 53 | 54 | ```python 55 | LOGGING_MODELS = ( 56 | 'app.ClassName', # logging only for this model 57 | 'another_app' # logging of all models in this app 58 | ) 59 | ``` 60 | 61 | Sometimes object.save() operation can be called many times per 1 request. 62 | Per each .save() models_logging creates Change, so your database can quickly grow to a very large size 63 | for prevent this "bug" you can add middleware in settings.py 64 | 65 | ```python 66 | MIDDLEWARE = ( 67 | ..., 68 | 'models_logging.middleware.LoggingStackMiddleware', # it merge all changes of object per request 69 | ) 70 | ``` 71 | 72 | or use context_manager from models_logging.utils in your view or script 73 | 74 | ```python 75 | from models_logging.utils import create_merged_changes 76 | 77 | def your_script(): 78 | with create_merged_changes(): 79 | ... 80 | ``` 81 | 82 | You can add model for ignore logging 83 | settings.py 84 | 85 | ```python 86 | LOGGING_EXCLUDE = ( 87 | 'app' # ignore logging of all models in this app 88 | 'another_app.Model' # ignore logging for this model 89 | ) 90 | ``` 91 | 92 | Also you can set up permission for the logging records 93 | Make func (it will be called in admin) or bool 94 | settings.py 95 | 96 | ```python 97 | def can_revert(request, obj): 98 | return request.user.username == 'myusername' 99 | 100 | LOGGING_REVERT_IS_ALLOWED = can_revert 101 | LOGGING_CAN_DELETE_REVISION = can_revert 102 | LOGGING_CAN_DELETE_CHANGES = False 103 | LOGGING_CAN_CHANGE_CHANGES = True 104 | ``` 105 | 106 | in models you can set attributes: 107 | 108 | ```python 109 | LOGGING_IGNORE_FIELDS = () # to ignore changes of some fields 110 | 111 | # OR 112 | 113 | LOGGING_ONLY_FIELDS = () # to save changes of only those fields 114 | ``` 115 | 116 | If you want to watch changes in admin/history of your object you can use models_logging.admin.HistoryAdmin 117 | 118 | ```python 119 | from models_logging.admin import HistoryAdmin 120 | 121 | 122 | class YourAdminModel(HistoryAdmin): 123 | history_latest_first = False # latest changes first 124 | inline_models_history = '__all__' # __all__ or list of inline models for this ModelAdmin 125 | 126 | ``` 127 | 128 | You can implement your own JSONEncoder and set path to it in django settings 129 | 130 | ```python 131 | LOGGING_JSON_ENCODER = 'path.to.your.JsonEncoder' 132 | ``` 133 | 134 | > Version > 1.0 is incompatible with old versions (requires django >= 2.0) 135 | > For django <= 2.0 use 0.9.7 version 136 | > Version > 2.0 is incompatible with old versions (requires django >= 3.1, <5) 137 | 138 | PS: This module is not optimal as a backup for your database. If you try to recover thousands of changes this will be very slow. 139 | -------------------------------------------------------------------------------- /testapp/testapp/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testapp project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.24. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | import sys 15 | 16 | 17 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 18 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | PACKAGE_DIR = os.path.dirname(BASE_DIR) 20 | sys.path += [PACKAGE_DIR] 21 | 22 | 23 | # Quick-start development settings - unsuitable for production 24 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 25 | 26 | # SECURITY WARNING: keep the secret key used in production secret! 27 | SECRET_KEY = '(f1focm%b=yfc=f^97dhb)-=gf^w=ti+6$iw!$hqgwwlqs53k6' 28 | 29 | # SECURITY WARNING: don't run with debug turned on in production! 30 | DEBUG = True 31 | 32 | ALLOWED_HOSTS = [] 33 | 34 | 35 | # Application definition 36 | 37 | INSTALLED_APPS = [ 38 | 'django.contrib.admin', 39 | 'django.contrib.auth', 40 | 'django.contrib.contenttypes', 41 | 'django.contrib.sessions', 42 | 'django.contrib.messages', 43 | 'django.contrib.staticfiles', 44 | 'testapp', 45 | 'models_logging', 46 | ] 47 | 48 | MIDDLEWARE = [ 49 | 'django.middleware.security.SecurityMiddleware', 50 | 'django.contrib.sessions.middleware.SessionMiddleware', 51 | 'django.middleware.common.CommonMiddleware', 52 | 'django.middleware.csrf.CsrfViewMiddleware', 53 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 54 | 'django.contrib.messages.middleware.MessageMiddleware', 55 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 56 | ] 57 | 58 | ROOT_URLCONF = 'testapp.urls' 59 | 60 | TEMPLATES = [ 61 | { 62 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 63 | 'DIRS': [], 64 | 'APP_DIRS': True, 65 | 'OPTIONS': { 66 | 'context_processors': [ 67 | 'django.template.context_processors.debug', 68 | 'django.template.context_processors.request', 69 | 'django.contrib.auth.context_processors.auth', 70 | 'django.contrib.messages.context_processors.messages', 71 | ], 72 | }, 73 | }, 74 | ] 75 | 76 | WSGI_APPLICATION = 'testapp.wsgi.application' 77 | 78 | 79 | # Database 80 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 81 | 82 | DATABASES = { 83 | 'default': { 84 | 'ENGINE': 'django.db.backends.sqlite3', 85 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 86 | }, 87 | } 88 | 89 | 90 | # Password validation 91 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 92 | 93 | AUTH_PASSWORD_VALIDATORS = [ 94 | { 95 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 96 | }, 97 | { 98 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 99 | }, 100 | { 101 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 102 | }, 103 | { 104 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 105 | }, 106 | ] 107 | 108 | 109 | # Internationalization 110 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 111 | 112 | LANGUAGE_CODE = 'en-us' 113 | 114 | TIME_ZONE = 'UTC' 115 | 116 | USE_I18N = True 117 | 118 | USE_L10N = True 119 | 120 | USE_TZ = True 121 | 122 | 123 | # Static files (CSS, JavaScript, Images) 124 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 125 | 126 | STATIC_URL = '/static/' 127 | 128 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 129 | 130 | try: 131 | from .settings_local import * 132 | except ImportError: 133 | pass 134 | -------------------------------------------------------------------------------- /models_logging/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.fields import GenericForeignKey 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db import models, transaction 4 | from django.db.models.functions import Cast 5 | from django.urls import reverse 6 | from django.utils.module_loading import import_string 7 | from django.utils.translation import gettext_lazy as _ 8 | from six import python_2_unicode_compatible 9 | 10 | from .settings import ADDED, CHANGED, DELETED, LOGGING_USER_MODEL, JSON_ENCODER_PATH 11 | 12 | 13 | def get_encoder(*args, **kwargs): 14 | encoder_cls = import_string(JSON_ENCODER_PATH) 15 | return encoder_cls(*args, **kwargs) 16 | 17 | 18 | @python_2_unicode_compatible 19 | class Revision(models.Model): 20 | """A group of related changes.""" 21 | 22 | class Meta: 23 | verbose_name_plural = _('Revisions') 24 | verbose_name = _('Revision') 25 | ordering = ('-pk',) 26 | 27 | date_created = models.DateTimeField(_("date created"), db_index=True, auto_now_add=True, 28 | help_text=_("The date and time this revision was created.")) 29 | comment = models.TextField(_("comment"), blank=True, help_text=_("A text comment on this revision.")) 30 | 31 | def __str__(self): 32 | return 'Revision %s of <%s>' % (self.id, self.date_created.strftime('%Y-%m-%d %H:%M:%S.%f')) 33 | 34 | def get_admin_url(self): 35 | return reverse('admin:models_logging_revision_change', args=[self.id]) 36 | 37 | def revert(self): 38 | for ch in self.change_set.all(): 39 | ch.revert() 40 | 41 | 42 | @python_2_unicode_compatible 43 | class Change(models.Model): 44 | class Meta: 45 | ordering = ("-pk",) 46 | verbose_name = _('Changes of object') 47 | verbose_name_plural = _('All changes') 48 | index_together = ('content_type', 'object_id') 49 | 50 | ACTIONS = ( 51 | (ADDED, _("Added")), 52 | (CHANGED, _("Changed")), 53 | (DELETED, _("Deleted")) 54 | ) 55 | 56 | date_created = models.DateTimeField(_("Date created"), db_index=True, auto_now_add=True, 57 | help_text=_("The date and time this changes was.")) 58 | user = models.ForeignKey(LOGGING_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL, 59 | verbose_name=_("User"), help_text=_("The user who created this changes.")) 60 | object_id = models.TextField( 61 | help_text=_("Primary key of the model under version control."), 62 | ) 63 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, 64 | help_text="Content type of the model under version control.") 65 | object = GenericForeignKey(ct_field="content_type", fk_field="object_id") 66 | # TODO: db is not used yet 67 | db = models.CharField(max_length=191, help_text=_("The database the model under version control is stored in.")) 68 | 69 | changed_data = models.JSONField(blank=True, null=True, encoder=get_encoder) 70 | 71 | object_repr = models.TextField(help_text=_("A string representation of the object.")) 72 | revision = models.ForeignKey(Revision, blank=True, null=True, verbose_name='to revision', on_delete=models.CASCADE) 73 | action = models.CharField(_("Action"), choices=ACTIONS, help_text=_('added|changed|deleted'), max_length=7) 74 | extras = models.JSONField(blank=True, default=dict, encoder=get_encoder, null=True) 75 | 76 | def __str__(self): 77 | return "Changes %s of %s <%s>" % (self.id, self.object_repr, self.date_created.strftime('%Y-%m-%d %H:%M:%S.%f')) 78 | 79 | @staticmethod 80 | def get_changes_by_obj(obj, related_models): 81 | """ 82 | get changes of object by model and obj 83 | :param obj: instance of tracked Model 84 | :param related_models: list of related models 85 | :return: queryset of Changes 86 | """ 87 | 88 | base_qs = Change.objects.select_related("user") 89 | changes_qs = base_qs.filter(content_type=ContentType.objects.get_for_model(obj.__class__), object_id=obj.pk) 90 | for rel_model in related_models: 91 | if isinstance(rel_model, models.OneToOneRel): 92 | try: 93 | changes_qs = changes_qs.union( 94 | base_qs.filter( 95 | content_type=ContentType.objects.get_for_model(rel_model.related_model), 96 | object_id=getattr(obj, rel_model.get_accessor_name()).pk 97 | ) 98 | ) 99 | except rel_model.related_model.DoesNotExist: 100 | continue 101 | elif isinstance(rel_model, models.ManyToOneRel): 102 | rel_objects_qs = getattr(obj, rel_model.get_accessor_name()).annotate( 103 | pk_str=Cast('pk', output_field=models.TextField()) 104 | ).values('pk_str') 105 | changes_qs = changes_qs.union( 106 | base_qs.filter( 107 | content_type=ContentType.objects.get_for_model(rel_model.related_model), 108 | object_id__in=rel_objects_qs 109 | ) 110 | ) 111 | 112 | return changes_qs.order_by('date_created') 113 | 114 | def revert(self): 115 | with transaction.atomic(): 116 | data = {field: values.get('old') for field, values in self.changed_data.items()} 117 | if self.action == ADDED: 118 | self.object.delete() 119 | elif self.action == CHANGED: 120 | for k, v in data.items(): 121 | setattr(self.object, k, v) 122 | self.object.save() 123 | else: 124 | obj = self.changes_model_class()(**data) 125 | obj.save() 126 | 127 | def changes_model_class(self): 128 | return self.content_type.model_class() 129 | 130 | def get_admin_url(self): 131 | return reverse('admin:models_logging_change_change', args=[self.id]) 132 | 133 | @classmethod 134 | def user_field_model(cls): 135 | return cls._meta.get_field('user').related_model 136 | -------------------------------------------------------------------------------- /models_logging/admin.py: -------------------------------------------------------------------------------- 1 | from functools import update_wrapper 2 | 3 | from django.apps import apps 4 | from django.contrib import admin 5 | from django.contrib import messages 6 | from django.contrib.admin.filters import RelatedFieldListFilter 7 | from django.contrib.admin.utils import unquote 8 | from django.contrib.admin.views.main import ChangeList 9 | from django.contrib.contenttypes.models import ContentType 10 | from django.core.exceptions import PermissionDenied 11 | from django.db import connection 12 | from django.db import transaction 13 | from django.db.models.sql import Query 14 | from django.shortcuts import get_object_or_404, render, redirect 15 | from django.template.response import TemplateResponse 16 | from django.urls import re_path 17 | from django.urls import reverse 18 | from django.utils.encoding import force_str 19 | from django.utils.html import format_html 20 | from django.utils.translation import gettext as _ 21 | 22 | from .models import Change, Revision 23 | from .settings import CAN_DELETE_CHANGES, CAN_CHANGE_CHANGES, CAN_DELETE_REVISION, REVERT_IS_ALLOWED, \ 24 | CHANGES_REVISION_LIMIT, ADDED 25 | 26 | 27 | class ChangeListWithFastCount(ChangeList): 28 | def get_queryset(self, request): 29 | qs = super().get_queryset(request) 30 | self.query: Query = qs.query 31 | qs.count = self.fast_count 32 | return qs 33 | 34 | def fast_count(self): 35 | if not (self.query.group_by or self.query.where or self.query.distinct): 36 | cursor = connection.cursor() 37 | cursor.execute( 38 | "SELECT reltuples FROM pg_class WHERE relname = %s", 39 | [self.query.model._meta.db_table] 40 | ) 41 | return int(cursor.fetchone()[0]) 42 | return self.query.get_count(using=self.queryset.db) 43 | 44 | 45 | class FastObjectsCountAdminModel(admin.ModelAdmin): 46 | show_full_result_count = False 47 | 48 | def get_changelist(self, request): 49 | return ChangeListWithFastCount 50 | 51 | 52 | class HistoryAdmin(admin.ModelAdmin): 53 | object_history_template = "models_logging/object_history.html" 54 | history_latest_first = False 55 | # If inline_models_history is '__all__' it will display changes for all models listed in `inlines` 56 | inline_models_history = '__all__' 57 | 58 | def history_view(self, request, object_id, extra_context=None): 59 | """Renders the history view.""" 60 | # Check if user has change permissions for model 61 | if not self.has_change_permission(request): 62 | raise PermissionDenied 63 | object_id = unquote(object_id) # Underscores in primary key get quoted to "_5F" 64 | 65 | assert isinstance(self.inline_models_history, (tuple, list)) or self.inline_models_history == '__all__' 66 | 67 | # First check if the user can see this history. 68 | model = self.model 69 | obj = self.get_object(request, object_id) 70 | if obj is None: 71 | return self._get_obj_does_not_exist_redirect( 72 | request, model._meta, object_id 73 | ) 74 | 75 | if not self.has_view_or_change_permission(request, obj): 76 | raise PermissionDenied 77 | 78 | # Compile the context. 79 | changes_admin = False 80 | if Change in admin.site._registry: 81 | changes_admin = True 82 | 83 | if self.inline_models_history == '__all__': 84 | self.inline_models_history = self.inlines 85 | 86 | context = { 87 | **self.admin_site.each_context(request), 88 | "changes": self.get_changes_queryset(obj), 89 | 'changes_admin': changes_admin, 90 | "title": _("Change history: %s") % obj, 91 | "subtitle": None, 92 | "opts": model._meta, 93 | "object": obj, 94 | } 95 | context.update(extra_context or {}) 96 | return TemplateResponse( 97 | request, 98 | self.object_history_template, 99 | context, 100 | ) 101 | 102 | def get_changes_queryset(self, obj): 103 | qs = Change.get_changes_by_obj( 104 | obj, 105 | related_models=self.get_related_objects_for_changes() 106 | ) 107 | if self.history_latest_first: 108 | qs = qs.order_by('-date_created') 109 | return qs 110 | 111 | def get_related_objects_for_changes(self): 112 | return [ 113 | m for m in self.model._meta.related_objects 114 | if m.related_model in [i.model for i in self.inline_models_history] 115 | ] 116 | 117 | 118 | class ContentTypeFilterForChange(RelatedFieldListFilter): 119 | def field_choices(self, field, request, model_admin): 120 | models_logging_config = apps.get_app_config('models_logging') 121 | items = ContentType.objects.get_for_models(*models_logging_config.registered_models) 122 | return [(item.pk, str(item)) for item in items.values()] 123 | 124 | 125 | class ChangeAdmin(FastObjectsCountAdminModel): 126 | list_display = ['__str__', 'content_type', 'get_comment', 'get_link_admin_object', 'user'] 127 | list_filter = [('content_type', ContentTypeFilterForChange), 'date_created', 'action'] 128 | change_form_template = 'models_logging/change_form.html' 129 | revert_form_template = 'models_logging/revert_changes_confirmation.html' 130 | search_fields = ['=object_id', '=id', '=revision__id'] 131 | raw_id_fields = ['revision'] 132 | list_select_related = ('user', 'content_type') 133 | 134 | def get_comment(self, obj): 135 | return '%s: %s' % (obj.action, obj.object_repr) 136 | 137 | def get_link_admin_object(self, obj): 138 | try: 139 | if obj.object_id and obj.content_type.model_class() in admin.site._registry: 140 | return format_html( 141 | '%s' % ( 142 | reverse( 143 | 'admin:%s_%s_change' % (obj.content_type.app_label, obj.content_type.model), 144 | args=[obj.object_id] 145 | ), 146 | obj.object_repr 147 | ) 148 | ) 149 | except AttributeError: 150 | return None 151 | 152 | def has_add_permission(self, request, *args, **kwargs): 153 | return 154 | 155 | def has_delete_permission(self, request, obj=None): 156 | return CAN_DELETE_CHANGES(request, obj) if callable(CAN_DELETE_CHANGES) else CAN_DELETE_CHANGES 157 | 158 | def revert_is_allowed(self, request, obj): 159 | return REVERT_IS_ALLOWED(request, obj) if callable(REVERT_IS_ALLOWED) else REVERT_IS_ALLOWED 160 | 161 | def get_readonly_fields(self, request, obj=None): 162 | fields = [f.name for f in obj._meta.fields if f.name != 'revision'] 163 | if CAN_CHANGE_CHANGES(request, obj) if callable(CAN_CHANGE_CHANGES) else CAN_CHANGE_CHANGES: 164 | return fields 165 | return fields + ['revision'] 166 | 167 | def revert_view(self, request, object_id, extra_context=None): 168 | obj = get_object_or_404(Change, id=object_id) 169 | if not self.revert_is_allowed(request, obj): 170 | raise PermissionDenied 171 | 172 | if request.method == 'POST': 173 | if obj.action == ADDED: 174 | # if model is registered in django admin try redirect to delete page 175 | if obj.content_type.model_class() in admin.site._registry: 176 | return redirect(reverse('admin:%s_%s_delete' % 177 | (obj.content_type.app_label, obj.content_type.model), args=[obj.object_id])) 178 | try: 179 | obj.revert() 180 | messages.success(request, 'Changes of %s was reverted' % obj.object_repr) 181 | return redirect(reverse('admin:models_logging_change_changelist')) 182 | except Exception as err: 183 | messages.warning(request, 'Error: %s' % err) 184 | 185 | context = { 186 | 'object': obj, 187 | 'opts': self.model._meta, 188 | 'object_name': obj.object_repr, 189 | 'changed_data': obj.changed_data, 190 | } 191 | context.update(extra_context or {}) 192 | return render(request, self.revert_form_template, context) 193 | 194 | def get_urls(self): 195 | def wrap(view): 196 | def wrapper(*args, **kwargs): 197 | return self.admin_site.admin_view(view)(*args, **kwargs) 198 | wrapper.model_admin = self 199 | return update_wrapper(wrapper, view) 200 | 201 | urls = super(ChangeAdmin, self).get_urls() 202 | urls.insert(0, re_path(r'^(.+)/revert/$', wrap(self.revert_view), name='revert_changes'),) 203 | return urls 204 | 205 | 206 | class ChangeInline(admin.TabularInline): 207 | model = Change 208 | fields = ['__str__', 'content_type', 'object_id', 'object_repr', 'action'] 209 | readonly_fields = fields 210 | extra = 0 211 | 212 | def get_queryset(self, request): 213 | return super(ChangeInline, self).get_queryset(request).select_related('content_type') 214 | 215 | def has_add_permission(self, request, *args, **kwargs): 216 | return False 217 | 218 | def has_delete_permission(self, request, obj=None): 219 | return CAN_DELETE_CHANGES(request, obj) if callable(CAN_DELETE_CHANGES) else CAN_DELETE_CHANGES 220 | 221 | 222 | class RevisionAdmin(FastObjectsCountAdminModel): 223 | inlines = [ChangeInline] 224 | list_display = ['__str__', 'comment', 'changes'] 225 | list_filter = ['date_created'] 226 | change_form_template = 'models_logging/change_form.html' 227 | revert_form_template = 'models_logging/revert_revision_confirmation.html' 228 | readonly_fields = ['comment'] 229 | search_fields = ['=id', '=change__id'] 230 | 231 | def get_queryset(self, request): 232 | return super(RevisionAdmin, self).get_queryset(request).prefetch_related('change_set') 233 | 234 | def has_delete_permission(self, request, obj=None): 235 | return CAN_DELETE_REVISION(request, obj) if callable(CAN_DELETE_REVISION) else CAN_DELETE_REVISION 236 | 237 | def has_add_permission(self, request, *args, **kwargs): 238 | return 239 | 240 | def revert_is_allowed(self, request, obj): 241 | return REVERT_IS_ALLOWED(request, obj) if callable(REVERT_IS_ALLOWED) else REVERT_IS_ALLOWED 242 | 243 | def changes(self, obj): 244 | count = obj.change_set.count() 245 | if count > CHANGES_REVISION_LIMIT: 246 | return 'Changes count - %s' % count 247 | return format_html(', '.join('%s' % (i.get_admin_url(), i.id) for i in 248 | [ch for ch in obj.change_set.all()])) 249 | 250 | def get_inline_formsets(self, request, formsets, inline_instances, obj=None): 251 | for formset in formsets: 252 | if formset.queryset.count() > CHANGES_REVISION_LIMIT: 253 | formset.queryset = formset.queryset[:CHANGES_REVISION_LIMIT] 254 | return super(RevisionAdmin, self).get_inline_formsets(request, formsets, inline_instances, obj) 255 | 256 | def get_urls(self): 257 | def wrap(view): 258 | def wrapper(*args, **kwargs): 259 | return self.admin_site.admin_view(view)(*args, **kwargs) 260 | wrapper.model_admin = self 261 | return update_wrapper(wrapper, view) 262 | 263 | urls = super(RevisionAdmin, self).get_urls() 264 | urls.insert(0, re_path(r'^(.+)/revert/$', wrap(self.revert_view), name='revert_revision'),) 265 | return urls 266 | 267 | def revert_view(self, request, object_id, extra_context=None): 268 | obj = get_object_or_404(Revision, id=object_id) 269 | if not self.revert_is_allowed(request, obj): 270 | raise PermissionDenied 271 | 272 | if request.method == 'POST': 273 | try: 274 | with transaction.atomic(): 275 | obj.revert() 276 | messages.success(request, 'Changes of %s was reverted' % force_str(obj)) 277 | return redirect(reverse('admin:models_logging_revision_changelist')) 278 | except Exception as err: 279 | messages.warning(request, 'Error: %s' % err) 280 | 281 | context = { 282 | 'object': obj, 283 | 'opts': self.model._meta, 284 | 'object_name': force_str(obj), 285 | 'changes': obj.change_set.all()[:CHANGES_REVISION_LIMIT], 286 | 'limit': CHANGES_REVISION_LIMIT, 287 | 'changes_count': obj.change_set.count(), 288 | } 289 | context.update(extra_context or {}) 290 | return render(request, self.revert_form_template, context) 291 | 292 | 293 | admin.site.register(Change, ChangeAdmin) 294 | admin.site.register(Revision, RevisionAdmin) 295 | --------------------------------------------------------------------------------