├── 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 |
{% 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 | {% blocktrans with escaped_object=object %}Are you sure you want to revert the {{ object_name }}?{% endblocktrans %}
17 |
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 | | {% trans 'Date/time' %} |
16 | {% trans 'User' %} |
17 | {% trans 'Object' %} |
18 | {% trans 'Action' %} |
19 | {% trans 'Changed data' %} |
20 |
21 |
22 |
23 | {% for change in changes %}
24 |
25 | |
26 | {% if changes_admin %}
27 | {{ change.date_created|date:"DATETIME_FORMAT"}}
28 | {% else %}
29 | {{ change.date_created|date:"DATETIME_FORMAT"}}
30 | {% endif %}
31 | |
32 |
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 | |
40 |
41 | {{ change.object_repr }}
42 | |
43 |
44 | {{ change.action }}
45 | |
46 |
47 | {% for field, values in change.changed_data.items %}
48 | {{ field }}: {{ values.old }} -> {{ values.new }}
49 | {% endfor %}
50 | |
51 |
52 | {% endfor %}
53 |
54 |
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 | '