diff --git a/HISTORY.rst b/HISTORY.rst index 9d4f38355f..f7cedceddd 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -95,7 +95,8 @@ - Add support to the ObjectActionMixin to report on instance action failures. Add also an error_message class property and the new ActionError exception. - +- Add favorite documents per user. Adds new setting option + DOCUMENTS_FAVORITE_COUNT. 3.0.3 (2018-08-17) ================== diff --git a/mayan/apps/documents/apps.py b/mayan/apps/documents/apps.py index 981612ce5d..e19a5d35b8 100644 --- a/mayan/apps/documents/apps.py +++ b/mayan/apps/documents/apps.py @@ -53,14 +53,17 @@ from .handlers import ( from .links import ( link_clear_image_cache, link_document_clear_transformations, link_document_clone_transformations, link_document_delete, - link_document_document_type_edit, link_document_duplicates_list, - link_document_multiple_document_type_edit, link_document_download, - link_document_edit, link_document_list, link_document_list_deleted, - link_document_list_recent_access, link_document_list_recent_added, - link_document_multiple_delete, link_document_multiple_trash, + link_document_document_type_edit, link_document_download, + link_document_duplicates_list, link_document_edit, + link_document_favorites_add, link_document_favorites_remove, + link_document_list, link_document_list_deleted, + link_document_list_favorites, link_document_list_recent_access, + link_document_list_recent_added, link_document_multiple_clear_transformations, - link_document_multiple_download, link_document_multiple_restore, - link_document_multiple_update_page_count, + link_document_multiple_delete, link_document_multiple_document_type_edit, + link_document_multiple_download, link_document_multiple_favorites_add, + link_document_multiple_favorites_remove, link_document_multiple_restore, + link_document_multiple_trash, link_document_multiple_update_page_count, link_document_page_navigation_first, link_document_page_navigation_last, link_document_page_navigation_next, link_document_page_navigation_previous, link_document_page_return, link_document_page_rotate_left, @@ -395,8 +398,9 @@ class DocumentsApp(MayanAppConfig): menu_documents.bind_links( links=( link_document_list_recent_access, - link_document_list_recent_added, link_document_list, - link_document_list_deleted, link_duplicated_document_list + link_document_list_recent_added, link_document_list_favorites, + link_document_list, link_document_list_deleted, + link_duplicated_document_list, ) ) @@ -446,6 +450,7 @@ class DocumentsApp(MayanAppConfig): # Document object links menu_object.bind_links( links=( + link_document_favorites_add, link_document_favorites_remove, link_document_edit, link_document_document_type_edit, link_document_print, link_document_trash, link_document_quick_download, link_document_download, @@ -488,10 +493,12 @@ class DocumentsApp(MayanAppConfig): ) menu_multi_item.bind_links( links=( + link_document_multiple_favorites_add, + link_document_multiple_favorites_remove, link_document_multiple_clear_transformations, link_document_multiple_trash, link_document_multiple_download, link_document_multiple_update_page_count, - link_document_multiple_document_type_edit + link_document_multiple_document_type_edit, ), sources=(Document,) ) menu_multi_item.bind_links( diff --git a/mayan/apps/documents/icons.py b/mayan/apps/documents/icons.py index 882e5bf7ba..e1cec6165e 100644 --- a/mayan/apps/documents/icons.py +++ b/mayan/apps/documents/icons.py @@ -23,6 +23,7 @@ icon_document_duplicates_list = Icon( ) icon_document_list = Icon(driver_name='fontawesome', symbol='file') icon_document_list_deleted = Icon(driver_name='fontawesome', symbol='trash') +icon_document_list_favorites = Icon(driver_name='fontawesome', symbol='star') icon_document_list_recent_access = Icon( driver_name='fontawesome', symbol='clock' ) diff --git a/mayan/apps/documents/links.py b/mayan/apps/documents/links.py index ea1cb8206e..7f6e1c999d 100644 --- a/mayan/apps/documents/links.py +++ b/mayan/apps/documents/links.py @@ -9,16 +9,17 @@ from navigation import Link from .icons import ( icon_clear_image_cache, icon_document_duplicates_list, icon_document_list, - icon_document_list_deleted, icon_document_list_recent_access, - icon_document_list_recent_added, icon_document_page_navigation_first, - icon_document_page_navigation_last, icon_document_page_navigation_next, - icon_document_page_navigation_previous, icon_document_page_return, - icon_document_page_rotate_left, icon_document_page_rotate_right, - icon_document_page_zoom_in, icon_document_page_zoom_out, - icon_document_pages, icon_document_preview, icon_document_properties, - icon_document_type_setup, icon_document_version_list, - icon_document_version_return_document, icon_document_version_return_list, - icon_duplicated_document_list, icon_duplicated_document_scan + icon_document_list_deleted, icon_document_list_favorites, + icon_document_list_recent_access, icon_document_list_recent_added, + icon_document_page_navigation_first, icon_document_page_navigation_last, + icon_document_page_navigation_next, icon_document_page_navigation_previous, + icon_document_page_return, icon_document_page_rotate_left, + icon_document_page_rotate_right, icon_document_page_zoom_in, + icon_document_page_zoom_out, icon_document_pages, icon_document_preview, + icon_document_properties, icon_document_type_setup, + icon_document_version_list, icon_document_version_return_document, + icon_document_version_return_list, icon_duplicated_document_list, + icon_duplicated_document_scan ) from .permissions import ( permission_document_delete, permission_document_download, @@ -98,6 +99,16 @@ link_document_delete = Link( args='resolved_object.id', permissions=(permission_document_delete,), tags='dangerous', text=_('Delete'), view='documents:document_delete', ) +link_document_favorites_add = Link( + args='resolved_object.id', + permissions=(permission_document_view,), text=_('Add to favorites'), + view='documents:document_add_to_favorites', +) +link_document_favorites_remove = Link( + args='resolved_object.id', + permissions=(permission_document_view,), text=_('Remove from favorites'), + view='documents:document_remove_from_favorites', +) link_document_trash = Link( args='resolved_object.id', permissions=(permission_document_trash,), tags='dangerous', text=_('Move to trash'), @@ -147,6 +158,14 @@ link_document_multiple_delete = Link( tags='dangerous', text=_('Delete'), view='documents:document_multiple_delete' ) +link_document_multiple_favorites_add = Link( + text=_('Add to favorites'), + view='documents:document_multiple_add_to_favorites', +) +link_document_multiple_favorites_remove = Link( + text=_('Remove from favorites'), + view='documents:document_multiple_remove_from_favorites', +) link_document_multiple_document_type_edit = Link( text=_('Change type'), view='documents:document_multiple_document_type_edit' @@ -189,6 +208,10 @@ link_document_list = Link( icon_class=icon_document_list, text=_('All documents'), view='documents:document_list' ) +link_document_list_favorites = Link( + icon_class=icon_document_list_favorites, text=_('Favorites'), + view='documents:document_list_favorites' +) link_document_list_recent_access = Link( icon_class=icon_document_list_recent_access, text=_('Recently accessed'), view='documents:document_list_recent_access' diff --git a/mayan/apps/documents/managers.py b/mayan/apps/documents/managers.py index 8bbaaa6c59..beccf494a6 100644 --- a/mayan/apps/documents/managers.py +++ b/mayan/apps/documents/managers.py @@ -11,7 +11,7 @@ from django.utils.encoding import force_text from django.utils.timezone import now from .literals import STUB_EXPIRATION_INTERVAL -from .settings import setting_recent_access_count +from .settings import setting_favorite_count, setting_recent_access_count logger = logging.getLogger(__name__) @@ -184,6 +184,43 @@ class DuplicatedDocumentManager(models.Manager): self.scan_for(document=document, scan_children=False) +class FavoriteDocumentManager(models.Manager): + def add_for_user(self, user, document): + favorite_document, created = self.model.objects.get_or_create( + user=user, document=document + ) + + old_favorites_to_delete = self.filter(user=user).values_list('pk', flat=True)[setting_favorite_count.value:] + self.filter(pk__in=list(old_favorites_to_delete)).delete() + + def get_by_natural_key(self, datetime_accessed, document_natural_key, user_natural_key): + Document = apps.get_model( + app_label='documents', model_name='Document' + ) + User = get_user_model() + try: + document = Document.objects.get_by_natural_key(*document_natural_key) + except Document.DoesNotExist: + raise self.model.DoesNotExist + else: + try: + user = User.objects.get_by_natural_key(*user_natural_key) + except User.DoesNotExist: + raise self.model.DoesNotExist + + return self.get(document__pk=document.pk, user__pk=user.pk) + + def get_for_user(self, user): + Document = apps.get_model( + app_label='documents', model_name='Document' + ) + + return Document.objects.filter(favorites__user=user) + + def remove_for_user(self, user, document): + self.get(user=user, document=document).delete() + + class PassthroughManager(models.Manager): pass @@ -224,14 +261,16 @@ class RecentDocumentManager(models.Manager): ) def get_for_user(self, user): - document_model = apps.get_model('documents', 'document') + Document = apps.get_model( + app_label='documents', model_name='Document' + ) if user.is_authenticated: - return document_model.objects.filter( - recentdocument__user=user - ).order_by('-recentdocument__datetime_accessed') + return Document.objects.filter( + recent__user=user + ).order_by('-recent__datetime_accessed') else: - return document_model.objects.none() + return Document.objects.none() class TrashCanManager(models.Manager): diff --git a/mayan/apps/documents/migrations/0044_auto_20180823_0452.py b/mayan/apps/documents/migrations/0044_auto_20180823_0452.py new file mode 100644 index 0000000000..d5bececed8 --- /dev/null +++ b/mayan/apps/documents/migrations/0044_auto_20180823_0452.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-08-23 04:52 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('documents', '0043_auto_20180429_0759'), + ] + + operations = [ + migrations.CreateModel( + name='FavoriteDocument', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'verbose_name': 'Favorite document', + 'verbose_name_plural': 'Favorite documents', + }, + ), + migrations.AlterModelOptions( + name='document', + options={'ordering': ('label',), 'verbose_name': 'Document', 'verbose_name_plural': 'Documents'}, + ), + migrations.AlterField( + model_name='recentdocument', + name='document', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='recent', to='documents.Document', verbose_name='Document'), + ), + migrations.AddField( + model_name='favoritedocument', + name='document', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to='documents.Document', verbose_name='Document'), + ), + migrations.AddField( + model_name='favoritedocument', + name='user', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + ), + ] diff --git a/mayan/apps/documents/models.py b/mayan/apps/documents/models.py index 5a75bd390e..c01da8ece4 100644 --- a/mayan/apps/documents/models.py +++ b/mayan/apps/documents/models.py @@ -37,8 +37,8 @@ from .events import ( from .literals import DEFAULT_DELETE_PERIOD, DEFAULT_DELETE_TIME_UNIT from .managers import ( DocumentManager, DocumentPageManager, DocumentVersionManager, - DocumentTypeManager, DuplicatedDocumentManager, PassthroughManager, - RecentDocumentManager, TrashCanManager + DocumentTypeManager, DuplicatedDocumentManager, FavoriteDocumentManager, + PassthroughManager, RecentDocumentManager, TrashCanManager ) from .permissions import permission_document_view from .settings import ( @@ -988,39 +988,6 @@ class DocumentPageResult(DocumentPage): verbose_name_plural = _('Document pages') -@python_2_unicode_compatible -class RecentDocument(models.Model): - """ - Keeps a list of the n most recent accessed or created document for - a given user - """ - user = models.ForeignKey( - db_index=True, editable=False, on_delete=models.CASCADE, - to=settings.AUTH_USER_MODEL, verbose_name=_('User') - ) - document = models.ForeignKey( - editable=False, on_delete=models.CASCADE, to=Document, - verbose_name=_('Document') - ) - datetime_accessed = models.DateTimeField( - auto_now=True, db_index=True, verbose_name=_('Accessed') - ) - - objects = RecentDocumentManager() - - class Meta: - ordering = ('-datetime_accessed',) - verbose_name = _('Recent document') - verbose_name_plural = _('Recent documents') - - def __str__(self): - return force_text(self.document) - - def natural_key(self): - return (self.datetime_accessed, self.document.natural_key(), self.user.natural_key()) - natural_key.dependencies = ['documents.Document', settings.AUTH_USER_MODEL] - - @python_2_unicode_compatible class DuplicatedDocument(models.Model): document = models.ForeignKey( @@ -1042,3 +1009,64 @@ class DuplicatedDocument(models.Model): def __str__(self): return force_text(self.document) + + +@python_2_unicode_compatible +class FavoriteDocument(models.Model): + """ + Keeps a list of the favorited documents of a given user + """ + user = models.ForeignKey( + db_index=True, editable=False, on_delete=models.CASCADE, + to=settings.AUTH_USER_MODEL, verbose_name=_('User') + ) + document = models.ForeignKey( + editable=False, on_delete=models.CASCADE, related_name='favorites', + to=Document, verbose_name=_('Document') + ) + + objects = FavoriteDocumentManager() + + class Meta: + verbose_name = _('Favorite document') + verbose_name_plural = _('Favorite documents') + + def __str__(self): + return force_text(self.document) + + def natural_key(self): + return (self.document.natural_key(), self.user.natural_key()) + natural_key.dependencies = ['documents.Document', settings.AUTH_USER_MODEL] + + +@python_2_unicode_compatible +class RecentDocument(models.Model): + """ + Keeps a list of the n most recent accessed or created document for + a given user + """ + user = models.ForeignKey( + db_index=True, editable=False, on_delete=models.CASCADE, + to=settings.AUTH_USER_MODEL, verbose_name=_('User') + ) + document = models.ForeignKey( + editable=False, on_delete=models.CASCADE, related_name='recent', + to=Document, verbose_name=_('Document') + ) + datetime_accessed = models.DateTimeField( + auto_now=True, db_index=True, verbose_name=_('Accessed') + ) + + objects = RecentDocumentManager() + + class Meta: + ordering = ('-datetime_accessed',) + verbose_name = _('Recent document') + verbose_name_plural = _('Recent documents') + + def __str__(self): + return force_text(self.document) + + def natural_key(self): + return (self.datetime_accessed, self.document.natural_key(), self.user.natural_key()) + natural_key.dependencies = ['documents.Document', settings.AUTH_USER_MODEL] diff --git a/mayan/apps/documents/settings.py b/mayan/apps/documents/settings.py index 27ecce1766..794c433a5e 100644 --- a/mayan/apps/documents/settings.py +++ b/mayan/apps/documents/settings.py @@ -47,6 +47,12 @@ setting_display_height = namespace.add_setting( setting_display_width = namespace.add_setting( global_name='DOCUMENTS_DISPLAY_WIDTH', default='3600' ) +setting_favorite_count = namespace.add_setting( + global_name='DOCUMENTS_FAVORITE_COUNT', default=40, + help_text=_( + 'Maximum number of favorite documents to remember per user.' + ) +) setting_fix_orientation = namespace.add_setting( global_name='DOCUMENTS_FIX_ORIENTATION', default=False, help_text=_( diff --git a/mayan/apps/documents/urls.py b/mayan/apps/documents/urls.py index 55b66feb46..897025aeb7 100644 --- a/mayan/apps/documents/urls.py +++ b/mayan/apps/documents/urls.py @@ -32,6 +32,7 @@ from .views import ( DocumentVersionDownloadFormView, DocumentVersionDownloadView, DocumentVersionListView, DocumentVersionRevertView, DocumentVersionView, DocumentView, DuplicatedDocumentListView, EmptyTrashCanView, + FavoriteAddView, FavoriteDocumentListView, FavoriteRemoveView, RecentAccessDocumentListView, RecentAddedDocumentListView, ScanDuplicatedDocuments ) @@ -56,6 +57,10 @@ urlpatterns = [ DuplicatedDocumentListView.as_view(), name='duplicated_document_list' ), + url( + r'^list/favorites/$', FavoriteDocumentListView.as_view(), + name='document_list_favorites' + ), url( r'^(?P\d+)/preview/$', DocumentPreviewView.as_view(), name='document_preview' @@ -68,6 +73,22 @@ urlpatterns = [ r'^(?P\d+)/duplicates/$', DocumentDuplicatesListView.as_view(), name='document_duplicates_list' ), + url( + r'^(?P\d+)/add_to_favorites/$', FavoriteAddView.as_view(), + name='document_add_to_favorites' + ), + url( + r'^multiple/add_to_favorites/$', FavoriteAddView.as_view(), + name='document_multiple_add_to_favorites' + ), + url( + r'^(?P\d+)/remove_from_favorites/$', FavoriteRemoveView.as_view(), + name='document_remove_from_favorites' + ), + url( + r'^multiple/remove_from_favorites/$', FavoriteRemoveView.as_view(), + name='document_multiple_remove_from_favorites' + ), url( r'^(?P\d+)/restore/$', DocumentRestoreView.as_view(), name='document_restore' diff --git a/mayan/apps/documents/views/document_views.py b/mayan/apps/documents/views/document_views.py index 071241c47e..4692dfd8d4 100644 --- a/mayan/apps/documents/views/document_views.py +++ b/mayan/apps/documents/views/document_views.py @@ -12,10 +12,12 @@ from django.utils.translation import ugettext_lazy as _, ungettext from acls.models import AccessControlList from common.compressed_files import CompressedFile +from common.exceptions import ActionError from common.generics import ( ConfirmView, FormView, MultipleObjectConfirmActionView, - MultipleObjectFormActionView, SingleObjectDetailView, - SingleObjectDownloadView, SingleObjectEditView, SingleObjectListView + MultipleObjectConfirmActionView, MultipleObjectFormActionView, + SingleObjectDetailView, SingleObjectDownloadView, SingleObjectEditView, + SingleObjectListView ) from common.mixins import MultipleInstanceActionMixin from common.utils import encapsulate @@ -30,9 +32,11 @@ from ..forms import ( DocumentPreviewForm, DocumentPrintForm, DocumentPropertiesForm, DocumentTypeSelectForm, ) +from ..icons import icon_document_list_favorites from ..literals import PAGE_RANGE_RANGE, DEFAULT_ZIP_FILENAME from ..models import ( - DeletedDocument, Document, DuplicatedDocument, RecentDocument + DeletedDocument, Document, DuplicatedDocument, FavoriteDocument, + RecentDocument ) from ..permissions import ( permission_document_delete, permission_document_download, @@ -813,9 +817,85 @@ class DuplicatedDocumentListView(DocumentListView): return context +class FavoriteDocumentListView(DocumentListView): + def get_document_queryset(self): + return FavoriteDocument.objects.get_for_user(user=self.request.user) + + def get_extra_context(self): + context = super(FavoriteDocumentListView, self).get_extra_context() + context.update( + { + 'title': _('Favorites'), + } + ) + return context + + +class FavoriteAddView(MultipleObjectConfirmActionView): + model = Document + object_permission = permission_document_view + success_message = _( + '%(count)d document added to favorites.' + ) + success_message_plural = _( + '%(count)d documents added to favorites.' + ) + + def get_extra_context(self): + queryset = self.get_queryset() + + return { + 'submit_label': _('Add'), + 'submit_icon_class': icon_document_list_favorites, + 'title': ungettext( + singular='Add the selected document to favorites', + plural='Add the selected documents to favorites', + number=queryset.count() + ) + } + + def object_action(self, form, instance): + FavoriteDocument.objects.add_for_user( + user=self.request.user, document=instance + ) + + +class FavoriteRemoveView(MultipleObjectConfirmActionView): + error_message = _('Document "%(instance)s" is not in favorites.') + model = Document + object_permission = permission_document_view + success_message = _( + '%(count)d document removed to favorites.' + ) + success_message_plural = _( + '%(count)d documents removed to favorites.' + ) + + def get_extra_context(self): + queryset = self.get_queryset() + + return { + 'submit_label': _('Remove'), + 'submit_icon_class': icon_document_list_favorites, + 'title': ungettext( + singular='Remove the selected document to favorites', + plural='Remove the selected documents to favorites', + number=queryset.count() + ) + } + + def object_action(self, form, instance): + try: + FavoriteDocument.objects.remove_for_user( + user=self.request.user, document=instance + ) + except FavoriteDocument.DoesNotExist: + raise ActionError + + class RecentAccessDocumentListView(DocumentListView): def get_document_queryset(self): - return RecentDocument.objects.get_for_user(self.request.user) + return RecentDocument.objects.get_for_user(user=self.request.user) def get_extra_context(self): context = super(RecentAccessDocumentListView, self).get_extra_context()