diff options
Diffstat (limited to 'django-simple-history/simple_history')
6 files changed, 467 insertions, 0 deletions
diff --git a/django-simple-history/simple_history/__init__.py b/django-simple-history/simple_history/__init__.py new file mode 100755 index 000000000..6df0b60b6 --- /dev/null +++ b/django-simple-history/simple_history/__init__.py @@ -0,0 +1,22 @@ +import models + + +registered_models = {} + + +def register(model, app=None, manager_name='history'): + """ + Create historical model for `model` and attach history manager to `model`. + + Keyword arguments: + app -- App to install historical model into (defaults to model.__module__) + manager_name -- class attribute name to use for historical manager + + This method should be used as an alternative to attaching an + `HistoricalManager` instance directly to `model`. + """ + if not model in registered_models: + records = models.HistoricalRecords() + records.manager_name = manager_name + records.module = ("%s.models" % app) or model.__module__ + records.finalize(model) diff --git a/django-simple-history/simple_history/admin.py b/django-simple-history/simple_history/admin.py new file mode 100644 index 000000000..7d83e1016 --- /dev/null +++ b/django-simple-history/simple_history/admin.py @@ -0,0 +1,139 @@ +from django import template +from django.core.exceptions import PermissionDenied +from django.conf.urls.defaults import patterns, url +from django.contrib import admin +from django.contrib.admin import helpers +from django.contrib.contenttypes.models import ContentType +from django.core.urlresolvers import reverse +from django.shortcuts import get_object_or_404, render_to_response +from django.contrib.admin.util import unquote +from django.utils.text import capfirst +from django.utils.html import mark_safe +from django.utils.translation import ugettext as _ +from django.utils.encoding import force_unicode + + +class SimpleHistoryAdmin(admin.ModelAdmin): + object_history_template = "simple_history/object_history.html" + object_history_form_template = "simple_history/object_history_form.html" + + def get_urls(self): + """Returns the additional urls used by the Reversion admin.""" + urls = super(SimpleHistoryAdmin, self).get_urls() + admin_site = self.admin_site + opts = self.model._meta + info = opts.app_label, opts.module_name, + history_urls = patterns("", + url("^([^/]+)/history/([^/]+)/$", + admin_site.admin_view(self.history_form_view), + name='%s_%s_simple_history' % info),) + return history_urls + urls + + def history_view(self, request, object_id, extra_context=None): + "The 'history' admin view for this model." + model = self.model + opts = model._meta + app_label = opts.app_label + pk_name = opts.pk.attname + history = getattr(model, model._meta.simple_history_manager_attribute) + action_list = history.filter(**{pk_name: object_id}) + # If no history was found, see whether this object even exists. + obj = get_object_or_404(model, pk=unquote(object_id)) + context = { + 'title': _('Change history: %s') % force_unicode(obj), + 'action_list': action_list, + 'module_name': capfirst(force_unicode(opts.verbose_name_plural)), + 'object': obj, + 'root_path': getattr(self.admin_site, 'root_path', None), + 'app_label': app_label, + 'opts': opts + } + context.update(extra_context or {}) + context_instance = template.RequestContext(request, current_app=self.admin_site.name) + return render_to_response(self.object_history_template, context, context_instance=context_instance) + + def history_form_view(self, request, object_id, version_id): + original_model = self.model + original_opts = original_model._meta + history = getattr(self.model, self.model._meta.simple_history_manager_attribute) + model = history.model + opts = model._meta + pk_name = original_opts.pk.attname + record = get_object_or_404(model, **{pk_name: object_id, 'history_id': version_id}) + obj = record.instance + obj._state.adding = False + + if not self.has_change_permission(request, obj): + raise PermissionDenied + + if request.method == 'POST' and '_saveas_new' in request.POST: + return self.add_view(request, form_url='../add/') + + formsets = [] + ModelForm = self.get_form(request, obj) + if request.method == 'POST': + form = ModelForm(request.POST, request.FILES, instance=obj) + if form.is_valid(): + form_validated = True + new_object = self.save_form(request, form, change=True) + else: + form_validated = False + new_object = obj + prefixes = {} + + if form_validated: + self.save_model(request, new_object, form, change=True) + form.save_m2m() + + change_message = self.construct_change_message(request, form, formsets) + self.log_change(request, new_object, change_message) + return self.response_change(request, new_object) + + else: + form = ModelForm(instance=obj) + + adminForm = helpers.AdminForm(form, self.get_fieldsets(request, obj), + self.prepopulated_fields, self.get_readonly_fields(request, obj), + model_admin=self) + media = self.media + adminForm.media + + url_triplet = (self.admin_site.name, original_opts.app_label, + original_opts.module_name) + context = { + 'title': _('Revert %s') % force_unicode(obj), + 'adminform': adminForm, + 'object_id': object_id, + 'original': obj, + 'is_popup': False, + 'media': mark_safe(media), + 'errors': helpers.AdminErrorList(form, formsets), + 'app_label': opts.app_label, + 'original_opts': original_opts, + 'changelist_url': reverse('%s:%s_%s_changelist' % url_triplet), + 'change_url': reverse('%s:%s_%s_change' % url_triplet, args=(obj.pk,)), + 'history_url': reverse('%s:%s_%s_history' % url_triplet, args=(obj.pk,)), + # Context variables copied from render_change_form + 'add': False, + 'change': True, + 'has_add_permission': self.has_add_permission(request), + 'has_change_permission': self.has_change_permission(request, obj), + 'has_delete_permission': self.has_delete_permission(request, obj), + 'has_file_field': True, + 'has_absolute_url': False, + 'ordered_objects': opts.get_ordered_objects(), + 'form_url': '', + 'opts': opts, + 'content_type_id': ContentType.objects.get_for_model(self.model).id, + 'save_as': self.save_as, + 'save_on_top': self.save_on_top, + 'root_path': getattr(self.admin_site, 'root_path', None), + } + context_instance = template.RequestContext(request, current_app=self.admin_site.name) + return render_to_response(self.object_history_form_template, context, context_instance) + + def save_model(self, request, obj, form, change): + """ + Add the admin user to a special model attribute for reference after save + """ + obj._history_user = request.user + super(SimpleHistoryAdmin, self).save_model(request, obj, form, change) diff --git a/django-simple-history/simple_history/manager.py b/django-simple-history/simple_history/manager.py new file mode 100755 index 000000000..923b310eb --- /dev/null +++ b/django-simple-history/simple_history/manager.py @@ -0,0 +1,75 @@ +from django.db import models + + +class HistoryDescriptor(object): + def __init__(self, model): + self.model = model + + def __get__(self, instance, owner): + if instance is None: + return HistoryManager(self.model) + return HistoryManager(self.model, instance) + + +class HistoryManager(models.Manager): + def __init__(self, model, instance=None): + super(HistoryManager, self).__init__() + self.model = model + self.instance = instance + + def get_query_set(self): + if self.instance is None: + return super(HistoryManager, self).get_query_set() + + if isinstance(self.instance._meta.pk, models.OneToOneField): + filter = {self.instance._meta.pk.name + "_id": self.instance.pk} + else: + filter = {self.instance._meta.pk.name: self.instance.pk} + return super(HistoryManager, self).get_query_set().filter(**filter) + + def most_recent(self): + """ + Returns the most recent copy of the instance available in the history. + """ + if not self.instance: + raise TypeError("Can't use most_recent() without a %s instance." % + self.instance._meta.object_name) + tmp = [] + for field in self.instance._meta.fields: + if isinstance(field, models.ForeignKey): + tmp.append(field.name + "_id") + else: + tmp.append(field.name) + fields = tuple(tmp) + try: + values = self.values_list(*fields)[0] + except IndexError: + raise self.instance.DoesNotExist("%s has no historical record." % + self.instance._meta.object_name) + return self.instance.__class__(*values) + + def as_of(self, date): + """ + Returns an instance of the original model with all the attributes set + according to what was present on the object on the date provided. + """ + if not self.instance: + raise TypeError("Can't use as_of() without a %s instance." % + self.instance._meta.object_name) + tmp = [] + for field in self.instance._meta.fields: + if isinstance(field, models.ForeignKey): + tmp.append(field.name + "_id") + else: + tmp.append(field.name) + fields = tuple(tmp) + qs = self.filter(history_date__lte=date) + try: + values = qs.values_list('history_type', *fields)[0] + except IndexError: + raise self.instance.DoesNotExist("%s had not yet been created." % + self.instance._meta.object_name) + if values[0] == '-': + raise self.instance.DoesNotExist("%s had already been deleted." % + self.instance._meta.object_name) + return self.instance.__class__(*values[1:]) diff --git a/django-simple-history/simple_history/models.py b/django-simple-history/simple_history/models.py new file mode 100644 index 000000000..06054ba34 --- /dev/null +++ b/django-simple-history/simple_history/models.py @@ -0,0 +1,169 @@ +import copy +from django.db import models +from django.contrib import admin +from django.contrib.auth.models import User +from django.utils import importlib +from manager import HistoryDescriptor + + +class HistoricalRecords(object): + def contribute_to_class(self, cls, name): + self.manager_name = name + self.module = cls.__module__ + models.signals.class_prepared.connect(self.finalize, sender=cls) + + def save_without_historical_record(self, *args, **kwargs): + """Caution! Make sure you know what you're doing before you use this method.""" + self.skip_history_when_saving = True + ret = self.save(*args, **kwargs) + del self.skip_history_when_saving + return ret + setattr(cls, 'save_without_historical_record', save_without_historical_record) + + def finalize(self, sender, **kwargs): + history_model = self.create_history_model(sender) + module = importlib.import_module(self.module) + setattr(module, history_model.__name__, history_model) + + # The HistoricalRecords object will be discarded, + # so the signal handlers can't use weak references. + models.signals.post_save.connect(self.post_save, sender=sender, + weak=False) + models.signals.post_delete.connect(self.post_delete, sender=sender, + weak=False) + + descriptor = HistoryDescriptor(history_model) + setattr(sender, self.manager_name, descriptor) + sender._meta.simple_history_manager_attribute = self.manager_name + + def create_history_model(self, model): + """ + Creates a historical model to associate with the model provided. + """ + attrs = {'__module__': self.module} + + fields = self.copy_fields(model) + attrs.update(fields) + attrs.update(self.get_extra_fields(model, fields)) + attrs.update(Meta=type('Meta', (), self.get_meta_options(model))) + name = 'Historical%s' % model._meta.object_name + return type(name, (models.Model,), attrs) + + def copy_fields(self, model): + """ + Creates copies of the model's original fields, returning + a dictionary mapping field name to copied field object. + """ + fields = {} + + for field in model._meta.fields: + field = copy.copy(field) + fk = None + + if isinstance(field, models.AutoField): + # The historical model gets its own AutoField, so any + # existing one must be replaced with an IntegerField. + field.__class__ = models.IntegerField + + if isinstance(field, models.ForeignKey): + field.__class__ = models.IntegerField + #ughhhh. open to suggestions here + try: + field.rel = None + except: + pass + try: + field.related = None + except: + pass + try: + field.related_query_name = None + except: + pass + field.null = True + field.blank = True + fk = True + else: + fk = False + + # The historical instance should not change creation/modification timestamps. + field.auto_now = False + field.auto_now_add = False + + if field.primary_key or field.unique: + # Unique fields can no longer be guaranteed unique, + # but they should still be indexed for faster lookups. + field.primary_key = False + field._unique = False + field.db_index = True + field.serialize = True + if fk: + field.name = field.name + "_id" + fields[field.name] = field + + return fields + + def get_extra_fields(self, model, fields): + """ + Returns a dictionary of fields that will be added to the historical + record model, in addition to the ones returned by copy_fields below. + """ + @models.permalink + def revert_url(self): + opts = model._meta + return ('%s:%s_%s_simple_history' % + (admin.site.name, opts.app_label, opts.module_name), + [getattr(self, opts.pk.attname), self.history_id]) + def get_instance(self): + return model(**dict([(k, getattr(self, k)) for k in fields])) + + return { + 'history_id': models.AutoField(primary_key=True), + 'history_date': models.DateTimeField(auto_now_add=True), + 'history_user': models.ForeignKey(User, null=True), + 'history_type': models.CharField(max_length=1, choices=( + ('+', 'Created'), + ('~', 'Changed'), + ('-', 'Deleted'), + )), + 'history_object': HistoricalObjectDescriptor(model), + 'instance': property(get_instance), + 'revert_url': revert_url, + '__unicode__': lambda self: u'%s as of %s' % (self.history_object, + self.history_date) + } + + def get_meta_options(self, model): + """ + Returns a dictionary of fields that will be added to + the Meta inner class of the historical record model. + """ + return { + 'ordering': ('-history_date', '-history_id'), + } + + def post_save(self, instance, created, **kwargs): + if not created and hasattr(instance, 'skip_history_when_saving'): + return + if not kwargs.get('raw', False): + self.create_historical_record(instance, created and '+' or '~') + + def post_delete(self, instance, **kwargs): + self.create_historical_record(instance, '-') + + def create_historical_record(self, instance, type): + history_user = getattr(instance, '_history_user', None) + manager = getattr(instance, self.manager_name) + attrs = {} + for field in instance._meta.fields: + attrs[field.attname] = getattr(instance, field.attname) + manager.create(history_type=type, history_user=history_user, **attrs) + + +class HistoricalObjectDescriptor(object): + def __init__(self, model): + self.model = model + + def __get__(self, instance, owner): + values = (getattr(instance, f.attname) for f in self.model._meta.fields) + return self.model(*values) diff --git a/django-simple-history/simple_history/templates/simple_history/object_history.html b/django-simple-history/simple_history/templates/simple_history/object_history.html new file mode 100644 index 000000000..d14338232 --- /dev/null +++ b/django-simple-history/simple_history/templates/simple_history/object_history.html @@ -0,0 +1,38 @@ +{% extends "admin/object_history.html" %} +{% load i18n %} + + +{% block content %} + <div id="content-main"> + + <p>{% blocktrans %}Choose a date from the list below to revert to a previous version of this object.{% endblocktrans %}</p> + + <div class="module"> + {% if action_list %} + <table id="change-history"> + <thead> + <tr> + <th scope="col">{% trans 'Object' %}</th> + <th scope="col">{% trans 'Date/time' %}</th> + <th scope="col">{% trans 'Comment' %}</th> + <th scope="col">{% trans 'Changed by' %}</th> + </tr> + </thead> + <tbody> + {% for action in action_list %} + <tr> + <td><a href="{{ action.revert_url }}">{{ action.history_object }}</a></td> + <td>{{ action.history_date }}</td> + <td>{{ action.get_history_type_display }}</td> + <td><a href="{% url admin:auth_user_change action.history_user_id %}">{{ action.history_user }}</a></td> + </tr> + {% endfor %} + </tbody> + </table> + {% else %} + <p>{% trans "This object doesn't have a change history." %}</p> + {% endif %} + </div> + </div> +{% endblock %} + diff --git a/django-simple-history/simple_history/templates/simple_history/object_history_form.html b/django-simple-history/simple_history/templates/simple_history/object_history_form.html new file mode 100644 index 000000000..fdc8f1a87 --- /dev/null +++ b/django-simple-history/simple_history/templates/simple_history/object_history_form.html @@ -0,0 +1,24 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} + +{% block breadcrumbs %} + <div class="breadcrumbs"> + <a href="{% url admin:index %}">{% trans "Home" %}</a> › + <a href="{% url admin:app_list app_label %}">{{app_label|capfirst|escape}}</a> › + <a href="{{changelist_url}}">{{opts.verbose_name_plural|capfirst}}</a> › + <a href="{{change_url}}">{{original|truncatewords:"18"}}</a> › + <a href="../">{% trans "History" %}</a> › + {% blocktrans with original_opts.verbose_name as verbose_name %}Revert {{verbose_name}}{% endblocktrans %} + </div> +{% endblock %} + +{% comment %}Hack to remove "Save as New" and "Save and Continue" buttons {% endcomment %} +{% block content %} + {% with 1 as is_popup %} + {{block.super}} + {% endwith %} +{% endblock %} + +{% block form_top %} + <p>{% blocktrans %}Press the save button below to revert to this version of the object.{% endblocktrans %}</p> +{% endblock %} |