├── .gitignore ├── README.rst ├── django_history ├── __init__.py ├── current_context.py ├── manager.py └── models.py ├── htmlversion.html └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | .project 4 | .metadata 5 | .pydevproject 6 | .settings 7 | pip-log.txt 8 | *.*~ 9 | .~* 10 | *.log 11 | .sass-cache 12 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Общее описание 2 | -------------- 3 | 4 | Приложение для сохранения истории изменений 5 | объектов. Технику взял из книги [Pro Django (автор Marty Alchin)][1]. 6 | 7 | Приложение умеет: 8 | 9 | 1. сохранять все изменения объекта 10 | 2. откатывать изменения 11 | 3. просто включается, не требует создания вручную моделей для сохранения истории, при этом история изменений хранится в разных таблицах для каждой модели 12 | 13 | Можно установить с помощью pip: 14 | 15 | pip install -e git+git://github.com/ildus/django-history.git#egg=django-history 16 | 17 | Установка: 18 | ---------- 19 | 20 | * добавляем в мидлвары `django_history.current_context.CurrentUserMiddleware` 21 | * в модели, для которого нужно сохранять историю, добавляем 22 | 23 | 24 | from django_history.models import HistoricalRecords 25 | history = HistoricalRecords() 26 | 27 | * выполнить syncdb 28 | 29 | 30 | Middleware нужен, для того чтобы сохранить какой пользователь сделал 31 | изменения. 32 | 33 | Использование: 34 | -------------- 35 | 36 | Можно получить историю всех изменений в модели или объекте модели. Например, 37 | так 38 | 39 | >>> from main.models import Poll 40 | >>> Poll.objects.create(language = 'en',question = 'Where are we?') 41 | 42 | >>> poll = Poll.objects.all()[0] 43 | >>> poll.language = 'ru' 44 | >>> poll.save() 45 | >>> poll.history.all() 46 | [, ] 47 | 48 | При авторизованном пользователе, будет видно кто изменил объект. 49 | 50 | Можно откатывать изменения. Нужно учесть, что откат тоже является изменением и 51 | тоже идет в историю 52 | 53 | >>> poll.history.all()[0].revert() 54 | >>> poll.history.all() 55 | [, ] 56 | 57 | Можно получать историю не только по объекту, но и по всей модели 58 | 59 | >>> poll2 = Poll.objects.create(language = 'cz',question = 'Who are we?') 60 | >>> poll2.language = 'cs' 61 | >>> poll2.save() 62 | >>> Poll.history.all() 63 | [, , , , ] 64 | 65 | Вот и все! Детали реализации лучше смотреть прямо в коде, благо его там не так 66 | много. А если коротко, то все реализовано через сигналы и метода 67 | contribute_to_class, который отрабатывает для полей модели. 68 | 69 | [1]: http://prodjango.com/ 70 | 71 | -------------------------------------------------------------------------------- /django_history/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ildus/django-history/074471100b7607266fd2ea30fdcb20680aededff/django_history/__init__.py -------------------------------------------------------------------------------- /django_history/current_context.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | from django.db import models 3 | from django.db.models import signals 4 | from django.utils.functional import curry 5 | from django.utils.decorators import decorator_from_middleware 6 | 7 | 8 | def get_user_model(): 9 | try: 10 | from django.contrib.auth import get_user_model 11 | except ImportError: # django < 1.5 12 | from django.contrib.auth.models import User 13 | return User 14 | else: 15 | return get_user_model() 16 | 17 | 18 | class FieldRegistry(object): 19 | _registry = {} 20 | 21 | def add_field(self, model, field): 22 | reg = self.__class__._registry.setdefault(model, []) 23 | reg.append(field) 24 | 25 | def get_fields(self, model): 26 | return self.__class__._registry.get(model, []) 27 | 28 | def __contains__(self, model): 29 | return model in self.__class__._registry 30 | 31 | 32 | class CurrentUserMiddleware(object): 33 | def process_request(self, request): 34 | if request.method in ('GET', 'HEAD', 'OPTIONS', 'TRACE'): 35 | # This request shouldn't update anything, 36 | # so no singal handler should be attached. 37 | return 38 | 39 | user = request.user if hasattr(request, 'user') and request.user.is_authenticated() else None 40 | 41 | update_context = curry(self.update_context, user) 42 | signals.pre_save.connect(update_context, dispatch_uid=request, weak=False) 43 | 44 | def update_context(self, user, sender, instance, **kwargs): 45 | registry = FieldRegistry() 46 | if sender in registry: 47 | for field in registry.get_fields(sender): 48 | if field.one_time and getattr(instance, field.name, None): 49 | continue 50 | 51 | if isinstance(field, CurrentUserField): 52 | setattr(instance, field.name, user) 53 | 54 | def process_response(self, request, response): 55 | signals.pre_save.disconnect(dispatch_uid=request) 56 | return response 57 | 58 | 59 | record_current_context = decorator_from_middleware(CurrentUserMiddleware) 60 | 61 | 62 | class CurrentUserField(models.ForeignKey): 63 | def __init__(self, one_time = False, **kwargs): 64 | self.one_time = one_time 65 | super(CurrentUserField, self).__init__(get_user_model(), null=True, **kwargs) 66 | 67 | def contribute_to_class(self, cls, name): 68 | super(CurrentUserField, self).contribute_to_class(cls, name) 69 | registry = FieldRegistry() 70 | registry.add_field(cls, self) 71 | 72 | 73 | try: 74 | from south.modelsinspector import add_introspection_rules 75 | 76 | ## south rules 77 | user_rules = [( 78 | (CurrentUserField,), 79 | [], 80 | { 81 | 'to': ['rel.to', {'default': get_user_model()}], 82 | 'null': ['null', {'default': True}], 83 | }, 84 | )] 85 | 86 | add_introspection_rules(user_rules, ["^django_history\.current_context\.CurrentUserField"]) 87 | except: 88 | pass 89 | -------------------------------------------------------------------------------- /django_history/manager.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | 3 | from django.db import models 4 | 5 | class HistoryDescriptor(object): 6 | def __init__(self, model): 7 | self.model = model 8 | 9 | def __get__(self, instance, owner): 10 | if instance is None: 11 | return HistoryManager(self.model) 12 | return HistoryManager(self.model, instance) 13 | 14 | class HistoryManager(models.Manager): 15 | def __init__(self, model, instance=None): 16 | super(HistoryManager, self).__init__() 17 | self.model = model 18 | self.instance = instance 19 | 20 | def get_query_set(self): 21 | if self.instance is None: 22 | return super(HistoryManager, self).get_query_set() 23 | 24 | filter = {self.instance._meta.pk.name: self.instance.pk} 25 | return super(HistoryManager, self).get_query_set().filter(**filter) -------------------------------------------------------------------------------- /django_history/models.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | 3 | from django.db import models 4 | 5 | from django_history.current_context import CurrentUserField 6 | from django_history.manager import HistoryDescriptor 7 | 8 | import cPickle as pickle 9 | from copy import copy 10 | from django.utils import timezone 11 | from django.utils.functional import curry 12 | from django.utils.encoding import force_unicode 13 | from django.core.exceptions import ObjectDoesNotExist 14 | 15 | def revert_changes(model, self, field = None): 16 | pk_value = getattr(self, model._meta.pk.name) 17 | instance = model._default_manager.get(pk = pk_value) 18 | data = self.get_data() 19 | if data: 20 | if field: #откат одного поля 21 | assert (field in data) 22 | setattr(instance, field, data[field][0]) 23 | del data[field] 24 | else: 25 | for attr, (old, __) in data.iteritems(): 26 | setattr(instance, attr, old) 27 | data = {} 28 | 29 | instance.save() 30 | 31 | item = instance.history.latest() 32 | item.is_reverting = True 33 | item.save() 34 | 35 | #удаляем объект истории если все было откатано 36 | if not data: self.delete() 37 | else: 38 | self.set_data(data) #сохраняем только не откатанные данные 39 | self.save() 40 | 41 | def verbose_value(field, value): 42 | if isinstance(field, models.BooleanField): 43 | return 'Да' if value else 'Нет' 44 | elif type(field) in (models.IntegerField, models.PositiveIntegerField, models.CharField): 45 | if field._choices: 46 | return force_unicode(dict(field.flatchoices).get(value, value), strings_only=True) 47 | else: 48 | return value 49 | return unicode(value) if value is not None else '' 50 | 51 | def get_info(model, self, prefix = None): 52 | full_name = self.history_user.userprofile.full_name if self.history_user else '' 53 | date = self.history_date 54 | result = [] 55 | prefix = prefix + ', ' if prefix else '' 56 | for attr, (old, new) in self.get_data().iteritems(): 57 | if '_id' in attr: 58 | field = model._meta.get_field(attr.replace('_id', '')) 59 | if isinstance(field, models.ForeignKey): 60 | try: 61 | old1 = field.rel.to._default_manager.get(pk = old) if old else old 62 | new1 = field.rel.to._default_manager.get(pk = new) if new else new 63 | new, old = new1, old1 64 | except ObjectDoesNotExist: 65 | pass 66 | else: 67 | field = model._meta.get_field(attr) 68 | 69 | result.append({ 70 | 'operation': self.history_type, 71 | 'id': self.pk, 72 | 'user': full_name, 73 | 'type': model._meta.module_name, 74 | 'attr': attr, 75 | 'attr_verbose': prefix + (field.verbose_name or 'undefined!'), 76 | 'old': verbose_value(field, old), 77 | 'new': verbose_value(field, new), 78 | 'date': date, 79 | 'is_reverting': self.is_reverting, 80 | }) 81 | return result 82 | 83 | class HistoricalRecords(object): 84 | registry = {} #register history models 85 | 86 | def __init__(self, exclude = None, include = None): 87 | self.exclude = exclude 88 | self.include = include 89 | 90 | def contribute_to_class(self, cls, name): 91 | self.manager_name = name 92 | models.signals.class_prepared.connect(self.finalize, sender=cls) 93 | 94 | def finalize(self, sender, **kwargs): 95 | history_model = self.create_history_model(sender) 96 | 97 | models.signals.pre_save.connect(self.pre_save, sender=sender, weak=False) 98 | models.signals.post_delete.connect(self.post_delete, sender=sender, weak=False) 99 | models.signals.post_save.connect(self.post_save, sender=sender, weak=False) 100 | 101 | descriptor = HistoryDescriptor(history_model) 102 | setattr(sender, self.manager_name, descriptor) 103 | 104 | def create_history_model(self, model): 105 | """ 106 | Creates a historical model to associate with the model provided. 107 | """ 108 | attrs = self.get_history_model_fields(model) 109 | attrs.update(Meta=type('Meta', (), self.get_meta_options(model))) 110 | name = 'Historical%s' % model._meta.object_name 111 | history_model = type(name, (models.Model,), attrs) 112 | self.__class__.registry[model._meta.module_name] = history_model 113 | return history_model 114 | 115 | def __contains__(self, module_name): 116 | return module_name in self.__class__.registry 117 | 118 | def get_history_model(self, module_name): 119 | return self.__class__.registry.get(module_name) 120 | 121 | def get_history_model_fields(self, model): 122 | """ 123 | Returns a dictionary of fields that will be added to the historical 124 | record model, in addition to the ones returned by copy_fields below. 125 | """ 126 | rel_nm = '_%s_history' % model._meta.object_name.lower() 127 | fields = { 128 | '__module__': model.__module__, 129 | 130 | #fields of history item 131 | 'history_id': models.AutoField(primary_key=True), 132 | 'history_date': models.DateTimeField(default=timezone.now), 133 | 'history_user': CurrentUserField(related_name=rel_nm), 134 | 'history_data': models.TextField(), #here is only the changed data 135 | 'history_all_data': models.TextField(blank = True, null = True), #here saved all data of item 136 | 'history_type': models.CharField(max_length=1, choices=( 137 | ('+', 'Created'), 138 | ('~', 'Changed'), 139 | ('-', 'Deleted'), 140 | )), 141 | 'is_reverting': models.BooleanField(default = False), 142 | 143 | #method of history item 144 | 'revert': curry(revert_changes, model), 145 | 'get_info': curry(get_info, model), 146 | 'get_data': lambda self: pickle.loads(self.history_data.encode('utf-8')), 147 | 'set_data': lambda self, data: setattr(self, 'data', pickle.dumps(data)), 148 | '__unicode__': lambda self: u'%s by %s on %s, %s' % (self.get_history_type_display(), 149 | self.history_user, self.history_date, 150 | self.get_data()) 151 | } 152 | 153 | #primary key that point to the main object 154 | pk_field = copy(model._meta.get_field(model._meta.pk.name)) 155 | pk_field.__class__ = models.IntegerField 156 | pk_field._unique = False 157 | pk_field.primary_key = False 158 | pk_field.db_index = True 159 | 160 | fields[model._meta.pk.name] = pk_field 161 | return fields 162 | 163 | def get_meta_options(self, model): 164 | """ 165 | Returns a dictionary of fields that will be added to 166 | the Meta inner class of the historical record model. 167 | """ 168 | options = {'ordering': ('-history_date',), 169 | 'get_latest_by': 'history_date'} 170 | if model._meta.app_label: 171 | options['app_label'] = model._meta.app_label 172 | return options 173 | 174 | def pre_save(self, instance, **kwargs): 175 | if instance.pk: 176 | self.create_historical_record(instance, '~') 177 | 178 | def post_save(self, instance, created, **kwargs): 179 | if created: 180 | self.create_historical_record(instance, '+') 181 | 182 | def post_delete(self, instance, **kwargs): 183 | self.create_historical_record(instance, '-') 184 | 185 | def create_historical_record(self, instance, history_type): 186 | manager = getattr(instance, self.manager_name) 187 | 188 | attrs = {} 189 | attrs[instance._meta.pk.name] = getattr(instance, instance._meta.pk.name) 190 | #collecting changed fields 191 | history_data = {} 192 | history_all_data = {} 193 | if instance.pk and history_type != '-': 194 | old = instance.__class__._default_manager.get(pk = instance.pk) 195 | for field in instance._meta.fields: 196 | if (self.exclude and field.name in self.exclude) or (self.include and field.name not in self.include): 197 | continue 198 | 199 | if field.editable and type(field) not in (models.ManyToManyField, ): 200 | new_value = getattr(instance, field.attname) 201 | old_value = getattr(old, field.attname) 202 | 203 | history_all_data[field.attname] = new_value 204 | 205 | if new_value != old_value: 206 | history_data[field.attname] = (old_value, new_value) 207 | 208 | manager.create(history_type=history_type, 209 | history_data = pickle.dumps(history_data), 210 | history_all_data = pickle.dumps(history_all_data), 211 | **attrs) 212 | 213 | 214 | class FullHistoricalRecords(object): 215 | registry = {} # register history models 216 | 217 | def __init__(self, register_in_admin=False): 218 | self.register_in_admin = register_in_admin 219 | 220 | def contribute_to_class(self, cls, name): 221 | self.manager_name = name 222 | models.signals.class_prepared.connect(self.finalize, sender=cls) 223 | 224 | def finalize(self, sender, **kwargs): 225 | history_model = self.create_history_model(sender) 226 | 227 | # The HistoricalRecords object will be discarded, 228 | # so the signal handlers can't use weak references. 229 | models.signals.post_save.connect(self.post_save, sender=sender, 230 | weak=False) 231 | models.signals.post_delete.connect(self.post_delete, sender=sender, 232 | weak=False) 233 | 234 | descriptor = HistoryDescriptor(history_model) 235 | setattr(sender, self.manager_name, descriptor) 236 | 237 | if self.register_in_admin: 238 | from django.contrib import admin 239 | admin.site.register(history_model) 240 | 241 | def create_history_model(self, model): 242 | """ 243 | Creates a historical model to associate with the model provided. 244 | """ 245 | attrs = self.copy_fields(model) 246 | attrs.update(self.get_extra_fields(model)) 247 | attrs.update(Meta=type('Meta', (), self.get_meta_options(model))) 248 | name = 'FullHistorical%s' % model._meta.object_name 249 | history_model = type(name, (models.Model,), attrs) 250 | self.__class__.registry[model._meta.module_name] = history_model 251 | return history_model 252 | 253 | def copy_fields(self, model): 254 | """ 255 | Creates copies of the model's original fields, returning 256 | a dictionary mapping field name to copied field object. 257 | """ 258 | # Though not strictly a field, this attribute 259 | # is required for a model to function properly. 260 | fields = {'__module__': model.__module__} 261 | 262 | for field in model._meta.fields: 263 | field = copy(field) 264 | 265 | if isinstance(field, models.AutoField): 266 | # The historical model gets its own AutoField, so any 267 | # existing one must be replaced with an IntegerField. 268 | field.__class__ = models.IntegerField 269 | if isinstance(field, models.OneToOneField): 270 | field.__class__ = models.ForeignKey 271 | 272 | if field.primary_key or field.unique: 273 | # Unique fields can no longer be guaranteed unique, 274 | # but they should still be indexed for faster lookups. 275 | field.primary_key = False 276 | field._unique = False 277 | field.db_index = True 278 | fields[field.name] = field 279 | 280 | return fields 281 | 282 | def get_extra_fields(self, model): 283 | """ 284 | Returns a dictionary of fields that will be added to the historical 285 | record model, in addition to the ones returned by copy_fields below. 286 | """ 287 | rel_nm = '_%s_history' % model._meta.object_name.lower() 288 | return { 289 | 'history_id': models.AutoField(primary_key=True), 290 | 'history_date': models.DateTimeField(default=timezone.now), 291 | 'history_user': CurrentUserField(related_name=rel_nm), 292 | 'history_type': models.CharField(max_length=1, choices=( 293 | ('+', 'Created'), 294 | ('~', 'Changed'), 295 | ('-', 'Deleted'), 296 | )), 297 | 'history_object': HistoricalObjectDescriptor(model), 298 | '__unicode__': lambda self: u'%s на %s' % (self.history_object, 299 | self.history_date.strftime('%d.%m.%Y %H:%M')) 300 | } 301 | 302 | def get_meta_options(self, model): 303 | """ 304 | Returns a dictionary of fields that will be added to 305 | the Meta inner class of the historical record model. 306 | """ 307 | return { 308 | 'ordering': ('-history_date',), 309 | 'verbose_name': u'История: %s' % model._meta.verbose_name, 310 | 'verbose_name_plural': u'История: %s' % model._meta.verbose_name_plural 311 | } 312 | 313 | def post_save(self, instance, created, **kwargs): 314 | self.create_historical_record(instance, created and '+' or '~') 315 | 316 | def post_delete(self, instance, **kwargs): 317 | self.create_historical_record(instance, '-') 318 | 319 | def create_historical_record(self, instance, type): 320 | manager = getattr(instance, self.manager_name) 321 | attrs = {} 322 | for field in instance._meta.fields: 323 | attrs[field.attname] = getattr(instance, field.attname) 324 | manager.create(history_type=type, **attrs) 325 | 326 | 327 | class HistoricalObjectDescriptor(object): 328 | def __init__(self, model): 329 | self.model = model 330 | 331 | def __get__(self, instance, owner): 332 | values = (getattr(instance, f.attname) for f in self.model._meta.fields) 333 | return self.model(*values) 334 | -------------------------------------------------------------------------------- /htmlversion.html: -------------------------------------------------------------------------------- 1 | Здравствуйте, решил поделиться приложением для сохранения истории изменений объектов. Технику взял из книги Pro Django (автор Marty Alchin).
2 |
3 | Приложение умеет:
4 |
    5 |
  1. сохранять все изменения объекта
  2. 6 |
  3. откатывать изменения
  4. 7 |
  5. просто включается, не требует создания вручную моделей для сохранения истории, при этом история изменений хранится в разных таблицах для каждой модели
  6. 8 |

