diff --git a/apps/acls/managers.py b/apps/acls/managers.py index 6ba807a897..529643c7ec 100644 --- a/apps/acls/managers.py +++ b/apps/acls/managers.py @@ -137,14 +137,14 @@ class AccessEntryManager(models.Manager): content_type = ContentType.objects.get_for_model(cls) # Calculate actor role membership ACL query - total_queries = None + total_queries = Q() for role in RoleMember.objects.get_roles_for_member(actor): role_type = ContentType.objects.get_for_model(role) if related: query = Q(holder_type=role_type, holder_id=role.pk, permission=permission.get_stored_permission) else: query = Q(holder_type=role_type, holder_id=role.pk, content_type=content_type, permission=permission.get_stored_permission) - if total_queries is None: + if not total_queries: total_queries = query else: total_queries = total_queries | query @@ -161,7 +161,7 @@ class AccessEntryManager(models.Manager): query = Q(holder_type=group_type, holder_id=group.pk, permission=permission.get_stored_permission) else: query = Q(holder_type=group_type, holder_id=group.pk, content_type=content_type, permission=permission.get_stored_permission) - if total_queries is None: + if not total_queries: total_queries = query else: total_queries = total_queries | query diff --git a/apps/checkouts/__init__.py b/apps/checkouts/__init__.py new file mode 100644 index 0000000000..2ad1a215b6 --- /dev/null +++ b/apps/checkouts/__init__.py @@ -0,0 +1,47 @@ +from __future__ import absolute_import + +from django.utils.translation import ugettext_lazy as _ + +from navigation.api import (register_links, register_top_menu, + register_multi_item_links, register_sidebar_template) +from scheduler.api import register_interval_job + +from documents.models import Document +from documents.permissions import PERMISSION_DOCUMENT_VIEW +from acls.api import class_permissions +from history.api import register_history_type + +from .permissions import (PERMISSION_DOCUMENT_CHECKOUT, + PERMISSION_DOCUMENT_CHECKIN, PERMISSION_DOCUMENT_CHECKIN_OVERRIDE, + PERMISSION_DOCUMENT_RESTRICTIONS_OVERRIDE) +from .links import checkout_list, checkout_document, checkout_info, checkin_document +from .models import DocumentCheckout +from .tasks import task_check_expired_check_outs +from .events import HISTORY_DOCUMENT_CHECKED_OUT, HISTORY_DOCUMENT_CHECKED_IN + + +def initialize_document_checkout_extra_methods(): + Document.add_to_class('is_checked_out', lambda document: DocumentCheckout.objects.is_document_checked_out(document)) + Document.add_to_class('check_in', lambda document, user=None: DocumentCheckout.objects.check_in_document(document, user)) + Document.add_to_class('checkout_info', lambda document: DocumentCheckout.objects.document_checkout_info(document)) + Document.add_to_class('checkout_state', lambda document: DocumentCheckout.objects.document_checkout_state(document)) + Document.add_to_class('is_new_versions_allowed', lambda document, user=None: DocumentCheckout.objects.is_document_new_versions_allowed(document, user)) + +register_top_menu(name='checkouts', link=checkout_list) +register_links(Document, [checkout_info], menu_name='form_header') +register_links(['checkout_info', 'checkout_document', 'checkin_document'], [checkout_document, checkin_document], menu_name="sidebar") + +class_permissions(Document, [ + PERMISSION_DOCUMENT_CHECKOUT, + PERMISSION_DOCUMENT_CHECKIN, + PERMISSION_DOCUMENT_CHECKIN_OVERRIDE, + PERMISSION_DOCUMENT_RESTRICTIONS_OVERRIDE +]) + +CHECK_EXPIRED_CHECK_OUTS_INTERVAL=60 # Lowest check out expiration allowed +register_interval_job('task_check_expired_check_outs', _(u'Check expired check out documents and checks them in.'), task_check_expired_check_outs, seconds=CHECK_EXPIRED_CHECK_OUTS_INTERVAL) +initialize_document_checkout_extra_methods() +register_history_type(HISTORY_DOCUMENT_CHECKED_OUT) +register_history_type(HISTORY_DOCUMENT_CHECKED_IN) + +#TODO: forcefull check in diff --git a/apps/checkouts/events.py b/apps/checkouts/events.py new file mode 100644 index 0000000000..6d91d8d4ea --- /dev/null +++ b/apps/checkouts/events.py @@ -0,0 +1,15 @@ +from django.utils.translation import ugettext_lazy as _ + +HISTORY_DOCUMENT_CHECKED_OUT = { + 'namespace': 'checkouts', 'name': 'document_checked_out', + 'label': _(u'Document checked out'), + 'summary': _(u'Document "%(document)s" checked out by %(fullname)s.'), + 'expressions': {'fullname': 'user.get_full_name() if user.get_full_name() else user'} +} + +HISTORY_DOCUMENT_CHECKED_IN = { + 'namespace': 'checkouts', 'name': 'document_checked_in', + 'label': _(u'Document checked in'), + 'summary': _(u'Document "%(document)s" checked in by %(fullname)s.'), + 'expressions': {'fullname': 'user.get_full_name() if user.get_full_name() else user'} +} diff --git a/apps/checkouts/exceptions.py b/apps/checkouts/exceptions.py new file mode 100644 index 0000000000..e27b9645de --- /dev/null +++ b/apps/checkouts/exceptions.py @@ -0,0 +1,11 @@ +class DocumentNotCheckedOut(Exception): + """ + Raised when trying to checkin a document that is not checkedout + """ + pass + +class DocumentAlreadyCheckedOut(Exception): + """ + Raised when trying to checkout an already checkedout document + """ + pass diff --git a/apps/checkouts/forms.py b/apps/checkouts/forms.py new file mode 100644 index 0000000000..80fb3091f9 --- /dev/null +++ b/apps/checkouts/forms.py @@ -0,0 +1,26 @@ +from __future__ import absolute_import + +from django import forms +from django.utils.translation import ugettext_lazy as _ + +from .models import DocumentCheckout +from .exceptions import DocumentAlreadyCheckedOut +from .widgets import SplitTimeDeltaField + + +class DocumentCheckoutForm(forms.ModelForm): + expiration_datetime = SplitTimeDeltaField() + + class Meta: + model = DocumentCheckout + exclude = ('checkout_datetime', 'user_content_type', 'user_object_id') + + widgets = { + 'document': forms.widgets.HiddenInput(), + } + + def clean_document(self): + document = self.cleaned_data['document'] + if document.is_checked_out(): + raise DocumentAlreadyCheckedOut + return document diff --git a/apps/checkouts/links.py b/apps/checkouts/links.py new file mode 100644 index 0000000000..5583dfe5b3 --- /dev/null +++ b/apps/checkouts/links.py @@ -0,0 +1,21 @@ +from __future__ import absolute_import + +from django.utils.translation import ugettext_lazy as _ + +from documents.permissions import PERMISSION_DOCUMENT_VIEW + +from .permissions import (PERMISSION_DOCUMENT_CHECKOUT, PERMISSION_DOCUMENT_CHECKIN, PERMISSION_DOCUMENT_CHECKIN_OVERRIDE) + + +def is_checked_out(context): + return context['object'].is_checked_out() + + +def is_not_checked_out(context): + return not context['object'].is_checked_out() + + +checkout_list = {'text': _(u'checkouts'), 'view': 'checkout_list', 'famfam': 'basket'} +checkout_document = {'text': _('check out document'), 'view': 'checkout_document', 'args': 'object.pk', 'famfam': 'basket_put', 'condition': is_not_checked_out, 'permissions': [PERMISSION_DOCUMENT_CHECKOUT]} +checkin_document = {'text': _('check in document'), 'view': 'checkin_document', 'args': 'object.pk', 'famfam': 'basket_remove', 'condition': is_checked_out, 'permissions': [PERMISSION_DOCUMENT_CHECKIN, PERMISSION_DOCUMENT_CHECKIN_OVERRIDE]} +checkout_info = {'text': _('check in/out'), 'view': 'checkout_info', 'args': 'object.pk', 'famfam': 'basket', 'children_views': ['checkout_document', 'checkin_document'], 'permissions': [PERMISSION_DOCUMENT_CHECKIN, PERMISSION_DOCUMENT_CHECKIN_OVERRIDE, PERMISSION_DOCUMENT_CHECKOUT]} diff --git a/apps/checkouts/literals.py b/apps/checkouts/literals.py new file mode 100644 index 0000000000..23e9920984 --- /dev/null +++ b/apps/checkouts/literals.py @@ -0,0 +1,16 @@ +from __future__ import absolute_import + +from django.utils.translation import ugettext_lazy as _ + +STATE_CHECKED_OUT = 'checkedout' +STATE_CHECKED_IN = 'checkedin' + +STATE_ICONS = { + STATE_CHECKED_OUT: 'basket_put.png', + STATE_CHECKED_IN: 'traffic_lights_green.png', +} + +STATE_LABELS = { + STATE_CHECKED_OUT: _(u'checked out'), + STATE_CHECKED_IN: _(u'checked in/available'), +} diff --git a/apps/checkouts/managers.py b/apps/checkouts/managers.py new file mode 100644 index 0000000000..c1185651ef --- /dev/null +++ b/apps/checkouts/managers.py @@ -0,0 +1,93 @@ +from __future__ import absolute_import + +import datetime +import logging + +from django.db import models +from django.core.exceptions import PermissionDenied + +from documents.models import Document +from history.api import create_history +from permissions.models import Permission +from acls.models import AccessEntry + +from .exceptions import DocumentNotCheckedOut +from .literals import STATE_CHECKED_OUT, STATE_CHECKED_IN +from .events import HISTORY_DOCUMENT_CHECKED_IN +from .permissions import PERMISSION_DOCUMENT_RESTRICTIONS_OVERRIDE + +logger = logging.getLogger(__name__) + + +class DocumentCheckoutManager(models.Manager): + def checked_out_documents(self): + return Document.objects.filter(pk__in=self.model.objects.all().values_list('document__pk', flat=True)) + + def expired_check_outs(self): + expired_list = Document.objects.filter(pk__in=self.model.objects.filter(expiration_datetime__lte=datetime.datetime.now()).values_list('document__pk', flat=True)) + logger.debug('expired_list: %s' % expired_list) + return expired_list + + def check_in_expired_check_outs(self): + for document in self.expired_check_outs(): + document.check_in() + + def is_document_checked_out(self, document): + if self.model.objects.filter(document=document): + return True + else: + return False + + def check_in_document(self, document, user=None): + try: + document_checkout = self.model.objects.get(document=document) + except self.model.DoesNotExist: + raise DocumentNotCheckedOut + else: + if user: + create_history(HISTORY_DOCUMENT_CHECKED_IN, source_object=document, data={'user': user, 'document': document}) + document_checkout.delete() + + def document_checkout_info(self, document): + try: + return self.model.objects.get(document=document) + except self.model.DoesNotExist: + raise DocumentNotCheckedOut + + def document_checkout_state(self, document): + if self.is_document_checked_out(document): + return STATE_CHECKED_OUT + else: + return STATE_CHECKED_IN + + def is_document_new_versions_allowed(self, document, user=None): + try: + checkout_info = self.document_checkout_info(document) + except DocumentNotCheckedOut: + return True + else: + if not user: + return not checkout_info.block_new_version + else: + if user.is_staff or user.is_superuser: + # Allow anything to superusers and staff + return True + + if user == checkout_info.user_object: + # Allow anything to the user who checked out this document + True + else: + # If not original user check to see if user has global or this document's PERMISSION_DOCUMENT_RESTRICTIONS_OVERRIDE permission + try: + Permission.objects.check_permissions(user, [PERMISSION_DOCUMENT_RESTRICTIONS_OVERRIDE]) + except PermissionDenied: + try: + AccessEntry.objects.check_accesses([PERMISSION_DOCUMENT_RESTRICTIONS_OVERRIDE], user, document) + except PermissionDenied: + # Last resort check if original user enabled restriction + return not checkout_info.block_new_version + else: + return True + else: + return True + diff --git a/apps/checkouts/migrations/0001_initial.py b/apps/checkouts/migrations/0001_initial.py new file mode 100644 index 0000000000..85ee9306e2 --- /dev/null +++ b/apps/checkouts/migrations/0001_initial.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'DocumentCheckout' + db.create_table('checkouts_documentcheckout', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('document', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['documents.Document'], unique=True)), + ('checkout_datetime', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2012, 6, 13, 0, 0))), + ('expiration_datetime', self.gf('django.db.models.fields.DateTimeField')()), + ('block_new_version', self.gf('django.db.models.fields.BooleanField')(default=False)), + )) + db.send_create_signal('checkouts', ['DocumentCheckout']) + + + def backwards(self, orm): + # Deleting model 'DocumentCheckout' + db.delete_table('checkouts_documentcheckout') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'checkouts.documentcheckout': { + 'Meta': {'object_name': 'DocumentCheckout'}, + 'block_new_version': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'checkout_datetime': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 6, 13, 0, 0)'}), + 'document': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['documents.Document']", 'unique': 'True'}), + 'expiration_datetime': ('django.db.models.fields.DateTimeField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'comments.comment': { + 'Meta': {'ordering': "('submit_date',)", 'object_name': 'Comment', 'db_table': "'django_comments'"}, + 'comment': ('django.db.models.fields.TextField', [], {'max_length': '3000'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'content_type_set_for_comment'", 'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'ip_address': ('django.db.models.fields.IPAddressField', [], {'max_length': '15', 'null': 'True', 'blank': 'True'}), + 'is_public': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_removed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'object_pk': ('django.db.models.fields.TextField', [], {}), + 'site': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['sites.Site']"}), + 'submit_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'comment_comments'", 'null': 'True', 'to': "orm['auth.User']"}), + 'user_email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'user_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'user_url': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'documents.document': { + 'Meta': {'ordering': "['-date_added']", 'object_name': 'Document'}, + 'date_added': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'document_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['documents.DocumentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'uuid': ('django.db.models.fields.CharField', [], {'max_length': '48', 'blank': 'True'}) + }, + 'documents.documenttype': { + 'Meta': {'ordering': "['name']", 'object_name': 'DocumentType'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32'}) + }, + 'sites.site': { + 'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"}, + 'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'taggit.tag': { + 'Meta': {'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100'}) + }, + 'taggit.taggeditem': { + 'Meta': {'object_name': 'TaggedItem'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'taggit_taggeditem_tagged_items'", 'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), + 'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'taggit_taggeditem_items'", 'to': "orm['taggit.Tag']"}) + } + } + + complete_apps = ['checkouts'] \ No newline at end of file diff --git a/apps/checkouts/migrations/0002_auto__add_field_documentcheckout_user_content_type__add_field_document.py b/apps/checkouts/migrations/0002_auto__add_field_documentcheckout_user_content_type__add_field_document.py new file mode 100644 index 0000000000..be5474450e --- /dev/null +++ b/apps/checkouts/migrations/0002_auto__add_field_documentcheckout_user_content_type__add_field_document.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'DocumentCheckout.user_content_type' + db.add_column('checkouts_documentcheckout', 'user_content_type', + self.gf('django.db.models.fields.related.ForeignKey')(to=orm['contenttypes.ContentType'], null=True, blank=True), + keep_default=False) + + # Adding field 'DocumentCheckout.user_object_id' + db.add_column('checkouts_documentcheckout', 'user_object_id', + self.gf('django.db.models.fields.PositiveIntegerField')(null=True, blank=True), + keep_default=False) + + + # Changing field 'DocumentCheckout.checkout_datetime' + db.alter_column('checkouts_documentcheckout', 'checkout_datetime', self.gf('django.db.models.fields.DateTimeField')(null=True)) + + def backwards(self, orm): + # Deleting field 'DocumentCheckout.user_content_type' + db.delete_column('checkouts_documentcheckout', 'user_content_type_id') + + # Deleting field 'DocumentCheckout.user_object_id' + db.delete_column('checkouts_documentcheckout', 'user_object_id') + + + # Changing field 'DocumentCheckout.checkout_datetime' + db.alter_column('checkouts_documentcheckout', 'checkout_datetime', self.gf('django.db.models.fields.DateTimeField')()) + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'checkouts.documentcheckout': { + 'Meta': {'object_name': 'DocumentCheckout'}, + 'block_new_version': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'checkout_datetime': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'document': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['documents.Document']", 'unique': 'True'}), + 'expiration_datetime': ('django.db.models.fields.DateTimeField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'user_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'comments.comment': { + 'Meta': {'ordering': "('submit_date',)", 'object_name': 'Comment', 'db_table': "'django_comments'"}, + 'comment': ('django.db.models.fields.TextField', [], {'max_length': '3000'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'content_type_set_for_comment'", 'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'ip_address': ('django.db.models.fields.IPAddressField', [], {'max_length': '15', 'null': 'True', 'blank': 'True'}), + 'is_public': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_removed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'object_pk': ('django.db.models.fields.TextField', [], {}), + 'site': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['sites.Site']"}), + 'submit_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'comment_comments'", 'null': 'True', 'to': "orm['auth.User']"}), + 'user_email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'user_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'user_url': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'documents.document': { + 'Meta': {'ordering': "['-date_added']", 'object_name': 'Document'}, + 'date_added': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'document_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['documents.DocumentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'uuid': ('django.db.models.fields.CharField', [], {'max_length': '48', 'blank': 'True'}) + }, + 'documents.documenttype': { + 'Meta': {'ordering': "['name']", 'object_name': 'DocumentType'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32'}) + }, + 'sites.site': { + 'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"}, + 'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'taggit.tag': { + 'Meta': {'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100'}) + }, + 'taggit.taggeditem': { + 'Meta': {'object_name': 'TaggedItem'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'taggit_taggeditem_tagged_items'", 'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), + 'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'taggit_taggeditem_items'", 'to': "orm['taggit.Tag']"}) + } + } + + complete_apps = ['checkouts'] \ No newline at end of file diff --git a/apps/checkouts/migrations/__init__.py b/apps/checkouts/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/checkouts/models.py b/apps/checkouts/models.py new file mode 100644 index 0000000000..819b8856c3 --- /dev/null +++ b/apps/checkouts/models.py @@ -0,0 +1,56 @@ +from __future__ import absolute_import + +import logging +import datetime + +from django.db import models, IntegrityError +from django.utils.translation import ugettext_lazy as _ +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes import generic + +from documents.models import Document +from history.api import create_history + +from .managers import DocumentCheckoutManager +from .exceptions import DocumentAlreadyCheckedOut +from .events import HISTORY_DOCUMENT_CHECKED_OUT + +logger = logging.getLogger(__name__) + + +class DocumentCheckout(models.Model): + """ + Model to store the state and information of a document checkout + """ + document = models.ForeignKey(Document, verbose_name=_(u'document'), unique=True) + checkout_datetime = models.DateTimeField(verbose_name=_(u'check out date and time'), blank=True, null=True) + expiration_datetime = models.DateTimeField(verbose_name=_(u'check out expiration date and time'), help_text=_(u'Amount of time to hold the document checked out in minutes.')) + user_content_type = models.ForeignKey(ContentType, null=True, blank=True) # blank and null added for ease of db migration + user_object_id = models.PositiveIntegerField(null=True, blank=True) + user_object = generic.GenericForeignKey(ct_field='user_content_type', fk_field='user_object_id') + + block_new_version = models.BooleanField(default=True, verbose_name=_(u'block new version upload'), help_text=_(u'Do not allow new version of this document to be uploaded.')) + + #block_metadata + #block_editing + #block tag add/remove + + objects = DocumentCheckoutManager() + + def __unicode__(self): + return unicode(self.document) + + def save(self, *args, **kwargs): + if not self.pk: + self.checkout_datetime = datetime.datetime.now() + result = super(DocumentCheckout, self).save(*args, **kwargs) + create_history(HISTORY_DOCUMENT_CHECKED_OUT, source_object=self.document, data={'user': self.user_object, 'document': self.document}) + return result + + @models.permalink + def get_absolute_url(self): + return ('checkout_info', [self.document.pk]) + + class Meta: + verbose_name = _(u'document checkout') + verbose_name_plural = _(u'document checkouts') diff --git a/apps/checkouts/permissions.py b/apps/checkouts/permissions.py new file mode 100644 index 0000000000..19430381b8 --- /dev/null +++ b/apps/checkouts/permissions.py @@ -0,0 +1,13 @@ +from __future__ import absolute_import + +from django.utils.translation import ugettext_lazy as _ + +from permissions.models import PermissionNamespace, Permission + +namespace = PermissionNamespace('checkouts', _(u'Document checkout')) + +PERMISSION_DOCUMENT_CHECKOUT = Permission.objects.register(namespace, 'checkout_document', _(u'Check out documents')) +PERMISSION_DOCUMENT_CHECKIN = Permission.objects.register(namespace, 'checkin_document', _(u'Check in documents')) +PERMISSION_DOCUMENT_CHECKIN_OVERRIDE = Permission.objects.register(namespace, 'checkin_document_override', _(u'Forcefully check in documents')) +PERMISSION_DOCUMENT_RESTRICTIONS_OVERRIDE = Permission.objects.register(namespace, 'checkout_restrictions_override', _(u'Allow overriding check out restrictions')) + diff --git a/apps/checkouts/static/images/icons/basket_put.png b/apps/checkouts/static/images/icons/basket_put.png new file mode 100644 index 0000000000..66e7fcec1a Binary files /dev/null and b/apps/checkouts/static/images/icons/basket_put.png differ diff --git a/apps/checkouts/static/images/icons/basket_remove.png b/apps/checkouts/static/images/icons/basket_remove.png new file mode 100644 index 0000000000..0d8c5a36a3 Binary files /dev/null and b/apps/checkouts/static/images/icons/basket_remove.png differ diff --git a/apps/checkouts/static/images/icons/traffic_lights_green.png b/apps/checkouts/static/images/icons/traffic_lights_green.png new file mode 100644 index 0000000000..bd53d89990 Binary files /dev/null and b/apps/checkouts/static/images/icons/traffic_lights_green.png differ diff --git a/apps/checkouts/tasks.py b/apps/checkouts/tasks.py new file mode 100644 index 0000000000..d97f08f54b --- /dev/null +++ b/apps/checkouts/tasks.py @@ -0,0 +1,24 @@ +from __future__ import absolute_import + +import logging + +from lock_manager import Lock, LockError + +from .models import DocumentCheckout + +LOCK_EXPIRE = 50 +logger = logging.getLogger(__name__) + + +def task_check_expired_check_outs(): + logger.debug('executing...') + lock_id = u'task_expired_check_outs' + try: + logger.debug('trying to acquire lock: %s' % lock_id) + lock = Lock.acquire_lock(lock_id, LOCK_EXPIRE) + logger.debug('acquired lock: %s' % lock_id) + DocumentCheckout.objects.check_in_expired_check_outs() + lock.release() + except LockError: + logger.debug('unable to obtain lock') + pass diff --git a/apps/checkouts/urls.py b/apps/checkouts/urls.py new file mode 100644 index 0000000000..6dd1d8fdd8 --- /dev/null +++ b/apps/checkouts/urls.py @@ -0,0 +1,8 @@ +from django.conf.urls.defaults import patterns, url + +urlpatterns = patterns('checkouts.views', + url(r'^list/$', 'checkout_list', (), 'checkout_list'), + url(r'^(?P\d+)/check/out/$', 'checkout_document', (), 'checkout_document'), + url(r'^(?P\d+)/check/in/$', 'checkin_document', (), 'checkin_document'), + url(r'^(?P\d+)/check/info/$', 'checkout_info', (), 'checkout_info'), +) diff --git a/apps/checkouts/views.py b/apps/checkouts/views.py new file mode 100644 index 0000000000..ff52336f38 --- /dev/null +++ b/apps/checkouts/views.py @@ -0,0 +1,134 @@ +from __future__ import absolute_import + +from django.utils.translation import ugettext_lazy as _ +from django.http import HttpResponseRedirect +from django.shortcuts import render_to_response, get_object_or_404 +from django.template import RequestContext +from django.contrib import messages +from django.core.urlresolvers import reverse +from django.core.exceptions import PermissionDenied + +from documents.views import document_list +from documents.models import Document + +from permissions.models import Permission +from acls.models import AccessEntry +from common.utils import get_object_name +from common.utils import encapsulate + +from .models import DocumentCheckout +from .permissions import PERMISSION_DOCUMENT_CHECKOUT, PERMISSION_DOCUMENT_CHECKIN +from .forms import DocumentCheckoutForm +from .exceptions import DocumentAlreadyCheckedOut, DocumentNotCheckedOut +from .literals import STATE_CHECKED_OUT, STATE_CHECKED_IN, STATE_ICONS, STATE_LABELS +from .widgets import checkout_widget + + +def checkout_list(request): + + return document_list( + request, + object_list=DocumentCheckout.objects.checked_out_documents(), + title=_(u'checked out documents'), + extra_context={ + 'extra_columns': [ + {'name': _(u'checkout user'), 'attribute': encapsulate(lambda document: get_object_name(document.checkout_info().user_object, display_object_type=False))}, + {'name': _(u'checkout time and date'), 'attribute': encapsulate(lambda document: document.checkout_info().checkout_datetime)}, + {'name': _(u'checkout expiration'), 'attribute': encapsulate(lambda document: document.checkout_info().expiration_datetime)}, + ], + } + ) + + +def checkout_info(request, document_pk): + document = get_object_or_404(Document, pk=document_pk) + try: + Permission.objects.check_permissions(request.user, [PERMISSION_DOCUMENT_CHECKOUT, PERMISSION_DOCUMENT_CHECKIN]) + except PermissionDenied: + AccessEntry.objects.check_accesses([PERMISSION_DOCUMENT_CHECKOUT, PERMISSION_DOCUMENT_CHECKIN], request.user, document) + + paragraphs = [checkout_widget(document)] + + if document.is_checked_out(): + checkout_info = document.checkout_info() + paragraphs.append(_(u'User: %s') % get_object_name(checkout_info.user_object, display_object_type=False)) + paragraphs.append(_(u'Check out time: %s') % checkout_info.checkout_datetime) + paragraphs.append(_(u'Check out expiration: %s') % checkout_info.expiration_datetime) + paragraphs.append(_(u'New versions allowed: %s') % (_(u'yes') if not checkout_info.block_new_version else _(u'no'))) + + return render_to_response('generic_template.html', { + 'paragraphs': paragraphs, + 'object': document, + 'title': _(u'Check out details for document: %s') % document + }, context_instance=RequestContext(request)) + + +def checkout_document(request, document_pk): + document = get_object_or_404(Document, pk=document_pk) + try: + Permission.objects.check_permissions(request.user, [PERMISSION_DOCUMENT_CHECKOUT]) + except PermissionDenied: + AccessEntry.objects.check_access(PERMISSION_DOCUMENT_CHECKOUT, request.user, document) + + if request.method == 'POST': + form = DocumentCheckoutForm(data=request.POST, initial={'document': document}) + try: + if form.is_valid(): + try: + document_checkout = form.save(commit=False) + document_checkout.user_object = request.user + document_checkout.save() + except Exception, exc: + messages.error(request, _(u'Error trying to check out document; %s') % exc) + else: + messages.success(request, _(u'Document "%s" checked out successfully.') % document) + return HttpResponseRedirect(reverse('checkout_info', args=[document.pk])) + except DocumentAlreadyCheckedOut: + messages.error(request, _(u'Document already checked out.')) + return HttpResponseRedirect(reverse('checkout_info', args=[document.pk])) + else: + form = DocumentCheckoutForm(initial={'document': document}) + + return render_to_response('generic_form.html', { + 'form': form, + 'object': document, + 'title': _(u'Check out document: %s') % document + }, context_instance=RequestContext(request)) + + +def checkin_document(request, document_pk): + document = get_object_or_404(Document, pk=document_pk) + post_action_redirect = reverse('checkout_info', args=[document.pk]) + # TODO: add forcefull checkin + # TODO: check user + try: + Permission.objects.check_permissions(request.user, [PERMISSION_DOCUMENT_CHECKIN]) + except PermissionDenied: + AccessEntry.objects.check_access(PERMISSION_DOCUMENT_CHECKIN, request.user, document) + + previous = request.POST.get('previous', request.GET.get('previous', request.META.get('HTTP_REFERER', '/'))) + next = request.POST.get('next', request.GET.get('next', post_action_redirect if post_action_redirect else request.META.get('HTTP_REFERER', '/'))) + + if request.method == 'POST': + try: + document.check_in(user=request.user) + except DocumentNotCheckedOut: + messages.error(request, _(u'Document has not been checked out.')) + except Exception, exc: + messages.error(request, _(u'Error trying to check in document; %s') % exc) + else: + messages.success(request, _(u'Document "%s" checked in successfully.') % document) + return HttpResponseRedirect(next) + + context = { + 'object_name': _(u'document'), + 'delete_view': False, + 'previous': previous, + 'next': next, + 'form_icon': u'basket_remove.png', + 'object': document, + 'title': _(u'Are you sure you wish to check in document: %s') % document + } + + return render_to_response('generic_confirm.html', context, + context_instance=RequestContext(request)) diff --git a/apps/checkouts/widgets.py b/apps/checkouts/widgets.py new file mode 100644 index 0000000000..cd5f3d8f70 --- /dev/null +++ b/apps/checkouts/widgets.py @@ -0,0 +1,107 @@ +from __future__ import absolute_import + +import datetime + +from django import forms +from django.utils.translation import ugettext_lazy as _ +from django.utils.safestring import mark_safe +from django.conf import settings +from django.core import validators + +from .literals import STATE_CHECKED_OUT, STATE_CHECKED_IN, STATE_ICONS, STATE_LABELS + + +def checkout_widget(document): + checkout_state = document.checkout_state() + + widget = (u'' % (settings.STATIC_URL, STATE_ICONS[checkout_state])) + return _(u'Document status: %(widget)s %(text)s') % { + 'widget': mark_safe(widget), + 'text': STATE_LABELS[checkout_state] + } + + +class SplitDeltaWidget(forms.widgets.MultiWidget): + """ + A Widget that splits a timedelta input into three boxes. + """ + def __init__(self, attrs=None): + widgets = ( + forms.widgets.TextInput(attrs={'maxlength': 3, 'style':'width: 5em;', 'placeholder': _(u'Days')}), + forms.widgets.TextInput(attrs={'maxlength': 4, 'style':'width: 5em;', 'placeholder': _(u'Hours')}), + forms.widgets.TextInput(attrs={'maxlength': 5, 'style':'width: 5em;', 'placeholder': _(u'Minutes')}), + ) + super(SplitDeltaWidget, self).__init__(widgets, attrs) + + def decompress(self, value): + if value: + return [value.days, value.seconds / 3600, (value.seconds / 60) % 60] + return [None, None, None] + + def value_from_datadict(self, data, files, name): + return [data.get('expiration_datetime_0', 0) or 0, data.get('expiration_datetime_1', 0) or 0, data.get('expiration_datetime_2', 0) or 0] + + +class SplitHiddenDeltaWidget(forms.widgets.SplitDateTimeWidget): + """ + A Widget that splits a timedelta input into three inputs. + """ + is_hidden = True + + def __init__(self, attrs=None): + super(SplitHiddenDeltaWidget, self).__init__(attrs, date_format, time_format) + for widget in self.widgets: + widget.input_type = 'hidden' + widget.is_hidden = True + + +class SplitTimeDeltaField(forms.MultiValueField): + widget = SplitDeltaWidget + hidden_widget = SplitHiddenDeltaWidget + default_error_messages = { + 'invalid_days': _(u'Enter a valid number of days.'), + 'invalid_hours': _(u'Enter a valid number of hours.'), + 'invalid_minutes': _(u'Enter a valid number of minutes.'), + 'invalid_timedelta': _(u'Enter a valid time difference.'), + } + + def __init__(self, *args, **kwargs): + errors = self.default_error_messages.copy() + if 'error_messages' in kwargs: + errors.update(kwargs['error_messages']) + localize = kwargs.get('localize', False) + fields = ( + forms.IntegerField(min_value=0, + error_messages={'invalid': errors['invalid_days']}, + localize=localize + ), + forms.IntegerField(min_value=0, + error_messages={'invalid': errors['invalid_hours']}, + localize=localize + ), + forms.IntegerField(min_value=0, + error_messages={'invalid': errors['invalid_minutes']}, + localize=localize + ), + ) + super(SplitTimeDeltaField, self).__init__(fields, *args, **kwargs) + self.help_text = _(u'Amount of time to hold the document in the checked out state in days, hours and/or minutes.') + self.label = _('Check out expiration date and time') + + def compress(self, data_list): + if data_list == [0, 0, 0]: + raise forms.ValidationError(self.error_messages['invalid_timedelta']) + + if data_list: + # Raise a validation error if time or date is empty + # (possible if SplitDateTimeField has required=False). + if data_list[0] in validators.EMPTY_VALUES: + raise forms.ValidationError(self.error_messages['invalid_days']) + if data_list[1] in validators.EMPTY_VALUES: + raise forms.ValidationError(self.error_messages['invalid_hours']) + if data_list[2] in validators.EMPTY_VALUES: + raise forms.ValidationError(self.error_messages['invalid_minutes']) + + timedelta = datetime.timedelta(days=data_list[0], hours=data_list[1], minutes=data_list[2]) + return datetime.datetime.now() + timedelta + return None diff --git a/apps/documents/exceptions.py b/apps/documents/exceptions.py new file mode 100644 index 0000000000..e3ab3eec34 --- /dev/null +++ b/apps/documents/exceptions.py @@ -0,0 +1,6 @@ +class NewDocumentVersionNotAllowed(Exception): + """ + Uploading new versions for this document is not allowed + Current reasons: Document is in checked out state + """ + pass diff --git a/apps/documents/models.py b/apps/documents/models.py index c820780c3b..eb94051902 100644 --- a/apps/documents/models.py +++ b/apps/documents/models.py @@ -37,6 +37,7 @@ from .managers import DocumentPageTransformationManager from .utils import document_save_to_temp_dir from .literals import (RELEASE_LEVEL_FINAL, RELEASE_LEVEL_CHOICES, VERSION_UPDATE_MAJOR, VERSION_UPDATE_MINOR, VERSION_UPDATE_MICRO) +from .exceptions import NewDocumentVersionNotAllowed # document image cache name hash function HASH_FUNCTION = lambda x: hashlib.sha256(x).hexdigest() @@ -169,8 +170,11 @@ class Document(models.Model): def size(self): return self.latest_version.size - def new_version(self, file, comment=None, version_update=None, release_level=None, serial=None): + def new_version(self, file, user=None, comment=None, version_update=None, release_level=None, serial=None): logger.debug('creating new document version') + if not self.is_new_versions_allowed(user=user): + raise NewDocumentVersionNotAllowed + if version_update: new_version_dict = self.latest_version.get_new_version_dict(version_update) logger.debug('new_version_dict: %s' % new_version_dict) @@ -520,8 +524,14 @@ class DocumentVersion(models.Model): return None def rename(self, new_name): + new_filename, new_extension = os.path.splitext(new_name) name, extension = os.path.splitext(self.filename) - self.filename = u''.join([new_name, extension]) + + # Preserve existing extension if new name doesn't has one + if new_extension: + extension = new_extension + + self.filename = u''.join([new_filename, extension]) self.save() diff --git a/apps/history/__init__.py b/apps/history/__init__.py index b0b4025a3e..24469fbd2c 100644 --- a/apps/history/__init__.py +++ b/apps/history/__init__.py @@ -6,7 +6,6 @@ from project_tools.api import register_tool from .permissions import PERMISSION_HISTORY_VIEW - -history_list = {'text': _(u'history'), 'view': 'history_list', 'famfam': 'book', 'icon': 'book.png', 'permissions': [PERMISSION_HISTORY_VIEW], 'children_view_regex': [r'history_[l,v]']} +history_list = {'text': _(u'history'), 'view': 'history_list', 'famfam': 'book', 'icon': 'book.png', 'children_view_regex': [r'history_[l,v]']} register_tool(history_list) diff --git a/apps/history/views.py b/apps/history/views.py index 2212c7e5ef..8b29a5d1ca 100644 --- a/apps/history/views.py +++ b/apps/history/views.py @@ -19,12 +19,22 @@ from .permissions import PERMISSION_HISTORY_VIEW from .widgets import history_entry_object_link, history_entry_summary -def history_list(request): - Permission.objects.check_permissions(request.user, [PERMISSION_HISTORY_VIEW]) +def history_list(request, object_list=None, title=None, extra_context=None): + pre_object_list = object_list if not (object_list is None) else History.objects.all() + + try: + Permission.objects.check_permissions(request.user, [PERMISSION_HISTORY_VIEW]) + except PermissionDenied: + # If user doesn't have global permission, get a list of document + # for which he/she does hace access use it to filter the + # provided object_list + final_object_list = AccessEntry.objects.filter_objects_by_access(PERMISSION_HISTORY_VIEW, request.user, pre_object_list, related='content_object') + else: + final_object_list = pre_object_list context = { - 'object_list': History.objects.all(), - 'title': _(u'history events'), + 'object_list': final_object_list, + 'title': title if title else _(u'history events'), 'extra_columns': [ { 'name': _(u'date and time'), @@ -42,6 +52,9 @@ def history_list(request): 'hide_object': True, } + if extra_context: + context.update(extra_context) + return render_to_response('generic_list.html', context, context_instance=RequestContext(request)) @@ -88,11 +101,11 @@ def history_view(request, object_id): AccessEntry.objects.check_access(PERMISSION_HISTORY_VIEW, request.user, history.content_object) 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'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()}, + {'label': _(u'Additional details'), 'field': lambda x: x.get_processed_details() or _(u'None')}, ]) return render_to_response('generic_detail.html', { diff --git a/apps/permissions/exceptions.py b/apps/permissions/exceptions.py new file mode 100644 index 0000000000..14c1eb54f6 --- /dev/null +++ b/apps/permissions/exceptions.py @@ -0,0 +1,5 @@ +from django.core.exceptions import PermissionDenied as DjangoPermissionDenied + + +class PermissionDenied(DjangoPermissionDenied): + pass diff --git a/apps/sources/models.py b/apps/sources/models.py index 0941abd00d..e659b4e60e 100644 --- a/apps/sources/models.py +++ b/apps/sources/models.py @@ -114,7 +114,7 @@ class BaseModel(models.Model): new_version_data = {} try: - new_version = document.new_version(file=file_object, **new_version_data) + new_version = document.new_version(file=file_object, user=user, **new_version_data) except Exception: # Don't leave the database in a broken state # document.delete() diff --git a/apps/sources/views.py b/apps/sources/views.py index adb4df3611..0014d9b4cf 100644 --- a/apps/sources/views.py +++ b/apps/sources/views.py @@ -15,6 +15,7 @@ from documents.permissions import (PERMISSION_DOCUMENT_CREATE, PERMISSION_DOCUMENT_NEW_VERSION) from documents.models import DocumentType, Document from documents.conf.settings import THUMBNAIL_SIZE +from documents.exceptions import NewDocumentVersionNotAllowed from metadata.api import decode_metadata_from_url, metadata_repr_as_list from permissions.models import Permission from common.utils import encapsulate @@ -174,6 +175,8 @@ def upload_interactive(request, source_type=None, source_id=None, document_pk=No messages.warning(request, _(u'File was not a compressed file, uploaded as it was.')) return HttpResponseRedirect(request.get_full_path()) + except NewDocumentVersionNotAllowed: + messages.error(request, _(u'New version uploads are not allowed for this document.')) except Exception, e: if settings.DEBUG: raise @@ -253,6 +256,8 @@ def upload_interactive(request, source_type=None, source_id=None, document_pk=No return HttpResponseRedirect(reverse('document_view_simple', args=[document.pk])) else: return HttpResponseRedirect(request.get_full_path()) + except NewDocumentVersionNotAllowed: + messages.error(request, _(u'New version uploads are not allowed for this document.')) except Exception, e: if settings.DEBUG: raise diff --git a/docs/index.rst b/docs/index.rst index fe94d819c9..5bb72eba86 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,7 @@ Mayan EDMS documentation ======================== .. rubric:: `Open source`_, Django_ based document manager with custom - metadata_ indexing_, file serving integration, OCR_ capabilities, + metadata_ indexing_, file serving integration, `checking out and in`_, OCR_ capabilities, document versioning_ and `digital signature verification`_. .. _Django: http://www.djangoproject.com/ @@ -16,6 +16,7 @@ Mayan EDMS documentation .. _metadata: http://en.wikipedia.org/wiki/Metadata .. _indexing: http://en.wikipedia.org/wiki/Index_card .. _Open source: http://en.wikipedia.org/wiki/Open_source +.. _checking out and in: http://en.wikipedia.org/wiki/Revision_control On the Web ===================== @@ -54,7 +55,7 @@ Understanding Mayan EDMS Between versions ================ .. toctree:: - :maxdepth: 2 + :maxdepth: 1 releases/index diff --git a/docs/releases/0.12.2.rst b/docs/releases/0.12.2.rst new file mode 100644 index 0000000000..8220786960 --- /dev/null +++ b/docs/releases/0.12.2.rst @@ -0,0 +1,50 @@ +================================ +Mayan EDMS v0.12.2 release notes +================================ + +*June 2012* + +This is the second maintenance release of the 0.12 series. + +Overview +======== + + +As with the previous release bug fixes and minor feature were the focus +for this release too. Long standing `issue #24`_ has been fixed and document +check outs have been added too as per the feature request posted as `issue #26`_. + +What's new in Mayan EDMS v0.12.2 +================================ + +Document check outs +~~~~~~~~~~~~~~~~~~~~~ + + +Upgrading from a previous version +================================= + +Migrate existing database schema with:: + + $ ./manage.py migrate checkouts + +The upgrade procedure is now complete. + + +Backward incompatible changes +============================= +* None + +Bugs fixed +========== +* `issue #24`_ "Duplicated filename extension when uploading a new version of a document" +* `issue #26`_ "checkout feature request" + +Stuff removed +============= +* None + + + +.. _issue #24: https://github.com/rosarior/mayan/issues/24 +.. _issue #26: https://github.com/rosarior/mayan/issues/26 diff --git a/docs/releases/index.rst b/docs/releases/index.rst index 328b8f08cf..478e26bbd1 100644 --- a/docs/releases/index.rst +++ b/docs/releases/index.rst @@ -11,11 +11,12 @@ all the backwards-incompatible changes and deprecated features for each 'final' release from the one after your current **Mayan EDMS** version, up to and including the latest version. -Latest version (0.12.1) +Latest version (0.12.2) ----------------------- .. toctree:: :maxdepth: 1 + 0.12.2 0.12.1 0.12 diff --git a/settings.py b/settings.py index a0da8da709..6132d57479 100644 --- a/settings.py +++ b/settings.py @@ -174,6 +174,7 @@ INSTALLED_APPS = ( 'main', 'rest_api', 'document_signatures', + 'checkouts', # Has to be last so the other apps can register it's signals 'signaler', diff --git a/urls.py b/urls.py index a6ae677f51..4f3430b786 100644 --- a/urls.py +++ b/urls.py @@ -32,6 +32,7 @@ urlpatterns = patterns('', (r'^gpg/', include('django_gpg.urls')), (r'^documents/signatures/', include('document_signatures.urls')), (r'^feedback/', include('feedback.urls')), + (r'^checkouts/', include('checkouts.urls')), )