diff --git a/apps/documents/__init__.py b/apps/documents/__init__.py index 93057e7647..36bea18212 100644 --- a/apps/documents/__init__.py +++ b/apps/documents/__init__.py @@ -7,6 +7,7 @@ from navigation.api import register_links, register_menu, \ from main.api import register_diagnostic, register_tool from permissions.api import register_permission, set_namespace_title from tags.widgets import get_tags_inline_widget_simple +from history.api import register_history_type from documents.models import Document, DocumentPage, DocumentPageTransformation from documents.staging import StagingFile @@ -17,7 +18,10 @@ from documents.literals import PERMISSION_DOCUMENT_CREATE, \ PERMISSION_DOCUMENT_DELETE, PERMISSION_DOCUMENT_DOWNLOAD, \ PERMISSION_DOCUMENT_TRANSFORM, PERMISSION_DOCUMENT_TOOLS, \ PERMISSION_DOCUMENT_EDIT +from documents.literals import HISTORY_DOCUMENT_CREATED, \ + HISTORY_DOCUMENT_EDITED +# Permission setup set_namespace_title('documents', _(u'documents')) register_permission(PERMISSION_DOCUMENT_CREATE) register_permission(PERMISSION_DOCUMENT_PROPERTIES_EDIT) @@ -28,6 +32,10 @@ register_permission(PERMISSION_DOCUMENT_DOWNLOAD) register_permission(PERMISSION_DOCUMENT_TRANSFORM) register_permission(PERMISSION_DOCUMENT_TOOLS) +# History setup +register_history_type(HISTORY_DOCUMENT_CREATED) +register_history_type(HISTORY_DOCUMENT_EDITED) + document_list = {'text': _(u'documents list'), 'view': 'document_list', 'famfam': 'page', 'permissions': [PERMISSION_DOCUMENT_VIEW]} document_list_recent = {'text': _(u'recent documents list'), 'view': 'document_list_recent', 'famfam': 'page', 'permissions': [PERMISSION_DOCUMENT_VIEW]} document_create = {'text': _(u'upload a new document'), 'view': 'document_create', 'famfam': 'page_add', 'permissions': [PERMISSION_DOCUMENT_CREATE]} @@ -97,7 +105,6 @@ register_links(['document_page_view'], [document_page_rotate_left, document_page # Upload sources register_links(['upload_document_from_local', 'upload_document_from_staging', 'upload_document_from_user_staging'], [upload_document_from_local, upload_document_from_staging, upload_document_from_user_staging], menu_name='form_header') - register_links(DocumentPageTransformation, [document_page_transformation_edit, document_page_transformation_delete]) register_links(DocumentPageTransformation, [document_page_transformation_page_edit, document_page_transformation_page_view], menu_name='sidebar') register_links('document_page_transformation_list', [document_page_transformation_create], menu_name='sidebar') diff --git a/apps/documents/literals.py b/apps/documents/literals.py index 075b35dadc..8e34bf1c54 100644 --- a/apps/documents/literals.py +++ b/apps/documents/literals.py @@ -17,3 +17,19 @@ PERMISSION_DOCUMENT_TOOLS = {'namespace': 'documents', 'name': 'document_tools', UPLOAD_SOURCE_LOCAL = u'local' UPLOAD_SOURCE_STAGING = u'staging' UPLOAD_SOURCE_USER_STAGING = u'user_staging' + +HISTORY_DOCUMENT_CREATED = { + 'namespace': 'documents', 'name': 'document_created', + 'label': _(u'Document creation'), + 'summary': _(u'Document: %(content_object)s created by %(fullname)s.'), + 'details': _(u'Document: %(content_object)s created on %(datetime)s by %(fullname)s.'), + 'expressions': [{'fullname': 'user.get_full_name() if user.get_full_name() else user.username'}] +} + +HISTORY_DOCUMENT_EDITED = { + 'namespace': 'documents', 'name': 'document_edited', + 'label': _(u'Document edited'), + 'summary': _(u'Document: %(content_object)s edited by %(fullname)s.'), + 'details': _(u'Document: %(content_object)s edited on %(datetime)s by %(fullname)s.'), + 'expressions': [{'fullname': 'user.get_full_name() if user.get_full_name() else user.username'}] +} diff --git a/apps/documents/models.py b/apps/documents/models.py index 3d3b97fd3d..63369ead6f 100644 --- a/apps/documents/models.py +++ b/apps/documents/models.py @@ -63,7 +63,7 @@ class Document(models.Model): description = models.TextField(blank=True, null=True, verbose_name=_(u'description'), db_index=True) tags = TaggableManager() - + comments = generic.GenericRelation( Comment, content_type_field='content_type', diff --git a/apps/documents/views.py b/apps/documents/views.py index 486d53819e..c54eea9c55 100644 --- a/apps/documents/views.py +++ b/apps/documents/views.py @@ -35,6 +35,7 @@ from permissions.api import check_permissions from tags.utils import get_tags_subtemplate from document_indexing.utils import get_document_indexing_subtemplate from document_indexing.api import update_indexes, delete_indexes +from history.api import create_history from documents.conf.settings import DELETE_STAGING_FILE_AFTER_UPLOAD from documents.conf.settings import USE_STAGING_DIRECTORY @@ -57,6 +58,8 @@ from documents.literals import PERMISSION_DOCUMENT_CREATE, \ PERMISSION_DOCUMENT_DELETE, PERMISSION_DOCUMENT_DOWNLOAD, \ PERMISSION_DOCUMENT_TRANSFORM, \ PERMISSION_DOCUMENT_EDIT +from documents.literals import HISTORY_DOCUMENT_CREATED, \ + HISTORY_DOCUMENT_EDITED from documents.forms import DocumentTypeSelectForm, \ DocumentForm, DocumentForm_edit, DocumentPropertiesForm, \ @@ -132,6 +135,8 @@ def _handle_save_document(request, document, form=None): for warning in warnings: messages.warning(request, warning) + create_history(HISTORY_DOCUMENT_CREATED, document, {'user': request.user}) + def _handle_zip_file(request, uploaded_file, document_type=None): filename = getattr(uploaded_file, 'filename', getattr(uploaded_file, 'name', '')) @@ -273,7 +278,7 @@ def document_view_simple(request, document_id): # Triggers a 404 error on documents uploaded via local upload # TODO: investigate document = get_object_or_404(Document, pk=document_id) - + RecentDocument.objects.add_document_for_user(request.user, document) subtemplates_list = [] @@ -491,8 +496,6 @@ def document_edit(request, document_id): document = get_object_or_404(Document, pk=document_id) - RecentDocument.objects.add_document_for_user(request.user, document) - if request.method == 'POST': form = DocumentForm_edit(request.POST, initial={'document_type': document.document_type}) if form.is_valid(): @@ -510,6 +513,9 @@ def document_edit(request, document_id): document.save() + create_history(HISTORY_DOCUMENT_EDITED, document, {'user': request.user}) + RecentDocument.objects.add_document_for_user(request.user, document) + messages.success(request, _(u'Document %s edited successfully.') % document) warnings = update_indexes(document) diff --git a/apps/history/__init__.py b/apps/history/__init__.py new file mode 100644 index 0000000000..06419c4469 --- /dev/null +++ b/apps/history/__init__.py @@ -0,0 +1,19 @@ +from django.utils.translation import ugettext_lazy as _ + +from navigation.api import register_links, register_menu +from permissions.api import register_permission, set_namespace_title + + +PERMISSION_HISTORY_VIEW = {'namespace': 'history', 'name': u'history_view', 'label': _(u'Access the history app')} + +set_namespace_title('history', _(u'history')) +register_permission(PERMISSION_HISTORY_VIEW) + +# TODO: support permissions AND operand +history_list = {'text': _(u'history'), 'view': 'history_list', 'famfam': 'book', 'permissions': [PERMISSION_HISTORY_VIEW]} + +register_links(['history_list'], [history_list], menu_name='sidebar') + +register_menu([ + {'text': _(u'history'), 'view': 'history_list', 'links': [ + ], 'famfam': 'book', 'position': 3}]) diff --git a/apps/history/api.py b/apps/history/api.py new file mode 100644 index 0000000000..f2e6ab57f6 --- /dev/null +++ b/apps/history/api.py @@ -0,0 +1,57 @@ +import pickle +import json + +from django.db.utils import DatabaseError +#from django.utils import simplejson +from django.core import serializers +from django.utils.translation import ugettext +from django.utils.translation import ugettext_lazy as _ +from django.shortcuts import get_object_or_404 +from django.db import models + +from history.models import HistoryType, History +from history.runtime_data import history_types_dict + + +def register_history_type(history_type_dict): + namespace = history_type_dict['namespace'] + name = history_type_dict['name'] + try: + # Permanent + history_type_obj, created = HistoryType.objects.get_or_create( + namespace=namespace, name=name) + history_type_obj.save() + + # Runtime + history_types_dict.setdefault(namespace, {}) + history_types_dict[namespace][name] = { + 'label': history_type_dict['label'], + 'summary': history_type_dict.get('summary', u''), + 'details': history_type_dict.get('details', u''), + 'expressions': history_type_dict.get('expressions', []), + } + except DatabaseError: + #Special case for ./manage.py syncdb + pass + + +def create_history(history_type_dict, source_object=None, data=None): + history_type = get_object_or_404(HistoryType, namespace=history_type_dict['namespace'], name=history_type_dict['name']) + new_history = History(history_type=history_type) + if source_object: + new_history.content_object = source_object + if data: + new_dict = {} + for key, value in data.items(): + new_dict[key] = {} + if isinstance(value, models.Model): + new_dict[key]['value'] = serializers.serialize('json', [value]) + elif isinstance(value, models.query.QuerySet): + new_dict[key]['value'] = serializers.serialize('json', value) + else: + new_dict[key]['value'] = json.dumps(value) + new_dict[key]['type'] = pickle.dumps(type(value)) + + new_history.dictionary = json.dumps(new_dict) + + new_history.save() diff --git a/apps/history/forms.py b/apps/history/forms.py new file mode 100644 index 0000000000..76e8dec93c --- /dev/null +++ b/apps/history/forms.py @@ -0,0 +1,11 @@ +from django import forms + +from common.forms import DetailForm + +from history.models import History + + +class HistoryDetailForm(DetailForm): + class Meta: + model = History + exclude = ('datetime', 'content_type', 'object_id', 'history_type', 'dictionary') diff --git a/apps/history/managers.py b/apps/history/managers.py new file mode 100644 index 0000000000..7975c06a6a --- /dev/null +++ b/apps/history/managers.py @@ -0,0 +1,10 @@ +from django.db import models +from django.contrib.contenttypes.models import ContentType +from django.core.urlresolvers import reverse +#from django.shortcuts import get_object_or_404 + + +class ObjectHistoryManager(models.Manager): + def get_url_for_object(self): + ct = ContentType.objects.get_for_model(self.instance) + return reverse('history_for_object', args=[ct, self.instance.pk]) diff --git a/apps/history/models.py b/apps/history/models.py new file mode 100644 index 0000000000..9a2cdffc9d --- /dev/null +++ b/apps/history/models.py @@ -0,0 +1,112 @@ +import json +import pickle +from datetime import datetime + +from django.db import models +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes import generic +from django.utils.translation import ugettext_lazy as _ +from django.core import serializers + +#from history.managers import HistoryManager +from history.runtime_data import history_types_dict + + +class HistoryType(models.Model): + namespace = models.CharField(max_length=64, verbose_name=_(u'namespace')) + name = models.CharField(max_length=64, verbose_name=_(u'name')) + + def __unicode__(self): + return u'%s - %s' % (self.namespace, self.name) + + class Meta: + ordering = ('namespace', 'name') + unique_together = ('namespace', 'name') + verbose_name = _(u'history type') + verbose_name_plural = _(u'history types') + + +class History(models.Model): + datetime = models.DateTimeField(verbose_name=_(u'date time')) + content_type = models.ForeignKey(ContentType, blank=True, null=True) + object_id = models.PositiveIntegerField(blank=True, null=True) + content_object = generic.GenericForeignKey('content_type', 'object_id') + history_type = models.ForeignKey(HistoryType, verbose_name=_(u'history type')) + dictionary = models.TextField(verbose_name=_(u'dictionary'), blank=True) + + #objects = HistoryManager() + + def __unicode__(self): + return u'%s - %s - %s' % (self.datetime, self.content_object, self.history_type) + + def save(self, *args, **kwargs): + if not self.pk: + self.datetime = datetime.now() + super(History, self).save(*args, **kwargs) + + def get_label(self): + return history_types_dict[self.history_type.namespace][self.history_type.name]['label'] + + def get_summary(self): + return history_types_dict[self.history_type.namespace][self.history_type.name].get('summary', u'') + + def get_details(self): + return history_types_dict[self.history_type.namespace][self.history_type.name].get('details', u'') + + def get_expressions(self): + return history_types_dict[self.history_type.namespace][self.history_type.name].get('expressions', []) + + def get_processed_summary(self): + return _process_history_text(self, self.get_summary()) + + def get_processed_details(self): + return _process_history_text(self, self.get_details()) + + @models.permalink + def get_absolute_url(self): + return ('history_view', [self.pk]) + + class Meta: + ordering = ('-datetime',) + verbose_name = _(u'history') + verbose_name_plural = _(u'histories') + + +def _process_history_text(history, text): + key_values = { + 'content_object': history.content_object, + 'datetime': history.datetime + } + + loaded_dictionary = json.loads(history.dictionary) + + new_dict = {} + for key, values in loaded_dictionary.items(): + value_type = pickle.loads(str(values['type'])) + if isinstance(value_type, models.base.ModelBase): + for deserialized in serializers.deserialize('json', values['value']): + new_dict[key] = deserialized.object + elif isinstance(value_type, models.query.QuerySet): + qs = [] + for deserialized in serializers.deserialize('json', values['value']): + qs.append(deserialized.object) + new_dict[key] = qs + else: + new_dict[key] = json.loads(values['value']) + + key_values.update(new_dict) + + expressions_dict = {} + for expression_item in history.get_expressions(): + key, value = expression_item.items()[0] + try: + expressions_dict = { + key: eval(value, key_values.copy()) + } + except Exception, e: + expressions_dict = { + key: e + } + + key_values.update(expressions_dict) + return text % key_values diff --git a/apps/history/runtime_data.py b/apps/history/runtime_data.py new file mode 100644 index 0000000000..a8acc92e5d --- /dev/null +++ b/apps/history/runtime_data.py @@ -0,0 +1 @@ +history_types_dict = {} diff --git a/apps/history/tests.py b/apps/history/tests.py new file mode 100644 index 0000000000..2247054b35 --- /dev/null +++ b/apps/history/tests.py @@ -0,0 +1,23 @@ +""" +This file demonstrates two different styles of tests (one doctest and one +unittest). These will both pass when you run "manage.py test". + +Replace these with more appropriate tests for your application. +""" + +from django.test import TestCase + +class SimpleTest(TestCase): + def test_basic_addition(self): + """ + Tests that 1 + 1 always equals 2. + """ + self.failUnlessEqual(1 + 1, 2) + +__test__ = {"doctest": """ +Another way to test that 1 + 1 is equal to 2. + +>>> 1 + 1 == 2 +True +"""} + diff --git a/apps/history/urls.py b/apps/history/urls.py new file mode 100644 index 0000000000..e67fbcbb57 --- /dev/null +++ b/apps/history/urls.py @@ -0,0 +1,7 @@ +from django.conf.urls.defaults import patterns, url + +urlpatterns = patterns('history.views', + url(r'^list/$', 'history_list', (), 'history_list'), + url(r'^list/for_object/(?P\d+)/(?P\d+)/$', 'history_for_object', (), 'history_for_object'), + url(r'^(?P\d+)/$', 'history_view', (), 'history_view'), +) diff --git a/apps/history/views.py b/apps/history/views.py new file mode 100644 index 0000000000..f31c7b4dae --- /dev/null +++ b/apps/history/views.py @@ -0,0 +1,93 @@ +from django.shortcuts import render_to_response +from django.template import RequestContext +from django.utils.translation import ugettext_lazy as _ +from django.shortcuts import get_object_or_404 +from django.contrib import messages +from django.http import HttpResponseRedirect +from django.core.urlresolvers import reverse +from django.utils.http import urlencode +from django.contrib.contenttypes.models import ContentType + +from permissions.api import check_permissions + +from history.models import History +from history.api import history_types_dict +from history.forms import HistoryDetailForm +from history import PERMISSION_HISTORY_VIEW + + +def history_list(request): + check_permissions(request.user, [PERMISSION_HISTORY_VIEW]) + + context = { + 'object_list': History.objects.all(), + 'title': _(u'history events'), + 'extra_columns': [ + { + 'name': _(u'date and time'), + 'attribute': 'datetime' + }, + { + 'name': _(u'summary'), + 'attribute': lambda x: '%(label)s' % { + 'url': x.get_absolute_url(), + 'label': unicode(x.get_processed_summary()) + } + } + ], + 'hide_object': True, + } + + return render_to_response('generic_list.html', context, + context_instance=RequestContext(request)) + + +def history_for_object(request, content_type_id, object_id): + check_permissions(request.user, [PERMISSION_HISTORY_VIEW]) + + content_type = get_object_or_404(ContentType, pk=content_type_id) + content_object = get_object_or_404(content_type.model_class(), pk=object_id) + + context = { + 'object_list': History.objects.filter(content_type=content_type_id, object_id=object_id), + 'title': _(u'history for: %s') % content_object, + 'extra_columns': [ + { + 'name': _(u'date and time'), + 'attribute': 'datetime' + }, + { + 'name': _(u'summary'), + 'attribute': lambda x: '%(label)s' % { + 'url': x.get_absolute_url(), + 'label': unicode(x.get_processed_summary()) + } + } + ], + 'hide_object': True, + } + + return render_to_response('generic_list.html', context, + context_instance=RequestContext(request)) + + +def history_view(request, object_id): + check_permissions(request.user, [PERMISSION_HISTORY_VIEW]) + + history = get_object_or_404(History, pk=object_id) + + + form = HistoryDetailForm(instance=history, extra_fields=[ + {'label': _(u'Date'), 'field':lambda x: x.datetime.date()}, + {'label': _(u'Time'), 'field':lambda x: unicode(x.datetime.time()).split('.')[0]}, + {'label': _(u'Object'), 'field': 'content_object'}, + {'label': _(u'Event type'), 'field': lambda x: x.get_label()}, + {'label': _(u'Event details'), 'field': lambda x: x.get_processed_details()}, + ]) + + return render_to_response('generic_detail.html', { + 'title': _(u'details for: %s') % history.get_processed_summary(), + 'form': form, + 'object': history, + }, + context_instance=RequestContext(request)) diff --git a/settings.py b/settings.py index dc79404274..0ab77165f7 100644 --- a/settings.py +++ b/settings.py @@ -122,6 +122,7 @@ INSTALLED_APPS = ( 'django.contrib.comments', 'smart_settings', 'navigation', + 'history', 'web_theme', 'main', 'common', diff --git a/urls.py b/urls.py index c94d915c99..179f385693 100644 --- a/urls.py +++ b/urls.py @@ -23,6 +23,7 @@ urlpatterns = patterns('', (r'^metadata/', include('metadata.urls')), (r'^grouping/', include('grouping.urls')), (r'^document_indexing/', include('document_indexing.urls')), + (r'^history/', include('history.urls')), )