9 |
10 | Весь код лежит здесь https://github.com/ildus/django-history. Можно установить с помощью pip:
11 | pip install -e git+git://github.com/ildus/django-history.git#egg=django-history
12 |
13 |
Установка:

14 |
    15 |
  • добавляем в мидлвары django_history.current_context.CurrentUserMiddleware
  • 16 |
  • в модели, для которого нужно сохранять историю, добавляем
    17 |
    18 |
    from django_history.models import HistoricalRecords
    19 | history = HistoricalRecords()

    20 |
  • 21 |
  • выполнить syncdb
  • 22 |

23 | Middleware нужен, для того чтобы сохранить какой пользователь сделал изменения.
24 |
25 |
Использование:

26 | Можно получить историю всех изменений в модели или объекте модели. Например, так
27 |
28 |
(InteractiveConsole)
29 | >>> from main.models import Poll
30 | >>> Poll.objects.create(language = 'en',question = 'Where are we?')
31 | <Poll: Where are we?>
32 | >>> poll = Poll.objects.all()[0]
33 | >>> poll.language = 'ru'
34 | >>> poll.save()
35 | >>> poll.history.all()
36 | [<HistoricalPoll: Changed by None on 2011-09-10 15:49:48.609916, {'language': (u'en', 'ru')}>, <HistoricalPoll: Created by None on 2011-09-10 15:49:00.355074, {}>]
37 | 

