diff --git a/mayan/apps/authentication/apps.py b/mayan/apps/authentication/apps.py index aeb0ca7a3c..86b2643208 100644 --- a/mayan/apps/authentication/apps.py +++ b/mayan/apps/authentication/apps.py @@ -5,6 +5,7 @@ import logging from django.utils.translation import ugettext_lazy as _ from common import MayanAppConfig, menu_user +from navigation.classes import Separator, Text from .links import link_logout, link_password_change @@ -21,6 +22,6 @@ class AuthenticationApp(MayanAppConfig): menu_user.bind_links( links=( - link_password_change, link_logout + Separator(), link_password_change, link_logout ), position=99 ) diff --git a/mayan/apps/cabinets/events.py b/mayan/apps/cabinets/events.py index 63ed2fb8fd..8e389c51b5 100644 --- a/mayan/apps/cabinets/events.py +++ b/mayan/apps/cabinets/events.py @@ -2,13 +2,13 @@ from __future__ import absolute_import, unicode_literals from django.utils.translation import ugettext_lazy as _ -from events.classes import Event +from events import EventTypeNamespace -event_cabinets_add_document = Event( - name='cabinets_add_document', - label=_('Document added to cabinet') +namespace = EventTypeNamespace(name='cabinets', label=_('Cabinets')) + +event_cabinets_add_document = namespace.add_event_type( + label=_('Document added to cabinet'), name='add_document' ) -event_cabinets_remove_document = Event( - name='cabinets_remove_document', - label=_('Document removed from cabinet') +event_cabinets_remove_document = namespace.add_event_type( + label=_('Document removed from cabinet'), name='remove_document' ) diff --git a/mayan/apps/checkouts/apps.py b/mayan/apps/checkouts/apps.py index 7b78e253ab..172791d28b 100644 --- a/mayan/apps/checkouts/apps.py +++ b/mayan/apps/checkouts/apps.py @@ -10,11 +10,17 @@ from django.utils.translation import ugettext_lazy as _ from acls import ModelPermission from common import MayanAppConfig, menu_facet, menu_main, menu_sidebar +from common.classes import DashboardWidget from common.dashboards import dashboard_main +from events import ModelEventType from mayan.celery import app from rest_api.classes import APIEndPoint from .dashboard_widgets import widget_checkouts +from .events import ( + event_document_auto_check_in, event_document_check_in, + event_document_check_out, event_document_forceful_check_in +) from .handlers import check_new_version_creation from .links import ( link_checkin_document, link_checkout_document, link_checkout_info, @@ -72,6 +78,13 @@ class CheckoutsApp(MayanAppConfig): ) ) + ModelEventType.register( + model=Document, event_types=( + event_document_auto_check_in, event_document_check_in, + event_document_check_out, event_document_forceful_check_in + ) + ) + ModelPermission.register( model=Document, permissions=( permission_document_checkout, diff --git a/mayan/apps/checkouts/events.py b/mayan/apps/checkouts/events.py index 854759ada8..2ce427beea 100644 --- a/mayan/apps/checkouts/events.py +++ b/mayan/apps/checkouts/events.py @@ -2,19 +2,21 @@ from __future__ import absolute_import, unicode_literals from django.utils.translation import ugettext_lazy as _ -from events.classes import Event +from events import EventTypeNamespace -event_document_auto_check_in = Event( - name='checkouts_document_auto_check_in', +namespace = EventTypeNamespace(name='checkouts', label=_('Checkouts')) + +event_document_auto_check_in = namespace.add_event_type( + name='document_auto_check_in', label=_('Document automatically checked in') ) -event_document_check_in = Event( - name='checkouts_document_check_in', label=_('Document checked in') +event_document_check_in = namespace.add_event_type( + name='document_check_in', label=_('Document checked in') ) -event_document_check_out = Event( - name='checkouts_document_check_out', label=_('Document checked out') +event_document_check_out = namespace.add_event_type( + name='document_check_out', label=_('Document checked out') ) -event_document_forceful_check_in = Event( - name='checkouts_document_forceful_check_in', +event_document_forceful_check_in = namespace.add_event_type( + name='document_forceful_check_in', label=_('Document forcefully checked in') ) diff --git a/mayan/apps/common/apps.py b/mayan/apps/common/apps.py index 79e3ba2c3a..d0033d7ad6 100644 --- a/mayan/apps/common/apps.py +++ b/mayan/apps/common/apps.py @@ -67,8 +67,8 @@ class MayanAppConfig(apps.AppConfig): except ImportError as exception: if force_text(exception) not in ('No module named urls', 'No module named \'{}.urls\''.format(self.name)): logger.error( - 'Import time error when running AppConfig.ready(). Check ' - 'apps.py, urls.py, views.py, etc.' + 'Import time error when running AppConfig.ready() of app ' + '"%s".', self.name ) raise exception @@ -127,7 +127,6 @@ class CommonApp(MayanAppConfig): Text(text=CommonApp.get_user_label_text), Separator(), link_current_user_details, link_current_user_edit, link_current_user_locale_profile_edit, - Separator() ) ) diff --git a/mayan/apps/document_comments/apps.py b/mayan/apps/document_comments/apps.py index d79f3bd7b3..9c3fff8d3b 100644 --- a/mayan/apps/document_comments/apps.py +++ b/mayan/apps/document_comments/apps.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from django.apps import apps from django.utils.translation import ugettext_lazy as _ @@ -6,9 +6,13 @@ from django.utils.translation import ugettext_lazy as _ from acls import ModelPermission from common import MayanAppConfig, menu_facet, menu_object, menu_sidebar from documents.search import document_page_search, document_search +from events import ModelEventType from navigation import SourceColumn from rest_api.classes import APIEndPoint +from .events import ( + event_document_comment_create, event_document_comment_delete +) from .links import ( link_comment_add, link_comment_delete, link_comments_for_document ) @@ -36,6 +40,12 @@ class DocumentCommentsApp(MayanAppConfig): Comment = self.get_model('Comment') + ModelEventType.register( + model=Document, event_types=( + event_document_comment_create, event_document_comment_delete + ) + ) + ModelPermission.register( model=Document, permissions=( permission_comment_create, permission_comment_delete, diff --git a/mayan/apps/document_comments/events.py b/mayan/apps/document_comments/events.py index 2a8eaf1860..1876854d43 100644 --- a/mayan/apps/document_comments/events.py +++ b/mayan/apps/document_comments/events.py @@ -2,13 +2,15 @@ from __future__ import absolute_import, unicode_literals from django.utils.translation import ugettext_lazy as _ -from events.classes import Event +from events import EventTypeNamespace -event_document_comment_create = Event( - name='document_comment_create', - label=_('Document comment created') +namespace = EventTypeNamespace( + name='document_comments', label=_('Document comments') ) -event_document_comment_delete = Event( - name='document_comment_delete', - label=_('Document comment deleted') + +event_document_comment_create = namespace.add_event_type( + name='create', label=_('Document comment created') +) +event_document_comment_delete = namespace.add_event_type( + name='delete', label=_('Document comment deleted') ) diff --git a/mayan/apps/document_parsing/events.py b/mayan/apps/document_parsing/events.py index 875527e911..2c90d72aae 100644 --- a/mayan/apps/document_parsing/events.py +++ b/mayan/apps/document_parsing/events.py @@ -2,13 +2,15 @@ from __future__ import absolute_import, unicode_literals from django.utils.translation import ugettext_lazy as _ -from events.classes import Event +from events import EventTypeNamespace -event_parsing_document_version_submit = Event( - name='parsing_document_version_submit', - label=_('Document version submitted for parsing') +namespace = EventTypeNamespace( + name='document_parsing', label=_('Document parsing') ) -event_parsing_document_version_finish = Event( - name='parsing_document_version_finish', - label=_('Document version parsing finished') + +event_parsing_document_version_submit = namespace.add_event_type( + label=_('Document version submitted for parsing'), name='version_submit' +) +event_parsing_document_version_finish = namespace.add_event_type( + label=_('Document version parsing finished'), name='version_finish' ) diff --git a/mayan/apps/document_states/handlers.py b/mayan/apps/document_states/handlers.py index a32b04947b..f874c12fbd 100644 --- a/mayan/apps/document_states/handlers.py +++ b/mayan/apps/document_states/handlers.py @@ -4,7 +4,7 @@ from django.apps import apps from django.utils.translation import ugettext_lazy as _ from document_indexing.tasks import task_index_document -from events.classes import Event +from events.classes import EventType def handler_index_document(sender, **kwargs): @@ -42,7 +42,7 @@ def handler_trigger_transition(sender, **kwargs): transition = list(set(trigger_transitions) & set(workflow_instance.get_transition_choices()))[0] workflow_instance.do_transition( - comment=_('Event trigger: %s') % Event.get(name=action.verb).label, + comment=_('Event trigger: %s') % EventType.get(name=action.verb).label, transition=transition ) diff --git a/mayan/apps/document_states/migrations/0005_auto_20170803_0638.py b/mayan/apps/document_states/migrations/0005_auto_20170803_0638.py index 22bf7c813f..adc52ca6a7 100644 --- a/mayan/apps/document_states/migrations/0005_auto_20170803_0638.py +++ b/mayan/apps/document_states/migrations/0005_auto_20170803_0638.py @@ -9,7 +9,7 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('events', '0001_initial'), + ('events', '0005_auto_20170731_0452'), ('document_states', '0004_workflow_internal_name'), ] diff --git a/mayan/apps/document_states/models.py b/mayan/apps/document_states/models.py index 3fd09ec999..7b2a1eb53c 100644 --- a/mayan/apps/document_states/models.py +++ b/mayan/apps/document_states/models.py @@ -17,7 +17,7 @@ from django.utils.translation import ugettext_lazy as _ from acls.models import AccessControlList from common.validators import validate_internal_name from documents.models import Document, DocumentType -from events.models import EventType +from events.models import StoredEventType from permissions import Permission from .error_logs import error_log_state_actions @@ -306,7 +306,8 @@ class WorkflowTransitionTriggerEvent(models.Model): related_name='trigger_events', verbose_name=_('Transition') ) event_type = models.ForeignKey( - EventType, on_delete=models.CASCADE, verbose_name=_('Event type') + StoredEventType, on_delete=models.CASCADE, + verbose_name=_('Event type') ) class Meta: diff --git a/mayan/apps/document_states/views.py b/mayan/apps/document_states/views.py index ccf5e8d25c..88da580ea3 100644 --- a/mayan/apps/document_states/views.py +++ b/mayan/apps/document_states/views.py @@ -17,8 +17,8 @@ from common.views import ( ) from documents.models import Document from documents.views import DocumentListView -from events.classes import Event -from events.models import EventType +from events.classes import EventType +from events.models import StoredEventType from .classes import WorkflowAction from .forms import ( @@ -675,7 +675,7 @@ class WorkflowStateListView(SingleObjectListView): class SetupWorkflowTransitionTriggerEventListView(FormView): form_class = WorkflowTransitionTriggerEventRelationshipFormSet - submodel = EventType + submodel = StoredEventType def dispatch(self, *args, **kwargs): messages.warning( @@ -689,7 +689,7 @@ class SetupWorkflowTransitionTriggerEventListView(FormView): user=self.request.user, obj=self.get_object().workflow ) - Event.refresh() + EventType.refresh() return super( SetupWorkflowTransitionTriggerEventListView, self ).dispatch(*args, **kwargs) @@ -735,8 +735,10 @@ class SetupWorkflowTransitionTriggerEventListView(FormView): initial = [] # Return the queryset by name from the sorted list of the class - event_type_ids = [event_type.name for event_type in Event.all()] - event_type_queryset = EventType.objects.filter(name__in=event_type_ids) + event_type_ids = [event_type.id for event_type in Event.all()] + event_type_queryset = StoredEventType.objects.filter( + name__in=event_type_ids + ) for event_type in event_type_queryset: initial.append({ diff --git a/mayan/apps/documents/apps.py b/mayan/apps/documents/apps.py index 2f65a0d70d..ef0fc48588 100644 --- a/mayan/apps/documents/apps.py +++ b/mayan/apps/documents/apps.py @@ -23,7 +23,11 @@ from converter.permissions import ( permission_transformation_delete, permission_transformation_edit, permission_transformation_view, ) -from events.links import link_events_for_object +from events import ModelEventType +from events.links import ( + link_events_for_object, link_object_event_types_user_subcriptions_list, + link_object_event_types_user_subcriptions_list_with_icon +) from events.permissions import permission_events_view from mayan.celery import app from mayan_statistics.classes import StatisticNamespace, CharJSLine @@ -36,6 +40,12 @@ from .dashboard_widgets import ( widget_new_documents_this_month, widget_pages_per_month, widget_total_documents ) +from .events import ( + event_document_create, event_document_download, + event_document_properties_edit, event_document_type_change, + event_document_new_version, event_document_version_revert, + event_document_view +) from .handlers import ( create_default_document_type, handler_scan_duplicates_for ) @@ -142,6 +152,19 @@ class DocumentsApp(MayanAppConfig): label=_('MIME type'), name='versions__mimetype', type_name='field' ) + ModelEventType.register( + model=DocumentType, event_types=( + event_document_create, + ) + ) + ModelEventType.register( + model=Document, event_types=( + event_document_download, event_document_properties_edit, + event_document_type_change, event_document_new_version, + event_document_version_revert, event_document_view + ) + ) + ModelPermission.register( model=Document, permissions=( permission_acl_edit, permission_acl_view, @@ -389,7 +412,8 @@ class DocumentsApp(MayanAppConfig): menu_object.bind_links( links=( link_document_type_edit, link_document_type_filename_list, - link_acl_list, link_document_type_delete + link_acl_list, link_object_event_types_user_subcriptions_list, + link_document_type_delete ), sources=(DocumentType,) ) menu_object.bind_links( @@ -446,8 +470,11 @@ class DocumentsApp(MayanAppConfig): links=(link_document_properties,), sources=(Document,), position=2 ) menu_facet.bind_links( - links=(link_events_for_object, link_document_version_list,), - sources=(Document,), position=2 + links=( + link_events_for_object, + link_object_event_types_user_subcriptions_list_with_icon, + link_document_version_list, + ), sources=(Document,), position=2 ) menu_facet.bind_links(links=(link_document_pages,), sources=(Document,)) diff --git a/mayan/apps/documents/events.py b/mayan/apps/documents/events.py index c474b6fbbd..16a0bdc3bb 100644 --- a/mayan/apps/documents/events.py +++ b/mayan/apps/documents/events.py @@ -2,29 +2,28 @@ from __future__ import absolute_import, unicode_literals from django.utils.translation import ugettext_lazy as _ -from events.classes import Event +from events import EventTypeNamespace -event_document_create = Event( - name='documents_document_create', label=_('Document created') +namespace = EventTypeNamespace(name='documents', label=_('Documents')) + +event_document_create = namespace.add_event_type( + name='document_create', label=_('Document created') ) -event_document_download = Event( - name='documents_document_download', - label=_('Document downloaded') +event_document_download = namespace.add_event_type( + name='document_download', label=_('Document downloaded') ) -event_document_properties_edit = Event( - name='documents_document_edit', label=_('Document properties edited') +event_document_properties_edit = namespace.add_event_type( + name='document_edit', label=_('Document properties edited') ) -event_document_type_change = Event( - name='documents_document_type_change', label=_('Document type changed') +event_document_type_change = namespace.add_event_type( + name='document_type_change', label=_('Document type changed') ) -event_document_new_version = Event( - name='documents_document_new_version', label=_('New version uploaded') +event_document_new_version = namespace.add_event_type( + name='document_new_version', label=_('New version uploaded') ) -event_document_version_revert = Event( - name='documents_document_version_revert', - label=_('Document version reverted') +event_document_version_revert = namespace.add_event_type( + name='document_version_revert', label=_('Document version reverted') ) -event_document_view = Event( - name='documents_document_view', - label=_('Document viewed') +event_document_view = namespace.add_event_type( + name='document_view', label=_('Document viewed') ) diff --git a/mayan/apps/documents/models.py b/mayan/apps/documents/models.py index 93b47f2049..b1e966cb77 100644 --- a/mayan/apps/documents/models.py +++ b/mayan/apps/documents/models.py @@ -222,9 +222,13 @@ class Document(models.Model): if new_document: if user: self.add_as_recent_document_for_user(user) - event_document_create.commit(actor=user, target=self) + event_document_create.commit( + actor=user, target=self, action_object=self.document_type + ) else: - event_document_create.commit(target=self) + event_document_create.commit( + target=self, action_object=self.document_type + ) else: if _commit_events: event_document_properties_edit.commit(actor=user, target=self) diff --git a/mayan/apps/documents/tests/test_events.py b/mayan/apps/documents/tests/test_events.py index 09e59364b5..65c9bcd4e1 100644 --- a/mayan/apps/documents/tests/test_events.py +++ b/mayan/apps/documents/tests/test_events.py @@ -64,7 +64,7 @@ class DocumentEventsTestCase(GenericDocumentViewTestCase): event = Action.objects.any(obj=self.document).first() - self.assertEqual(event.verb, event_document_download.name) + self.assertEqual(event.verb, event_document_download.id) self.assertEqual(event.target, self.document) self.assertEqual(event.actor, self.user) @@ -98,6 +98,6 @@ class DocumentEventsTestCase(GenericDocumentViewTestCase): event = Action.objects.any(obj=self.document).first() - self.assertEqual(event.verb, event_document_view.name) + self.assertEqual(event.verb, event_document_view.id) self.assertEqual(event.target, self.document) self.assertEqual(event.actor, self.user) diff --git a/mayan/apps/events/__init__.py b/mayan/apps/events/__init__.py index a0227939b4..d4884e06cb 100644 --- a/mayan/apps/events/__init__.py +++ b/mayan/apps/events/__init__.py @@ -1,5 +1,5 @@ from __future__ import unicode_literals -from .classes import Event # NOQA +from .classes import EventTypeNamespace, ModelEventType # NOQA default_app_config = 'events.apps.EventsApp' diff --git a/mayan/apps/events/admin.py b/mayan/apps/events/admin.py index dfab0098f0..28bcd16b21 100644 --- a/mayan/apps/events/admin.py +++ b/mayan/apps/events/admin.py @@ -2,9 +2,19 @@ from __future__ import unicode_literals from django.contrib import admin -from .models import EventType +from .models import EventSubscription, Notification, StoredEventType -@admin.register(EventType) -class EventTypeAdmin(admin.ModelAdmin): +@admin.register(EventSubscription) +class EventSubscriptionAdmin(admin.ModelAdmin): + list_display = ('user', 'stored_event_type') + + +@admin.register(StoredEventType) +class StoredEventTypeAdmin(admin.ModelAdmin): readonly_fields = ('name', '__str__') + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ('user', 'action', 'read') diff --git a/mayan/apps/events/api_views.py b/mayan/apps/events/api_views.py index 35cc03c14c..227e813c9b 100644 --- a/mayan/apps/events/api_views.py +++ b/mayan/apps/events/api_views.py @@ -10,9 +10,13 @@ from rest_framework import generics from acls.models import AccessControlList from rest_api.permissions import MayanPermission -from .classes import Event +from .classes import EventType, EventTypeNamespace +from .models import Notification from .permissions import permission_events_view -from .serializers import EventSerializer, EventTypeSerializer +from .serializers import ( + EventSerializer, EventTypeSerializer, EventTypeNamespaceSerializer, + NotificationSerializer +) class APIObjectEventListView(generics.ListAPIView): @@ -46,13 +50,72 @@ class APIObjectEventListView(generics.ListAPIView): return any_stream(obj) +class APIEventTypeNamespaceDetailView(generics.RetrieveAPIView): + """ + Returns the details of an event type namespace. + """ + serializer_class = EventTypeNamespaceSerializer + + def get_object(self): + try: + return EventTypeNamespace.get(name=self.kwargs['name']) + except KeyError: + raise Http404 + + +class APIEventTypeNamespaceListView(generics.ListAPIView): + """ + Returns a list of all the available event type namespaces. + """ + + serializer_class = EventTypeNamespaceSerializer + queryset = EventTypeNamespace.all() + + def get_serializer_context(self): + return { + 'format': self.format_kwarg, + 'request': self.request, + 'view': self + } + + +class APIEventTypeNamespaceEventTypeListView(generics.ListAPIView): + """ + Returns a list of all the available event types from a namespaces. + """ + + serializer_class = EventTypeSerializer + + def get_queryset(self): + try: + return EventTypeNamespace.get( + name=self.kwargs['name'] + ).get_event_types() + except KeyError: + raise Http404 + + def get_serializer_context(self): + return { + 'format': self.format_kwarg, + 'request': self.request, + 'view': self + } + + class APIEventTypeListView(generics.ListAPIView): """ Returns a list of all the available event types. """ serializer_class = EventTypeSerializer - queryset = sorted(Event.all(), key=lambda event: event.name) + queryset = EventType.all() + + def get_serializer_context(self): + return { + 'format': self.format_kwarg, + 'request': self.request, + 'view': self + } class APIEventListView(generics.ListAPIView): @@ -64,3 +127,20 @@ class APIEventListView(generics.ListAPIView): permission_classes = (MayanPermission,) queryset = Action.objects.all() serializer_class = EventSerializer + + def get_serializer_context(self): + return { + 'format': self.format_kwarg, + 'request': self.request, + 'view': self + } + + +class APINotificationListView(generics.ListAPIView): + """ + Return a list of notifications for the current user. + """ + serializer_class = NotificationSerializer + + def get_queryset(self): + return Notification.objects.filter(user=self.request.user) diff --git a/mayan/apps/events/apps.py b/mayan/apps/events/apps.py index 5e0b765167..4f1485ac68 100644 --- a/mayan/apps/events/apps.py +++ b/mayan/apps/events/apps.py @@ -3,11 +3,19 @@ from __future__ import unicode_literals from django.apps import apps from django.utils.translation import ugettext_lazy as _ -from common import MayanAppConfig, menu_tools +from common import ( + MayanAppConfig, menu_main, menu_object, menu_secondary, menu_tools, + menu_user +) +from common.widgets import two_state_template from navigation import SourceColumn from rest_api.classes import APIEndPoint -from .links import link_events_list +from .links import ( + link_events_list, link_event_types_subscriptions_list, + link_notification_mark_read, link_notification_mark_read_all, + link_user_notifications_list, +) from .licenses import * # NOQA from .widgets import event_object_link, event_type_link @@ -28,6 +36,8 @@ class EventsApp(MayanAppConfig): def ready(self): super(EventsApp, self).ready() Action = apps.get_model(app_label='actstream', model_name='Action') + Notification = self.get_model(model_name='Notification') + StoredEventType = self.get_model(model_name='StoredEventType') APIEndPoint(app=self, version_string='1') @@ -39,7 +49,7 @@ class EventsApp(MayanAppConfig): func=lambda context: event_actor(context['object']) ) SourceColumn( - source=Action, label=_('Verb'), + source=Action, label=_('Event'), func=lambda context: event_type_link(context['object']) ) SourceColumn( @@ -49,4 +59,44 @@ class EventsApp(MayanAppConfig): ) ) + SourceColumn( + source=StoredEventType, label=_('Namespace'), attribute='namespace' + ) + SourceColumn( + source=StoredEventType, label=_('Label'), attribute='label' + ) + + SourceColumn( + source=Notification, label=_('Timestamp'), + attribute='action.timestamp' + ) + SourceColumn( + source=Notification, label=_('Actor'), attribute='action.actor' + ) + SourceColumn( + source=Notification, label=_('Event'), + func=lambda context: event_type_link(context['object'].action) + ) + SourceColumn( + source=Notification, label=_('Target'), + func=lambda context: event_object_link(context['object'].action) + ) + SourceColumn( + source=Notification, label=_('Seen'), + func=lambda context: two_state_template( + state=context['object'].read + ) + ) + + menu_main.bind_links( + links=(link_user_notifications_list,), position=99 + ) + menu_object.bind_links( + links=(link_notification_mark_read,), sources=(Notification,) + ) + menu_secondary.bind_links( + links=(link_notification_mark_read_all,), + sources=('events:user_notifications_list',) + ) menu_tools.bind_links(links=(link_events_list,)) + menu_user.bind_links(links=(link_event_types_subscriptions_list,)) diff --git a/mayan/apps/events/classes.py b/mayan/apps/events/classes.py index 9e5d247eff..6ac26769ab 100644 --- a/mayan/apps/events/classes.py +++ b/mayan/apps/events/classes.py @@ -1,70 +1,231 @@ from __future__ import unicode_literals +import logging + from django.apps import apps -from django.utils.encoding import force_text +from django.contrib.auth import get_user_model +from django.core.exceptions import PermissionDenied +from django.utils.encoding import force_text, python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ from actstream import action +from .permissions import permission_events_view -class Event(object): +logger = logging.getLogger(__name__) + + +@python_2_unicode_compatible +class EventTypeNamespace(object): _registry = {} @classmethod def all(cls): - return Event.sort(event_type_list=cls._registry.values()) + return sorted(cls._registry.values()) + + @classmethod + def get(cls, name): + return cls._registry[name] + + def __init__(self, name, label): + self.name = name + self.label = label + self.event_types = [] + self.__class__._registry[name] = self + + def __str__(self): + return force_text(self.label) + + def add_event_type(self, name, label): + event_type = EventType(namespace=self, name=name, label=label) + self.event_types.append(event_type) + return event_type + + def get_event_types(self): + return EventType.sort(event_type_list=self.event_types) + + +@python_2_unicode_compatible +class EventType(object): + _registry = {} + + @staticmethod + def sort(event_type_list): + return sorted( + event_type_list, key=lambda x: (x.namespace.label, x.label) + ) + + @classmethod + def all(cls): + # Return sorted permisions by namespace.name + return EventType.sort(event_type_list=cls._registry.values()) @classmethod def get(cls, name): try: return cls._registry[name] except KeyError: - raise KeyError( - _('Unknown or obsolete event type: {0}'.format(name)) - ) - - @classmethod - def get_label(cls, name): - try: - return cls.get(name=name).label - except KeyError as exception: - return force_text(exception) + return _('Unknown or obsolete event type: %s') % name @classmethod def refresh(cls): for event_type in cls.all(): - event_type.get_type() + event_type.get_stored_event_type() - @staticmethod - def sort(event_type_list): - return sorted( - event_type_list, key=lambda x: x.label - ) - - def __init__(self, name, label): + def __init__(self, namespace, name, label): + self.namespace = namespace self.name = name self.label = label - self.event_type = None - self.__class__._registry[name] = self + self.stored_event_type = None + self.__class__._registry[self.id] = self - def get_type(self): - if not self.event_type: - EventType = apps.get_model('events', 'EventType') - - self.event_type, created = EventType.objects.get_or_create( - name=self.name - ) - - return self.event_type + def __str__(self): + return force_text('{}: {}'.format(self.namespace.label, self.label)) def commit(self, actor=None, action_object=None, target=None): - if not self.event_type: - EventType = apps.get_model('events', 'EventType') - self.event_type, created = EventType.objects.get_or_create( - name=self.name - ) + AccessControlList = apps.get_model( + app_label='acls', model_name='AccessControlList' + ) + Action = apps.get_model( + app_label='actstream', model_name='Action' + ) + ContentType = apps.get_model( + app_label='contenttypes', model_name='ContentType' + ) + Notification = apps.get_model( + app_label='events', model_name='Notification' + ) - action.send( - actor or target, actor=actor, verb=self.name, + results = action.send( + actor or target, actor=actor, verb=self.id, action_object=action_object, target=target ) + + for handler, result in results: + if isinstance(result, Action): + for user in get_user_model().objects.all(): + notification = None + + if user.event_subscriptions.filter(stored_event_type__name=result.verb).exists(): + if result.target: + try: + AccessControlList.objects.check_access( + permissions=permission_events_view, + user=user, obj=result.target + ) + except PermissionDenied: + pass + else: + notification = Notification.objects.create(action=result, user=user) + else: + notification = Notification.objects.create(action=result, user=user) + + if result.target: + content_type = ContentType.objects.get_for_model(model=result.target) + + relationship = user.object_subscriptions.filter( + content_type=content_type, + object_id=result.target.pk, + stored_event_type__name=result.verb + ) + + if relationship.exists(): + try: + AccessControlList.objects.check_access( + permissions=permission_events_view, + user=user, obj=result.target + ) + except PermissionDenied: + pass + else: + notification = Notification.objects.create(action=result, user=user) + if not notification and result.action_object: + content_type = ContentType.objects.get_for_model(model=result.action_object) + + relationship = user.object_subscriptions.filter( + content_type=content_type, + object_id=result.action_object.pk, + stored_event_type__name=result.verb + ) + + if relationship.exists(): + try: + AccessControlList.objects.check_access( + permissions=permission_events_view, + user=user, obj=result.action_object + ) + except PermissionDenied: + pass + else: + notification = Notification.objects.create(action=result, user=user) + + def get_stored_event_type(self): + if not self.stored_event_type: + StoredEventType = apps.get_model('events', 'StoredEventType') + + self.stored_event_type, created = StoredEventType.objects.get_or_create( + name=self.id + ) + + return self.stored_event_type + + @property + def id(self): + return '%s.%s' % (self.namespace.name, self.name) + + +class ModelEventType(object): + """ + Class to allow matching a model to a specific set of events. + """ + _inheritances = {} + _proxies = {} + _registry = {} + + @classmethod + def get_for_class(cls, klass): + return cls._registry.get(klass, ()) + + @classmethod + def get_for_instance(cls, instance): + StoredEventType = apps.get_model( + app_label='events', model_name='StoredEventType' + ) + + events = [] + + class_events = cls._registry.get(type(instance)) + + if class_events: + events.extend(class_events) + + proxy = cls._proxies.get(type(instance)) + + if proxy: + events.extend(cls._registry.get(proxy)) + + pks = [ + event.id for event in set(events) + ] + + return EventType.sort( + event_type_list=StoredEventType.objects.filter(name__in=pks) + ) + + @classmethod + def get_inheritance(cls, model): + return cls._inheritances[model] + + @classmethod + def register(cls, model, event_types): + cls._registry.setdefault(model, []) + for event_type in event_types: + cls._registry[model].append(event_type) + + @classmethod + def register_inheritance(cls, model, related): + cls._inheritances[model] = related + + @classmethod + def register_proxy(cls, source, model): + cls._proxies[model] = source diff --git a/mayan/apps/events/forms.py b/mayan/apps/events/forms.py new file mode 100644 index 0000000000..a9617c909e --- /dev/null +++ b/mayan/apps/events/forms.py @@ -0,0 +1,122 @@ +from __future__ import unicode_literals + +from django import forms +from django.forms.formsets import formset_factory +from django.utils.translation import ugettext_lazy as _ + +from .models import EventSubscription, ObjectEventSubscription + + +class EventTypeUserRelationshipForm(forms.Form): + namespace = forms.CharField( + label=_('Namespace'), required=False, + widget=forms.TextInput(attrs={'readonly': 'readonly'}) + ) + label = forms.CharField( + label=_('Label'), required=False, + widget=forms.TextInput(attrs={'readonly': 'readonly'}) + ) + subscription = forms.ChoiceField( + label=_('Subscription'), + widget=forms.RadioSelect(), choices=( + ('none', _('No')), + ('subscribed', _('Subscribed')), + ) + ) + + def __init__(self, *args, **kwargs): + super(EventTypeUserRelationshipForm, self).__init__( + *args, **kwargs + ) + + self.fields['namespace'].initial = self.initial['stored_event_type'].namespace + self.fields['label'].initial = self.initial['stored_event_type'].label + + subscription = EventSubscription.objects.get_for( + stored_event_type=self.initial['stored_event_type'], + user=self.initial['user'], + ) + + if subscription.exists(): + self.fields['subscription'].initial = 'subscribed' + else: + self.fields['subscription'].initial = 'none' + + def save(self): + subscription = EventSubscription.objects.get_for( + stored_event_type=self.initial['stored_event_type'], + user=self.initial['user'], + ) + + if self.cleaned_data['subscription'] == 'none': + subscription.delete() + elif self.cleaned_data['subscription'] == 'subscribed': + if not subscription.exists(): + EventSubscription.objects.create_for( + stored_event_type=self.initial['stored_event_type'], + user=self.initial['user'] + ) + + +EventTypeUserRelationshipFormSet = formset_factory( + EventTypeUserRelationshipForm, extra=0 +) + + +class ObjectEventTypeUserRelationshipForm(forms.Form): + namespace = forms.CharField( + label=_('Namespace'), required=False, + widget=forms.TextInput(attrs={'readonly': 'readonly'}) + ) + label = forms.CharField( + label=_('Label'), required=False, + widget=forms.TextInput(attrs={'readonly': 'readonly'}) + ) + subscription = forms.ChoiceField( + label=_('Subscription'), + widget=forms.RadioSelect(), choices=( + ('none', _('No')), + ('subscribed', _('Subscribed')), + ) + ) + + def __init__(self, *args, **kwargs): + super(ObjectEventTypeUserRelationshipForm, self).__init__( + *args, **kwargs + ) + + self.fields['namespace'].initial = self.initial['stored_event_type'].namespace + self.fields['label'].initial = self.initial['stored_event_type'].label + + subscription = ObjectEventSubscription.objects.get_for( + obj=self.initial['object'], + stored_event_type=self.initial['stored_event_type'], + user=self.initial['user'], + ) + + if subscription.exists(): + self.fields['subscription'].initial = 'subscribed' + else: + self.fields['subscription'].initial = 'none' + + def save(self): + subscription = ObjectEventSubscription.objects.get_for( + obj=self.initial['object'], + stored_event_type=self.initial['stored_event_type'], + user=self.initial['user'], + ) + + if self.cleaned_data['subscription'] == 'none': + subscription.delete() + elif self.cleaned_data['subscription'] == 'subscribed': + if not subscription.exists(): + ObjectEventSubscription.objects.create_for( + obj=self.initial['object'], + stored_event_type=self.initial['stored_event_type'], + user=self.initial['user'] + ) + + +ObjectEventTypeUserRelationshipFormSet = formset_factory( + ObjectEventTypeUserRelationshipForm, extra=0 +) diff --git a/mayan/apps/events/links.py b/mayan/apps/events/links.py index b8ea83d25c..63bc728cf8 100644 --- a/mayan/apps/events/links.py +++ b/mayan/apps/events/links.py @@ -26,12 +26,44 @@ def get_kwargs_factory(variable_name): return get_kwargs +def get_notification_count(context): + return context['request'].user.notifications.filter(read=False).count() + + link_events_list = Link( icon='fa fa-list-ol', permissions=(permission_events_view,), text=_('Events'), view='events:events_list' ) +link_events_details = Link( + text=_('Events'), view='events:events_list' +) link_events_for_object = Link( icon='fa fa-list-ol', permissions=(permission_events_view,), text=_('Events'), view='events:events_for_object', kwargs=get_kwargs_factory('resolved_object') ) +link_event_types_subscriptions_list = Link( + icon='fa fa-list-ol', text=_('Event subscriptions'), + view='events:event_types_user_subcriptions_list' +) +link_notification_mark_read = Link( + args='object.pk', text=_('Mark as seen'), + view='events:notification_mark_read' +) +link_notification_mark_read_all = Link( + text=_('Mark all as seen'), view='events:notification_mark_read_all' +) +link_object_event_types_user_subcriptions_list = Link( + kwargs=get_kwargs_factory('resolved_object'), + permissions=(permission_events_view,), text=_('Subscriptions'), + view='events:object_event_types_user_subcriptions_list', +) +link_object_event_types_user_subcriptions_list_with_icon = Link( + kwargs=get_kwargs_factory('resolved_object'), icon='fa fa-rss', + permissions=(permission_events_view,), text=_('Subscriptions'), + view='events:object_event_types_user_subcriptions_list', +) +link_user_notifications_list = Link( + icon='fa fa-bell', text=get_notification_count, + view='events:user_notifications_list' +) diff --git a/mayan/apps/events/managers.py b/mayan/apps/events/managers.py new file mode 100644 index 0000000000..ebb2dd8739 --- /dev/null +++ b/mayan/apps/events/managers.py @@ -0,0 +1,34 @@ +from __future__ import unicode_literals + +from django.contrib.contenttypes.models import ContentType +from django.db import models + + +class EventSubscriptionManager(models.Manager): + def create_for(self, stored_event_type, user): + return self.create( + stored_event_type=stored_event_type, user=user + ) + + def get_for(self, stored_event_type, user): + return self.filter( + stored_event_type=stored_event_type, user=user + ) + + +class ObjectEventSubscriptionManager(models.Manager): + def create_for(self, obj, stored_event_type, user): + content_type = ContentType.objects.get_for_model(model=obj) + + return self.create( + content_type=content_type, object_id=obj.pk, + stored_event_type=stored_event_type, user=user + ) + + def get_for(self, obj, stored_event_type, user): + content_type = ContentType.objects.get_for_model(model=obj) + + return self.filter( + content_type=content_type, object_id=obj.pk, + stored_event_type=stored_event_type, user=user + ) diff --git a/mayan/apps/events/migrations/0002_eventsubscription.py b/mayan/apps/events/migrations/0002_eventsubscription.py new file mode 100644 index 0000000000..8c84935e1a --- /dev/null +++ b/mayan/apps/events/migrations/0002_eventsubscription.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-07-29 07:04 +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), + ('events', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='EventSubscription', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='events.EventType', verbose_name='Event type')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Event subscription', + 'verbose_name_plural': 'Event subscriptions', + }, + ), + ] diff --git a/mayan/apps/events/migrations/0003_notification.py b/mayan/apps/events/migrations/0003_notification.py new file mode 100644 index 0000000000..343ac7b91c --- /dev/null +++ b/mayan/apps/events/migrations/0003_notification.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-07-29 07:23 +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), + ('actstream', '0002_remove_action_data'), + ('events', '0002_eventsubscription'), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('read', models.BooleanField(default=False, verbose_name='Read')), + ('action', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='actstream.Action', verbose_name='Action')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Notification', + 'verbose_name_plural': 'Notifications', + }, + ), + ] diff --git a/mayan/apps/events/migrations/0004_auto_20170731_0423.py b/mayan/apps/events/migrations/0004_auto_20170731_0423.py new file mode 100644 index 0000000000..af9d7379b0 --- /dev/null +++ b/mayan/apps/events/migrations/0004_auto_20170731_0423.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-07-31 04:23 +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 = [ + ('events', '0003_notification'), + ] + + operations = [ + migrations.RenameModel( + old_name='EventType', + new_name='StoredEventType', + ), + migrations.AlterModelOptions( + name='storedeventtype', + options={'verbose_name': 'Stored event type', 'verbose_name_plural': 'Stored event types'}, + ), + migrations.RemoveField( + model_name='eventsubscription', + name='event_type', + ), + migrations.AddField( + model_name='eventsubscription', + name='stored_event_type', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='event_subscriptions', to='events.StoredEventType', verbose_name='Event type'), + preserve_default=False, + ), + migrations.AlterField( + model_name='eventsubscription', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_subscriptions', to=settings.AUTH_USER_MODEL, verbose_name='User'), + ), + ] diff --git a/mayan/apps/events/migrations/0005_auto_20170731_0452.py b/mayan/apps/events/migrations/0005_auto_20170731_0452.py new file mode 100644 index 0000000000..42b7ff7795 --- /dev/null +++ b/mayan/apps/events/migrations/0005_auto_20170731_0452.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-07-31 04:52 +from __future__ import unicode_literals + +import re + +from django.db import migrations + + +def update_event_types_names(apps, schema_editor): + Action = apps.get_model('actstream', 'Action') + StoredEventType = apps.get_model('events', 'StoredEventType') + + known_namespaces = { + 'documents_': 'documents.', + 'checkouts_': 'checkouts.', + 'document_comment_': 'document_comments.', + 'parsing_document_': 'document_parsing.', + 'ocr_': 'ocr.', + 'tag_': 'tags.', + } + + pattern = re.compile('|'.join(known_namespaces.keys())) + + for event_type in StoredEventType.objects.all(): + event_type.name = pattern.sub( + lambda x: known_namespaces[x.group()], event_type.name + ) + event_type.save() + + for action in Action.objects.all(): + action.verb = pattern.sub( + lambda x: known_namespaces[x.group()], action.verb + ) + action.save() + + +def revert_event_types_names(apps, schema_editor): + Action = apps.get_model('actstream', 'Action') + StoredEventType = apps.get_model('events', 'StoredEventType') + + known_namespaces = { + 'documents\.': 'documents_', + 'checkouts\.': 'checkouts_', + 'document_comments\.': 'document_comment_', + 'document_parsing\.': 'parsing_document_', + 'ocr\.': 'ocr_', + 'tags\.': 'tag_', + } + + pattern = re.compile('|'.join(known_namespaces.keys())) + + for event_type in StoredEventType.objects.all(): + old_name = event_type.name + new_name = pattern.sub( + lambda x: known_namespaces[x.group().replace('.', '\\.')], + event_type.name + ) + event_type.name = new_name + if old_name == new_name: + event_type.delete() + else: + event_type.save() + + for action in Action.objects.all(): + new_name = pattern.sub( + lambda x: known_namespaces[x.group().replace('.', '\\.')], + action.verb + ) + action.verb = new_name + action.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0004_auto_20170731_0423'), + ('actstream', '0001_initial'), + ] + + operations = [ + migrations.RunPython( + code=update_event_types_names, + reverse_code=revert_event_types_names + ), + ] diff --git a/mayan/apps/events/migrations/0006_objecteventsubscription.py b/mayan/apps/events/migrations/0006_objecteventsubscription.py new file mode 100644 index 0000000000..15094ce433 --- /dev/null +++ b/mayan/apps/events/migrations/0006_objecteventsubscription.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-07-31 06:40 +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 = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('events', '0005_auto_20170731_0452'), + ] + + operations = [ + migrations.CreateModel( + name='ObjectEventSubscription', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('stored_event_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='object_subscriptions', to='events.StoredEventType', verbose_name='Event type')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='object_subscriptions', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Object event subscription', + 'verbose_name_plural': 'Object event subscriptions', + }, + ), + ] diff --git a/mayan/apps/events/models.py b/mayan/apps/events/models.py index 2a57121eb9..1a8ae597fe 100644 --- a/mayan/apps/events/models.py +++ b/mayan/apps/events/models.py @@ -1,28 +1,111 @@ from __future__ import unicode_literals +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.db import models -from django.utils.encoding import python_2_unicode_compatible +from django.utils.encoding import force_text, python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ -from .classes import Event +from actstream.models import Action + +from .classes import EventType +from .managers import ( + EventSubscriptionManager, ObjectEventSubscriptionManager +) @python_2_unicode_compatible -class EventType(models.Model): +class StoredEventType(models.Model): name = models.CharField( max_length=64, unique=True, verbose_name=_('Name') ) class Meta: - verbose_name = _('Event type') - verbose_name_plural = _('Event types') + verbose_name = _('Stored event type') + verbose_name_plural = _('Stored event types') def __str__(self): - return self.get_class().label + return force_text(self.get_class()) def get_class(self): - return Event.get(name=self.name) + return EventType.get(name=self.name) @property def label(self): return self.get_class().label + + @property + def namespace(self): + return self.get_class().namespace + + +@python_2_unicode_compatible +class EventSubscription(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, db_index=True, on_delete=models.CASCADE, + related_name='event_subscriptions', verbose_name=_('User') + ) + stored_event_type = models.ForeignKey( + StoredEventType, on_delete=models.CASCADE, + related_name='event_subscriptions', verbose_name=_('Event type') + ) + + objects = EventSubscriptionManager() + + class Meta: + verbose_name = _('Event subscription') + verbose_name_plural = _('Event subscriptions') + + def __str__(self): + return force_text(self.stored_event_type) + + +@python_2_unicode_compatible +class Notification(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, db_index=True, on_delete=models.CASCADE, + related_name='notifications', verbose_name=_('User') + ) + action = models.ForeignKey( + Action, on_delete=models.CASCADE, related_name='notifications', + verbose_name=_('Action') + ) + read = models.BooleanField(default=False, verbose_name=_('Read')) + + class Meta: + ordering = ('-action__timestamp',) + verbose_name = _('Notification') + verbose_name_plural = _('Notifications') + + def __str__(self): + return force_text(self.action) + + +@python_2_unicode_compatible +class ObjectEventSubscription(models.Model): + content_type = models.ForeignKey( + ContentType, on_delete=models.CASCADE, + ) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey( + ct_field='content_type', + fk_field='object_id', + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, db_index=True, on_delete=models.CASCADE, + related_name='object_subscriptions', verbose_name=_('User') + ) + stored_event_type = models.ForeignKey( + StoredEventType, on_delete=models.CASCADE, + related_name='object_subscriptions', verbose_name=_('Event type') + ) + + objects = ObjectEventSubscriptionManager() + + class Meta: + verbose_name = _('Object event subscription') + verbose_name_plural = _('Object event subscriptions') + + def __str__(self): + return force_text(self.stored_event_type) diff --git a/mayan/apps/events/serializers.py b/mayan/apps/events/serializers.py index aa3926eb5e..3f2c7d8e20 100644 --- a/mayan/apps/events/serializers.py +++ b/mayan/apps/events/serializers.py @@ -4,30 +4,59 @@ from django.utils.six import string_types from actstream.models import Action from rest_framework import serializers +from rest_framework.reverse import reverse from common.serializers import ContentTypeSerializer from rest_api.fields import DynamicSerializerField +from user_management.serializers import UserSerializer -from .classes import Event -from .models import EventType +from .classes import EventType +from .models import Notification, StoredEventType + + +class EventTypeNamespaceSerializer(serializers.Serializer): + label = serializers.CharField() + name = serializers.CharField() + url = serializers.SerializerMethodField() + + event_types_url = serializers.HyperlinkedIdentityField( + lookup_field='name', + view_name='rest_api:event-type-namespace-event-type-list', + ) + + def get_url(self, instance): + return reverse( + 'rest_api:event-type-namespace-detail', args=( + instance.name, + ), request=self.context['request'], format=self.context['format'] + ) class EventTypeSerializer(serializers.Serializer): label = serializers.CharField() name = serializers.CharField() + id = serializers.CharField() + event_type_namespace_url = serializers.SerializerMethodField() + + def get_event_type_namespace_url(self, instance): + return reverse( + 'rest_api:event-type-namespace-detail', args=( + instance.namespace.name, + ), request=self.context['request'], format=self.context['format'] + ) def to_representation(self, instance): - if isinstance(instance, Event): + if isinstance(instance, EventType): return super(EventTypeSerializer, self).to_representation( instance ) - elif isinstance(instance, EventType): + elif isinstance(instance, StoredEventType): return super(EventTypeSerializer, self).to_representation( instance.get_class() ) elif isinstance(instance, string_types): return super(EventTypeSerializer, self).to_representation( - Event.get(name=instance) + EventType.get(name=instance) ) @@ -43,3 +72,12 @@ class EventSerializer(serializers.ModelSerializer): 'action_object_content_type', 'action_object_object_id' ) model = Action + + +class NotificationSerializer(serializers.ModelSerializer): + user = UserSerializer(read_only=True) + action = EventSerializer(read_only=True) + + class Meta: + fields = ('action', 'read', 'user') + model = Notification diff --git a/mayan/apps/events/urls.py b/mayan/apps/events/urls.py index 873a38a576..f0c96c8467 100644 --- a/mayan/apps/events/urls.py +++ b/mayan/apps/events/urls.py @@ -3,9 +3,15 @@ from __future__ import unicode_literals from django.conf.urls import url from .api_views import ( - APIEventListView, APIEventTypeListView, APIObjectEventListView + APIEventListView, APIEventTypeListView, APIEventTypeNamespaceDetailView, + APIEventTypeNamespaceEventTypeListView, APIEventTypeNamespaceListView, + APINotificationListView, APIObjectEventListView +) +from .views import ( + EventListView, EventTypeSubscriptionListView, NotificationListView, + NotificationMarkRead, NotificationMarkReadAll, ObjectEventListView, + ObjectEventTypeSubscriptionListView, VerbEventListView ) -from .views import EventListView, ObjectEventListView, VerbEventListView urlpatterns = [ url(r'^all/$', EventListView.as_view(), name='events_list'), @@ -14,16 +20,60 @@ urlpatterns = [ ObjectEventListView.as_view(), name='events_for_object' ), url( - r'^by_verb/(?P[\w\-]+)/$', VerbEventListView.as_view(), + r'^by_verb/(?P[\w\-\.]+)/$', VerbEventListView.as_view(), name='events_by_verb' ), + url( + r'^notifications/(?P\d+)/mark_read/$', + NotificationMarkRead.as_view(), name='notification_mark_read' + ), + url( + r'^notifications/all/mark_read/$', + NotificationMarkReadAll.as_view(), name='notification_mark_read_all' + ), + url( + r'^user/(?P[-\w]+)/(?P[-\w]+)/(?P\d+)/subscriptions/$', + ObjectEventTypeSubscriptionListView.as_view(), + name='object_event_types_user_subcriptions_list' + ), + url( + r'^user/event_types/subscriptions/$', + EventTypeSubscriptionListView.as_view(), + name='event_types_user_subcriptions_list' + ), + url( + r'^user/notifications/$', + NotificationListView.as_view(), + name='user_notifications_list' + ), ] api_urls = [ - url(r'^types/$', APIEventTypeListView.as_view(), name='event-type-list'), + url( + r'^event_type_namespaces/(?P[-\w]+)/$', + APIEventTypeNamespaceDetailView.as_view(), + name='event-type-namespace-detail' + ), + url( + r'^event_type_namespaces/(?P[-\w]+)/event_types/$', + APIEventTypeNamespaceEventTypeListView.as_view(), + name='event-type-namespace-event-type-list' + ), + url( + r'^event_type_namespaces/$', APIEventTypeNamespaceListView.as_view(), + name='event-type-namespace-list' + ), + url( + r'^event_types/$', APIEventTypeListView.as_view(), + name='event-type-list' + ), url(r'^events/$', APIEventListView.as_view(), name='event-list'), url( - r'^object/(?P[-\w]+)/(?P[-\w]+)/(?P\d+)/events/$', + r'^notifications/$', APINotificationListView.as_view(), + name='notification-list' + ), + url( + r'^objects/(?P[-\w]+)/(?P[-\w]+)/(?P\d+)/events/$', APIObjectEventListView.as_view(), name='object-event-list' ), ] diff --git a/mayan/apps/events/views.py b/mayan/apps/events/views.py index c2f6b6a94c..aa8cbd8f72 100644 --- a/mayan/apps/events/views.py +++ b/mayan/apps/events/views.py @@ -1,17 +1,24 @@ from __future__ import absolute_import, unicode_literals +from django.contrib import messages from django.contrib.contenttypes.models import ContentType -from django.http import Http404 +from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404 +from django.urls import reverse from django.utils.translation import ugettext_lazy as _ from actstream.models import Action, any_stream from acls.models import AccessControlList +from common.generics import FormView, SimpleView from common.utils import encapsulate from common.views import SingleObjectListView -from .classes import Event +from .classes import EventType, ModelEventType +from .forms import ( + EventTypeUserRelationshipFormSet, ObjectEventTypeUserRelationshipFormSet +) +from .models import StoredEventType from .permissions import permission_events_view from .widgets import event_object_link @@ -37,6 +44,96 @@ class EventListView(SingleObjectListView): return Action.objects.all() +class EventTypeSubscriptionListView(FormView): + form_class = EventTypeUserRelationshipFormSet + main_model = 'user' + submodel = StoredEventType + + def dispatch(self, *args, **kwargs): + EventType.refresh() + return super(EventTypeSubscriptionListView, self).dispatch(*args, **kwargs) + + def form_valid(self, form): + try: + for instance in form: + instance.save() + except Exception as exception: + messages.error( + self.request, + _('Error updating event subscription; %s') % exception + ) + else: + messages.success( + self.request, _('Event subscriptions updated successfully') + ) + + return super( + EventTypeSubscriptionListView, self + ).form_valid(form=form) + + def get_object(self): + return self.request.user + + def get_extra_context(self): + return { + 'form_display_mode_table': True, + 'object': self.get_object(), + 'title': _( + 'Event subscriptions' + ) % self.get_object() + } + + def get_initial(self): + obj = self.get_object() + initial = [] + + for element in self.get_queryset(): + initial.append({ + 'user': obj, + 'main_model': self.main_model, + 'stored_event_type': element, + }) + return initial + + def get_queryset(self): + # Return the queryset by name from the sorted list of the class + event_type_ids = [event_type.id for event_type in EventType.all()] + return self.submodel.objects.filter(name__in=event_type_ids) + + def get_post_action_redirect(self): + return reverse('common:current_user_details') + + +class NotificationListView(SingleObjectListView): + def get_extra_context(self): + return { + 'hide_object': True, + 'object': self.request.user, + 'title': _('Notifications'), + } + + def get_object_list(self): + return self.request.user.notifications.all() + + +class NotificationMarkRead(SimpleView): + def dispatch(self, *args, **kwargs): + self.get_queryset().filter(pk=self.kwargs['pk']).update(read=True) + return HttpResponseRedirect(reverse('events:user_notifications_list')) + + def get_queryset(self): + return self.request.user.notifications.all() + + +class NotificationMarkReadAll(SimpleView): + def dispatch(self, *args, **kwargs): + self.get_queryset().update(read=True) + return HttpResponseRedirect(reverse('events:user_notifications_list')) + + def get_queryset(self): + return self.request.user.notifications.all() + + class ObjectEventListView(EventListView): view_permissions = None @@ -73,6 +170,76 @@ class ObjectEventListView(EventListView): return any_stream(self.content_object) +class ObjectEventTypeSubscriptionListView(FormView): + form_class = ObjectEventTypeUserRelationshipFormSet + + def dispatch(self, *args, **kwargs): + EventType.refresh() + return super(ObjectEventTypeSubscriptionListView, self).dispatch(*args, **kwargs) + + def form_valid(self, form): + try: + for instance in form: + instance.save() + except Exception as exception: + messages.error( + self.request, + _('Error updating object event subscription; %s') % exception + ) + else: + messages.success( + self.request, _('Object event subscriptions updated successfully') + ) + + return super( + ObjectEventTypeSubscriptionListView, self + ).form_valid(form=form) + + def get_object(self): + object_content_type = get_object_or_404( + ContentType, app_label=self.kwargs['app_label'], + model=self.kwargs['model'] + ) + + try: + content_object = object_content_type.get_object_for_this_type( + pk=self.kwargs['object_id'] + ) + except object_content_type.model_class().DoesNotExist: + raise Http404 + + AccessControlList.objects.check_access( + permissions=permission_events_view, user=self.request.user, + obj=content_object + ) + + return content_object + + def get_extra_context(self): + return { + 'form_display_mode_table': True, + 'object': self.get_object(), + 'title': _( + 'Event subscriptions for: %s' + ) % self.get_object() + } + + def get_initial(self): + obj = self.get_object() + initial = [] + + for element in self.get_queryset(): + initial.append({ + 'user': self.request.user, + 'object': obj, + 'stored_event_type': element, + }) + return initial + + def get_queryset(self): + return ModelEventType.get_for_instance(instance=self.get_object()) + + class VerbEventListView(SingleObjectListView): def get_extra_context(self): return { @@ -87,7 +254,7 @@ class VerbEventListView(SingleObjectListView): 'hide_object': True, 'title': _( 'Events of type: %s' - ) % Event.get_label(self.kwargs['verb']), + ) % EventType.get(name=self.kwargs['verb']), } def get_object_list(self): diff --git a/mayan/apps/events/widgets.py b/mayan/apps/events/widgets.py index 075a1edd76..d52cadcf9e 100644 --- a/mayan/apps/events/widgets.py +++ b/mayan/apps/events/widgets.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals from django.urls import reverse from django.utils.safestring import mark_safe -from .classes import Event +from .classes import EventType def event_object_link(entry, attribute='target'): @@ -26,6 +26,6 @@ def event_type_link(entry): return mark_safe( '%(label)s' % { 'url': reverse('events:events_by_verb', kwargs={'verb': entry.verb}), - 'label': Event.get_label(entry.verb) + 'label': EventType.get(name=entry.verb) } ) diff --git a/mayan/apps/ocr/events.py b/mayan/apps/ocr/events.py index ac330df821..cae9d83ae3 100644 --- a/mayan/apps/ocr/events.py +++ b/mayan/apps/ocr/events.py @@ -2,13 +2,15 @@ from __future__ import absolute_import, unicode_literals from django.utils.translation import ugettext_lazy as _ -from events.classes import Event +from events import EventTypeNamespace -event_ocr_document_version_submit = Event( - name='ocr_document_version_submit', - label=_('Document version submitted for OCR') +namespace = EventTypeNamespace(name='ocr', label=_('OCR')) + +event_ocr_document_version_submit = namespace.add_event_type( + label=_('Document version submitted for OCR'), + name='document_version_submit' ) -event_ocr_document_version_finish = Event( - name='ocr_document_version_finish', - label=_('Document version OCR finished') +event_ocr_document_version_finish = namespace.add_event_type( + label=_('Document version OCR finished'), + name='document_version_finish' ) diff --git a/mayan/apps/rest_api/fields.py b/mayan/apps/rest_api/fields.py index df2a5853d0..537bf945ff 100644 --- a/mayan/apps/rest_api/fields.py +++ b/mayan/apps/rest_api/fields.py @@ -24,7 +24,10 @@ class DynamicSerializerField(serializers.ReadOnlyField): for klass, serializer_class in self.serializers.items(): if isinstance(value, klass): return serializer_class( - context={'request': self.context['request']} + context={ + 'format': self.context['format'], + 'request': self.context['request'] + } ).to_representation(instance=value) return _('Unable to find serializer class for: %s') % value diff --git a/mayan/apps/sources/models.py b/mayan/apps/sources/models.py index e2e1d1c533..0b379aba79 100644 --- a/mayan/apps/sources/models.py +++ b/mayan/apps/sources/models.py @@ -124,7 +124,7 @@ class Source(models.Model): logger.critical( 'Unexpected exception while trying to create version for ' 'new document "%s" from source "%s"; %s', - label or file_object.name, self, exception + label or file_object.name, self, exception, exc_info=True ) document.delete(to_trash=False) raise diff --git a/mayan/apps/tags/events.py b/mayan/apps/tags/events.py index e5063aee2c..8181f0fcaf 100644 --- a/mayan/apps/tags/events.py +++ b/mayan/apps/tags/events.py @@ -2,13 +2,13 @@ from __future__ import absolute_import, unicode_literals from django.utils.translation import ugettext_lazy as _ -from events.classes import Event +from events import EventTypeNamespace -event_tag_attach = Event( - name='tag_attach', - label=_('Tag attached to document') +namespace = EventTypeNamespace(name='tags', label=_('Tags')) + +event_tag_attach = namespace.add_event_type( + label=_('Tag attached to document'), name='attach' ) -event_tag_remove = Event( - name='tag_remove', - label=_('Tag removed from document') +event_tag_remove = namespace.add_event_type( + label=_('Tag removed from document'), name='remove' )