38 | При авторизованном пользователе, будет видно кто изменил объект.
39 |
40 | Можно откатывать изменения. Нужно учесть, что откат тоже является изменением и тоже идет в историю
41 |
42 |
>>> poll.history.all()[0].revert()
43 | >>> poll.history.all()
44 | [<HistoricalPoll: Changed by None on 2011-09-10 17:24:30.570957, {'language': (u'ru', u'en')}>, <HistoricalPoll: Created by None on 2011-09-10 15:49:00.355074, {}>]
45 | 

46 | Можно получать историю не только по объекту, но и по всей модели
47 |
48 |
>>> poll2 = Poll.objects.create(language = 'cz',question = 'Who are we?')
49 | >>> poll2.language = 'cs'
50 | >>> poll2.save()
51 | >>> Poll.history.all()
52 | [<HistoricalPoll: Changed by None on 2011-09-10 17:27:01.669054, {'language': (u'cz', 'cs')}>, <HistoricalPoll: Created by None on 2011-09-10 17:26:30.827953, {}>, <HistoricalPoll: Created by None on 2011-09-10 17:25:57.839304, {}>, <HistoricalPoll: Changed by None on 2011-09-10 17:24:30.570957, {'language': (u'ru', u'en')}>, <HistoricalPoll: Created by None on 2011-09-10 15:49:00.355074, {}>]
53 | 

54 |
55 | Вот и все! Детали реализации лучше смотреть прямо в коде, благо его там не так много. А если коротко, то все реализовано через сигналы и метода contribute_to_class, который отрабатывает для полей модели. 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='django-history', 5 | version='0.1.1', 6 | description='Django History', 7 | author='Ildus K.', 8 | author_email='k-dus@yandex.ru', 9 | url='https://github.com/django-history', 10 | packages=find_packages(), 11 | requires = ['south', 'progressbar'], 12 | classifiers=[ 13 | 'Development Status :: 1 - Alpha', 14 | 'Environment :: Web Environment', 15 | 'Intended Audience :: Developers', 16 | 'License :: OSI Approved :: BSD License', 17 | 'Operating System :: OS Independent', 18 | 'Programming Language :: Python', 19 | 'Framework :: Django', 20 | ], 21 | include_package_data=True, 22 | zip_safe=False, 23 | install_requires=[], 24 | ) 25 | --------------------------------------------------------------------------------