From 08e31a2ca9adda32065b9073872e6611ee4308bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Thu, 27 Sep 2018 14:48:35 +0200 Subject: [PATCH] Web notifications --- settings/celery_local.py.example | 4 - taiga/events/events.py | 16 + taiga/projects/api.py | 5 + taiga/projects/history/api.py | 16 + taiga/projects/issues/api.py | 10 +- taiga/projects/notifications/__init__.py | 16 + taiga/projects/notifications/api.py | 51 +++ taiga/projects/notifications/apps.py | 46 +++ taiga/projects/notifications/choices.py | 19 + .../migrations/0008_auto_20181010_1124.py | 37 ++ taiga/projects/notifications/mixins.py | 157 +++++++- taiga/projects/notifications/models.py | 10 + taiga/projects/notifications/serializers.py | 72 +++- taiga/projects/notifications/services.py | 43 ++- taiga/projects/notifications/signals.py | 141 +++++++ taiga/projects/tasks/api.py | 10 +- taiga/projects/userstories/api.py | 7 +- taiga/routers.py | 7 +- tests/integration/test_notifications.py | 345 +++++++++++++++++- 19 files changed, 978 insertions(+), 34 deletions(-) delete mode 100644 settings/celery_local.py.example create mode 100644 taiga/projects/notifications/apps.py create mode 100644 taiga/projects/notifications/migrations/0008_auto_20181010_1124.py create mode 100644 taiga/projects/notifications/signals.py diff --git a/settings/celery_local.py.example b/settings/celery_local.py.example deleted file mode 100644 index a16e5ec3..00000000 --- a/settings/celery_local.py.example +++ /dev/null @@ -1,4 +0,0 @@ -from .celery import * - -# To use celery in memory -#task_always_eager = True diff --git a/taiga/events/events.py b/taiga/events/events.py index 35176adc..0fe4ac3c 100644 --- a/taiga/events/events.py +++ b/taiga/events/events.py @@ -91,6 +91,22 @@ def emit_event_for_model(obj, *, type:str="change", channel:str="events", sessionid=sessionid, data=data) + +def emit_event_for_user_notification(user_id, + *, + session_id: str=None, + event_type: str=None, + data: dict=None): + """ + Sends a user notification event. + """ + return emit_event( + data, + "web_notifications.{}".format(user_id), + sessionid=session_id + ) + + def emit_live_notification_for_model(obj, user, history, *, type:str="change", channel:str="events", sessionid:str="not-existing"): """ diff --git a/taiga/projects/api.py b/taiga/projects/api.py index 695376a2..007e939c 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -45,6 +45,7 @@ from taiga.projects.epics.models import Epic from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.issues.models import Issue from taiga.projects.likes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin +from taiga.projects.notifications.apps import signal_members_added from taiga.projects.notifications.mixins import WatchersViewSetMixin from taiga.projects.notifications.choices import NotifyLevel from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin @@ -980,6 +981,10 @@ class MembershipViewSet(BlockedByProjectMixin, ModelCrudViewSet): invitation_extra_text=invitation_extra_text, callback=self.post_save, precall=self.pre_save) + signal_members_added.send(sender=self.__class__, + user=self.request.user, + project=project, + new_members=members) except exc.ValidationError as err: return response.BadRequest(err.message_dict) diff --git a/taiga/projects/history/api.py b/taiga/projects/history/api.py index 52dabcb0..45000421 100644 --- a/taiga/projects/history/api.py +++ b/taiga/projects/history/api.py @@ -24,6 +24,8 @@ from taiga.base import response from taiga.base.decorators import detail_route from taiga.base.api import ReadOnlyListViewSet from taiga.mdrender.service import render as mdrender +from taiga.projects.notifications import services as notifications_services +from taiga.projects.notifications.apps import signal_mentions from . import permissions from . import serializers @@ -57,6 +59,11 @@ class HistoryViewSet(ReadOnlyListViewSet): return response.Ok(serializer.data) + def _get_new_mentions(self, obj: object, old_comment: str, new_comment: str): + old_mentions = notifications_services.get_mentions(obj.project, old_comment) + submitted_mentions = notifications_services.get_mentions(obj, new_comment) + return list(set(submitted_mentions) - set(old_mentions)) + @detail_route(methods=['get']) def comment_versions(self, request, pk): obj = self.get_object() @@ -106,11 +113,20 @@ class HistoryViewSet(ReadOnlyListViewSet): } }) + new_mentions = self._get_new_mentions(obj, history_entry.comment, comment) + history_entry.edit_comment_date = timezone.now() history_entry.comment = comment history_entry.comment_html = mdrender(obj.project, comment) history_entry.comment_versions = comment_versions history_entry.save() + + if new_mentions: + signal_mentions.send(sender=self.__class__, + user=self.request.user, + obj=obj, + mentions=new_mentions) + return response.Ok() @detail_route(methods=['post']) diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index d23bf673..f6f132b3 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -30,7 +30,9 @@ from taiga.base.api.utils import get_object_or_404 from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.mixins.by_ref import ByRefMixin from taiga.projects.models import Project, IssueStatus, Severity, Priority, IssueType -from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin +from taiga.projects.notifications.mixins import AssignedToSignalMixin +from taiga.projects.notifications.mixins import WatchedResourceMixin +from taiga.projects.notifications.mixins import WatchersViewSetMixin from taiga.projects.occ import OCCResourceMixin from taiga.projects.tagging.api import TaggedResourceMixin from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin @@ -44,8 +46,10 @@ from . import serializers from . import validators -class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin, - ByRefMixin, TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet): +class IssueViewSet(AssignedToSignalMixin, OCCResourceMixin, VotedResourceMixin, + HistoryResourceMixin, WatchedResourceMixin, ByRefMixin, + TaggedResourceMixin, BlockedByProjectMixin, + ModelCrudViewSet): validator_class = validators.IssueValidator queryset = models.Issue.objects.all() permission_classes = (permissions.IssuePermission, ) diff --git a/taiga/projects/notifications/__init__.py b/taiga/projects/notifications/__init__.py index e69de29b..306b84e8 100644 --- a/taiga/projects/notifications/__init__.py +++ b/taiga/projects/notifications/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2018 Taiga Agile LLC +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +default_app_config = "taiga.projects.notifications.apps.NotificationsAppConfig" diff --git a/taiga/projects/notifications/api.py b/taiga/projects/notifications/api.py index 8a7a06dd..a3363b26 100644 --- a/taiga/projects/notifications/api.py +++ b/taiga/projects/notifications/api.py @@ -17,8 +17,12 @@ # along with this program. If not, see . from django.db.models import Q +from django.utils import timezone +from taiga.base import response from taiga.base.api import ModelCrudViewSet +from taiga.base.api import GenericViewSet +from taiga.base.api.utils import get_object_or_404 from taiga.projects.notifications.choices import NotifyLevel from taiga.projects.models import Project @@ -50,3 +54,50 @@ class NotifyPolicyViewSet(ModelCrudViewSet): return models.NotifyPolicy.objects.filter(user=self.request.user).filter( Q(project__owner=self.request.user) | Q(project__memberships__user=self.request.user) ).distinct() + + +class WebNotificationsViewSet(GenericViewSet): + serializer_class = serializers.WebNotificationSerializer + resource_model = models.WebNotification + + def check_permissions(self, request, obj=None): + return obj and request.user.is_authenticated() and \ + request.user.pk == obj.user_id + + def list(self, request): + queryset = models.WebNotification.objects\ + .filter(user=self.request.user) + + if request.GET.get("only_unread", False): + queryset = queryset.filter(read__isnull=True) + + queryset = queryset.order_by('-read', '-created') + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_pagination_serializer(page) + return response.Ok({ + "objects": serializer.data, + "total": queryset.count() + }) + + serializer = self.get_serializer(queryset, many=True) + return response.Ok(serializer.data) + + def patch(self, request, *args, **kwargs): + self.check_permissions(request) + + resource_id = kwargs.get("resource_id", None) + resource = get_object_or_404(self.resource_model, pk=resource_id) + resource.read = timezone.now() + resource.save() + + return response.Ok({}) + + def post(self, request): + self.check_permissions(request) + + models.WebNotification.objects.filter(user=self.request.user)\ + .update(read=timezone.now()) + + return response.Ok() diff --git a/taiga/projects/notifications/apps.py b/taiga/projects/notifications/apps.py new file mode 100644 index 00000000..9ea0bb5c --- /dev/null +++ b/taiga/projects/notifications/apps.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2018 Taiga Agile LLC +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django import dispatch +from django.apps import AppConfig + +signal_assigned_to = dispatch.Signal(providing_args=["user", "obj"]) +signal_assigned_users = dispatch.Signal(providing_args=["user", "obj", + "new_assigned_users"]) +signal_watchers_added = dispatch.Signal(providing_args=["user", "obj", + "new_watchers"]) +signal_members_added = dispatch.Signal(providing_args=["user", "project", + "new_members"]) +signal_mentions = dispatch.Signal(providing_args=["user", "obj", + "mentions"]) +signal_comment = dispatch.Signal(providing_args=["user", "obj", + "watchers"]) +signal_comment_mentions = dispatch.Signal(providing_args=["user", "obj", + "mentions"]) + + +class NotificationsAppConfig(AppConfig): + name = "taiga.projects.notifications" + verbose_name = "Notifications" + + def ready(self): + from . import signals as handlers + signal_assigned_to.connect(handlers.on_assigned_to) + signal_assigned_users.connect(handlers.on_assigned_users) + signal_watchers_added.connect(handlers.on_watchers_added) + signal_members_added.connect(handlers.on_members_added) + signal_mentions.connect(handlers.on_mentions) + signal_comment.connect(handlers.on_comment) + signal_comment_mentions.connect(handlers.on_comment_mentions) diff --git a/taiga/projects/notifications/choices.py b/taiga/projects/notifications/choices.py index 9fbe0f07..6db01dd8 100644 --- a/taiga/projects/notifications/choices.py +++ b/taiga/projects/notifications/choices.py @@ -31,3 +31,22 @@ NOTIFY_LEVEL_CHOICES = ( (NotifyLevel.all, _("All")), (NotifyLevel.none, _("None")), ) + + +class WebNotificationType(enum.IntEnum): + assigned = 1 + mentioned = 2 + added_as_watcher = 3 + added_as_member = 4 + comment = 5 + mentioned_in_comment = 6 + + +WEB_NOTIFICATION_TYPE_CHOICES = ( + (WebNotificationType.assigned, _("Assigned")), + (WebNotificationType.mentioned, _("Mentioned")), + (WebNotificationType.added_as_watcher, _("Added as watcher")), + (WebNotificationType.added_as_member, _("Added as member")), + (WebNotificationType.comment, _("Comment")), + (WebNotificationType.mentioned_in_comment, _("Mentioned in comment")), +) diff --git a/taiga/projects/notifications/migrations/0008_auto_20181010_1124.py b/taiga/projects/notifications/migrations/0008_auto_20181010_1124.py new file mode 100644 index 00000000..98ef8019 --- /dev/null +++ b/taiga/projects/notifications/migrations/0008_auto_20181010_1124.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.2 on 2018-10-10 11:24 +from __future__ import unicode_literals + +from django.conf import settings +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import taiga.base.db.models.fields.json + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('notifications', '0007_notifypolicy_live_notify_level'), + ] + + operations = [ + migrations.CreateModel( + name='WebNotification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('read', models.DateTimeField(default=None, null=True)), + ('event_type', models.PositiveIntegerField()), + ('data', taiga.base.db.models.fields.json.JSONField(encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='web_notifications', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='notifypolicy', + name='web_notify_level', + field=models.BooleanField(default=True), + ), + ] diff --git a/taiga/projects/notifications/mixins.py b/taiga/projects/notifications/mixins.py index cf23b490..70b3ea75 100644 --- a/taiga/projects/notifications/mixins.py +++ b/taiga/projects/notifications/mixins.py @@ -29,6 +29,12 @@ from taiga.base.api.utils import get_object_or_404 from taiga.base.fields import WatchersField, MethodField from taiga.projects.notifications import services +from . apps import signal_assigned_to +from . apps import signal_assigned_users +from . apps import signal_comment +from . apps import signal_comment_mentions +from . apps import signal_mentions +from . apps import signal_watchers_added from . serializers import WatcherSerializer @@ -47,6 +53,8 @@ class WatchedResourceMixin: """ _not_notify = False + _old_watchers = None + _old_mentions = [] @detail_route(methods=["POST"]) def watch(self, request, pk=None): @@ -86,13 +94,38 @@ class WatchedResourceMixin: # some text fields for extract mentions and add them # to watchers before obtain a complete list of # notifiable users. - services.analize_object_for_watchers(obj, history.comment, history.owner) + services.analize_object_for_watchers(obj, history.comment, + history.owner) # Get a complete list of notifiable users for current # object and send the change notification to them. services.send_notifications(obj, history=history) + def update(self, request, *args, **kwargs): + obj = self.get_object_or_none() + if obj and obj.id: + if hasattr(obj, "watchers"): + self._old_watchers = [ + watcher.id for watcher in self.get_object().get_watchers() + ] + + mention_fields = ['description', 'content'] + for field_name in mention_fields: + old_mentions = self._get_old_mentions_in_field(obj, field_name) + if not len(old_mentions): + continue + self._old_mentions = old_mentions + + return super().update(request, *args, **kwargs) + def post_save(self, obj, created=False): + self.create_web_notifications_for_added_watchers(obj) + self.create_web_notifications_for_mentioned_users(obj) + + mentions = self.create_web_notifications_for_mentions_in_comments(obj) + exclude = mentions + [self.request.user.id] + self.create_web_notifications_for_comment(obj, exclude) + self.send_notifications(obj) super().post_save(obj, created) @@ -100,6 +133,84 @@ class WatchedResourceMixin: self.send_notifications(obj) super().pre_delete(obj) + def create_web_notifications_for_comment(self, obj, exclude: list=None): + if "comment" in self.request.DATA: + watchers = [ + watcher_id for watcher_id in obj.watchers + if watcher_id not in exclude + ] + + signal_comment.send(sender=self.__class__, + user=self.request.user, + obj=obj, + watchers=watchers) + + def create_web_notifications_for_added_watchers(self, obj): + if not hasattr(obj, "watchers"): + return + + new_watchers = [ + watcher_id for watcher_id in obj.watchers + if watcher_id not in self._old_watchers + and watcher_id != self.request.user.id + ] + signal_watchers_added.send(sender=self.__class__, + user=self.request.user, + obj=obj, + new_watchers=new_watchers) + + def create_web_notifications_for_mentioned_users(self, obj): + """ + Detect and notify mentioned users + """ + submitted_mentions = self._get_submitted_mentions(obj) + new_mentions = list(set(submitted_mentions) - set(self._old_mentions)) + if new_mentions: + signal_mentions.send(sender=self.__class__, + user=self.request.user, + obj=obj, + mentions=new_mentions) + + def create_web_notifications_for_mentions_in_comments(self, obj): + """ + Detect and notify mentioned users + """ + new_mentions_in_comment = self._get_mentions_in_comment(obj) + if new_mentions_in_comment: + signal_comment_mentions.send(sender=self.__class__, + user=self.request.user, + obj=obj, + mentions=new_mentions_in_comment) + + return [user.id for user in new_mentions_in_comment] + + def _get_submitted_mentions(self, obj): + mention_fields = ['description', 'content'] + for field_name in mention_fields: + new_mentions = self._get_new_mentions_in_field(obj, field_name) + if len(new_mentions) > 0: + return new_mentions + + return [] + + def _get_mentions_in_comment(self, obj): + comment = self.request.DATA.get('comment') + if comment: + return services.get_mentions(obj, comment) + return [] + + def _get_old_mentions_in_field(self, obj, field_name): + if not hasattr(obj, field_name): + return [] + + return services.get_mentions(obj, getattr(obj, field_name)) + + def _get_new_mentions_in_field(self, obj, field_name): + value = self.request.DATA.get(field_name) + if not value: + return [] + return services.get_mentions(obj, value) + class WatchedModelMixin(object): """ @@ -274,3 +385,47 @@ class WatchersViewSetMixin: def get_queryset(self): resource = self.resource_model.objects.get(pk=self.kwargs.get("resource_id")) return resource.get_watchers() + + +class AssignedToSignalMixin: + _old_assigned_to = None + + def pre_save(self, obj): + if obj.id: + self._old_assigned_to = self.get_object().assigned_to + super().pre_save(obj) + + def post_save(self, obj, created=False): + if obj.assigned_to and obj.assigned_to != self._old_assigned_to \ + and self.request.user != obj.assigned_to: + signal_assigned_to.send(sender=self.__class__, + user=self.request.user, + obj=obj) + super().post_save(obj, created) + + +class AssignedUsersSignalMixin: + _old_assigned_users = None + + def update(self, request, *args, **kwargs): + obj = self.get_object_or_none() + if hasattr(obj, "assigned_users") and obj.id: + self._old_assigned_users = [ + user for user in obj.assigned_users.all() + ].copy() + + result = super().update(request, *args, **kwargs) + + if result and obj.assigned_users: + new_assigned_users = [ + user for user in obj.assigned_users.all() + if user not in self._old_assigned_users + and user != self.request.user + ] + + signal_assigned_users.send(sender=self.__class__, + user=self.request.user, + obj=obj, + new_assigned_users=new_assigned_users) + + return result diff --git a/taiga/projects/notifications/models.py b/taiga/projects/notifications/models.py index dbf67353..d3b1dc62 100644 --- a/taiga/projects/notifications/models.py +++ b/taiga/projects/notifications/models.py @@ -23,6 +23,7 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ from django.utils import timezone +from taiga.base.db.models.fields import JSONField from taiga.projects.history.choices import HISTORY_TYPE_CHOICES from .choices import NOTIFY_LEVEL_CHOICES, NotifyLevel @@ -37,6 +38,7 @@ class NotifyPolicy(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="notify_policies") notify_level = models.SmallIntegerField(choices=NOTIFY_LEVEL_CHOICES) live_notify_level = models.SmallIntegerField(choices=NOTIFY_LEVEL_CHOICES, default=NotifyLevel.involved) + web_notify_level = models.BooleanField(default=True, null=False, blank=True) created_at = models.DateTimeField(default=timezone.now) modified_at = models.DateTimeField() @@ -94,3 +96,11 @@ class Watched(models.Model): verbose_name = _("Watched") verbose_name_plural = _("Watched") unique_together = ("content_type", "object_id", "user", "project") + + +class WebNotification(models.Model): + created = models.DateTimeField(default=timezone.now, db_index=True) + read = models.DateTimeField(default=None, null=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="web_notifications") + event_type = models.PositiveIntegerField() + data = JSONField() diff --git a/taiga/projects/notifications/serializers.py b/taiga/projects/notifications/serializers.py index f55fa191..10799bf3 100644 --- a/taiga/projects/notifications/serializers.py +++ b/taiga/projects/notifications/serializers.py @@ -16,8 +16,13 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from django.contrib.contenttypes.models import ContentType + from taiga.base.api import serializers +from taiga.base.fields import Field, DateTimeField, MethodField +from taiga.users.gravatar import get_user_gravatar_id from taiga.users.models import get_user_model_safe +from taiga.users.services import get_user_photo_url, get_user_big_photo_url from . import models @@ -27,7 +32,8 @@ class NotifyPolicySerializer(serializers.ModelSerializer): class Meta: model = models.NotifyPolicy - fields = ('id', 'project', 'project_name', 'notify_level', "live_notify_level") + fields = ('id', 'project', 'project_name', 'notify_level', + 'live_notify_level', 'web_notify_level') def get_project_name(self, obj): return obj.project.name @@ -39,3 +45,67 @@ class WatcherSerializer(serializers.ModelSerializer): class Meta: model = get_user_model_safe() fields = ('id', 'username', 'full_name') + + +class WebNotificationSerializer(serializers.ModelSerializer): + class Meta: + model = models.WebNotification + fields = ('id', 'event_type', 'user', 'data', 'created', 'read') + + +class ProjectSerializer(serializers.LightSerializer): + id = Field() + slug = Field() + name = Field() + + +class ObjectSerializer(serializers.LightSerializer): + id = Field() + ref = MethodField() + subject = MethodField() + content_type = MethodField() + + def get_ref(self, obj): + return obj.ref if hasattr(obj, 'ref') else None + + def get_subject(self, obj): + return obj.subject if hasattr(obj, 'subject') else None + + def get_content_type(self, obj): + content_type = ContentType.objects.get_for_model(obj) + return content_type.model if content_type else None + + +class UserSerializer(serializers.LightSerializer): + id = Field() + name = MethodField() + photo = MethodField() + big_photo = MethodField() + gravatar_id = MethodField() + username = Field() + is_profile_visible = MethodField() + date_joined = DateTimeField() + + def get_name(self, obj): + return obj.get_full_name() + + def get_photo(self, obj): + return get_user_photo_url(obj) + + def get_big_photo(self, obj): + return get_user_big_photo_url(obj) + + def get_gravatar_id(self, obj): + return get_user_gravatar_id(obj) + + def get_is_profile_visible(self, obj): + return obj.is_active and not obj.is_system + + +class NotificationDataSerializer(serializers.LightDictSerializer): + project = ProjectSerializer() + user = UserSerializer() + + +class ObjectNotificationSerializer(NotificationDataSerializer): + obj = ObjectSerializer() diff --git a/taiga/projects/notifications/services.py b/taiga/projects/notifications/services.py index 89227370..318072ca 100644 --- a/taiga/projects/notifications/services.py +++ b/taiga/projects/notifications/services.py @@ -73,7 +73,8 @@ def create_notify_policy(project, user, level=NotifyLevel.involved, def create_notify_policy_if_not_exists(project, user, level=NotifyLevel.involved, - live_level=NotifyLevel.involved): + live_level=NotifyLevel.involved, + web_level=True): """ Given a project and user, create notification policy for it. """ @@ -82,7 +83,11 @@ def create_notify_policy_if_not_exists(project, user, result = model_cls.objects.get_or_create( project=project, user=user, - defaults={"notify_level": level, "live_notify_level": live_level} + defaults={ + "notify_level": level, + "live_notify_level": live_level, + "web_notify_level": web_level + } ) return result[0] except IntegrityError as e: @@ -95,27 +100,39 @@ def analize_object_for_watchers(obj: object, comment: str, user: object): Generic implementation for analize model objects and extract mentions from it and add it to watchers. """ - - if not hasattr(obj, "get_project"): + if not hasattr(obj, "add_watcher"): return - if not hasattr(obj, "add_watcher"): + mentions = get_object_mentions(obj, comment) + if mentions: + for user in mentions: + obj.add_watcher(user) + + # Adding the person who edited the object to the watchers + if comment and not user.is_system: + obj.add_watcher(user) + + +def get_object_mentions(obj: object, comment: str): + """ + Generic implementation for analize model objects and + extract mentions from it. + """ + if not hasattr(obj, "get_project"): return texts = (getattr(obj, "description", ""), getattr(obj, "content", ""), comment,) + return get_mentions(obj.get_project(), "\n".join(texts)) + + +def get_mentions(project: object, text: str): from taiga.mdrender.service import render_and_extract - _, data = render_and_extract(obj.get_project(), "\n".join(texts)) + _, data = render_and_extract(project, text) - if data["mentions"]: - for user in data["mentions"]: - obj.add_watcher(user) - - # Adding the person who edited the object to the watchers - if comment and not user.is_system: - obj.add_watcher(user) + return data.get("mentions") def _filter_by_permissions(obj, user): diff --git a/taiga/projects/notifications/signals.py b/taiga/projects/notifications/signals.py new file mode 100644 index 00000000..06bfe0d0 --- /dev/null +++ b/taiga/projects/notifications/signals.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2018 Taiga Agile LLC +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django.utils import timezone + +from taiga.events import events +from taiga.events import middleware as mw + +from . import choices +from . import models +from . import serializers + + +def _filter_recipients(project, user, recipients): + notify_policies = models.NotifyPolicy.objects.filter( + user_id__in=recipients, + project=project, + web_notify_level=True).exclude(user_id=user.id).all() + return [notify_policy.user_id for notify_policy in notify_policies] + + +def _push_to_web_notifications(event_type, data, recipients, + serializer_class=None): + if not serializer_class: + serializer_class = serializers.ObjectNotificationSerializer + + serializer = serializer_class(data) + for user_id in recipients: + with transaction.atomic(): + models.WebNotification.objects.create( + event_type=event_type.value, + created=timezone.now(), + user_id=user_id, + data=serializer.data, + ) + session_id = mw.get_current_session_id() + events.emit_event_for_user_notification(user_id, + session_id=session_id, + event_type=event_type.value, + data=serializer.data) + + +def on_assigned_to(sender, user, obj, **kwargs): + event_type = choices.WebNotificationType.assigned + data = { + "project": obj.project, + "user": user, + "obj": obj, + } + recipients = _filter_recipients(obj.project, user, + [obj.assigned_to.id]) + _push_to_web_notifications(event_type, data, recipients) + + +def on_assigned_users(sender, user, obj, new_assigned_users, **kwargs): + event_type = choices.WebNotificationType.assigned + data = { + "project": obj.project, + "user": user, + "obj": obj, + } + recipients = _filter_recipients(obj.project, user, + [user.id for user in new_assigned_users]) + _push_to_web_notifications(event_type, data, recipients) + + +def on_watchers_added(sender, user, obj, new_watchers, **kwargs): + event_type = choices.WebNotificationType.added_as_watcher + data = { + "project": obj.project, + "user": user, + "obj": obj, + } + recipients = _filter_recipients(obj.project, user, new_watchers) + _push_to_web_notifications(event_type, data, recipients) + + +def on_members_added(sender, user, project, new_members, **kwargs): + serializer_class = serializers.NotificationDataSerializer + event_type = choices.WebNotificationType.added_as_member + data = { + "project": project, + "user": user, + } + recipients = _filter_recipients(project, user, + [member.user_id for member in new_members + if member.user_id]) + + _push_to_web_notifications(event_type, data, recipients, serializer_class) + + +def on_mentions(sender, user, obj, mentions, **kwargs): + content_type = ContentType.objects.get_for_model(obj) + valid_content_types = ['issue', 'task', 'userstory'] + if content_type.model in valid_content_types: + event_type = choices.WebNotificationType.mentioned + data = { + "project": obj.project, + "user": user, + "obj": obj, + } + recipients = _filter_recipients(obj.project, user, + [user.id for user in mentions]) + _push_to_web_notifications(event_type, data, recipients) + + +def on_comment_mentions(sender, user, obj, mentions, **kwargs): + event_type = choices.WebNotificationType.mentioned_in_comment + data = { + "project": obj.project, + "user": user, + "obj": obj, + } + recipients = _filter_recipients(obj.project, user, + [user.id for user in mentions]) + _push_to_web_notifications(event_type, data, recipients) + + +def on_comment(sender, user, obj, watchers, **kwargs): + event_type = choices.WebNotificationType.comment + data = { + "project": obj.project, + "user": user, + "obj": obj, + } + recipients = _filter_recipients(obj.project, user, watchers) + _push_to_web_notifications(event_type, data, recipients) diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index 0e8f9549..8546c3e9 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -30,7 +30,9 @@ from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.milestones.models import Milestone from taiga.projects.mixins.by_ref import ByRefMixin from taiga.projects.models import Project, TaskStatus -from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin +from taiga.projects.notifications.mixins import AssignedToSignalMixin +from taiga.projects.notifications.mixins import WatchedResourceMixin +from taiga.projects.notifications.mixins import WatchersViewSetMixin from taiga.projects.occ import OCCResourceMixin from taiga.projects.tagging.api import TaggedResourceMixin from taiga.projects.userstories.models import UserStory @@ -45,8 +47,10 @@ from . import validators from . import utils as tasks_utils -class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin, - ByRefMixin, TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet): +class TaskViewSet(AssignedToSignalMixin, OCCResourceMixin, VotedResourceMixin, + HistoryResourceMixin, WatchedResourceMixin, ByRefMixin, + TaggedResourceMixin, BlockedByProjectMixin, + ModelCrudViewSet): validator_class = validators.TaskValidator queryset = models.Task.objects.all() permission_classes = (permissions.TaskPermission,) diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 8a36d54f..6af29984 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -39,6 +39,7 @@ from taiga.projects.history.services import take_snapshot from taiga.projects.milestones.models import Milestone from taiga.projects.mixins.by_ref import ByRefMixin from taiga.projects.models import Project, UserStoryStatus +from taiga.projects.notifications.mixins import AssignedUsersSignalMixin from taiga.projects.notifications.mixins import WatchedResourceMixin from taiga.projects.notifications.mixins import WatchersViewSetMixin from taiga.projects.occ import OCCResourceMixin @@ -55,8 +56,10 @@ from . import services from . import validators -class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin, - ByRefMixin, TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet): +class UserStoryViewSet(AssignedUsersSignalMixin, OCCResourceMixin, + VotedResourceMixin, HistoryResourceMixin, + WatchedResourceMixin, ByRefMixin, TaggedResourceMixin, + BlockedByProjectMixin, ModelCrudViewSet): validator_class = validators.UserStoryValidator queryset = models.UserStory.objects.all() permission_classes = (permissions.UserStoryPermission,) diff --git a/taiga/routers.py b/taiga/routers.py index 57a36001..e70203c9 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -43,11 +43,14 @@ from taiga.userstorage.api import StorageEntriesViewSet router.register(r"user-storage", StorageEntriesViewSet, base_name="user-storage") -# Notify policies +# Notifications & Notify policies from taiga.projects.notifications.api import NotifyPolicyViewSet +from taiga.projects.notifications.api import WebNotificationsViewSet router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications") - +router.register(r"web-notifications", WebNotificationsViewSet, base_name="web-notifications") +router.register(r"web-notifications/set-as-read", WebNotificationsViewSet, base_name="web-notifications") +router.register(r"web-notifications/(?P\d+)/set-as-read", WebNotificationsViewSet, base_name="web-notifications") # Project settings from taiga.projects.settings.api import UserProjectSettingsViewSet, SectionsViewSet diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py index e7e9b70c..d6f480d7 100644 --- a/tests/integration/test_notifications.py +++ b/tests/integration/test_notifications.py @@ -19,7 +19,6 @@ import pytest import time -import math import base64 import datetime import hashlib @@ -35,17 +34,16 @@ from django.utils import timezone from django.apps import apps from .. import factories as f +from taiga.base.api.settings import api_settings from taiga.base.utils import json from taiga.projects.notifications import services -from taiga.projects.notifications import utils from taiga.projects.notifications import models from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.notifications.choices import WebNotificationType from taiga.projects.history.choices import HistoryType from taiga.projects.history.services import take_snapshot -from taiga.projects.issues.serializers import IssueSerializer -from taiga.projects.userstories.serializers import UserStorySerializer -from taiga.projects.tasks.serializers import TaskSerializer from taiga.permissions.choices import MEMBERS_PERMISSIONS +from taiga.users.gravatar import get_user_gravatar_id pytestmark = pytest.mark.django_db @@ -1074,3 +1072,340 @@ def parse_ms_thread_index(index): ts.append(ts[-1] + datetime.timedelta(microseconds=(f << 18)//10)) return guid, ts + + +def _notification_data(project, user, obj, content_type): + return { + "project": { + "id": project.pk, + "slug": project.slug, + "name": project.name, + }, + "obj": { + "id": obj.pk, + "ref": obj.ref, + "subject": obj.subject, + "content_type": content_type, + }, + "user": { + 'big_photo': None, + 'date_joined': user.date_joined.strftime( + api_settings.DATETIME_FORMAT), + 'gravatar_id': get_user_gravatar_id(user), + 'id': user.pk, + 'is_profile_visible': True, + 'name': user.get_full_name(), + 'photo': None, + 'username': user.username + }, + } + + +def test_issue_updated_generates_web_notifications(client): + project = f.ProjectFactory.create() + role = f.RoleFactory.create( + project=project, + permissions=['view_issues', 'modify_issue'] + ) + member1 = f.MembershipFactory.create(project=project, role=role) + member2 = f.MembershipFactory.create(project=project, role=role) + member3 = f.MembershipFactory.create(project=project, role=role) + member4 = f.MembershipFactory.create(project=project, role=role) + issue = f.IssueFactory.create(project=project, owner=member1.user) + + client.login(member1.user) + mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" + with patch(mock_path): + client.patch( + reverse("issues-detail", args=[issue.pk]), + json.dumps({ + "description": "Lorem ipsum @%s dolor sit amet" % + member4.user.username, + "assigned_to": member2.user.pk, + "watchers": [member3.user.pk], + "version": issue.version + }), + content_type="application/json" + ) + + assert 3 == models.WebNotification.objects.count() + + notifications = models.WebNotification.objects.all() + notification_data = _notification_data(project, member1.user, issue, + 'issue') + # Notification assigned_to + assert notifications[0].user == member2.user + assert notifications[0].event_type == WebNotificationType.assigned.value + assert notifications[0].read is None + assert notifications[0].data == notification_data + + # Notification added_as_watcher + assert notifications[1].user == member3.user + assert notifications[1].event_type == WebNotificationType.added_as_watcher + assert notifications[1].read is None + assert notifications[1].data == notification_data + + # Notification mentioned + assert notifications[2].user == member4.user + assert notifications[2].event_type == WebNotificationType.mentioned + assert notifications[2].read is None + assert notifications[2].data == notification_data + + +def test_comment_on_issue_generates_web_notifications(client): + project = f.ProjectFactory.create() + role = f.RoleFactory.create( + project=project, + permissions=['view_issues', 'modify_issue'] + ) + member1 = f.MembershipFactory.create(project=project, role=role) + member2 = f.MembershipFactory.create(project=project, role=role) + issue = f.IssueFactory.create(project=project, owner=member1.user) + issue.add_watcher(member2.user) + + client.login(member1.user) + mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" + with patch(mock_path): + client.patch( + reverse("issues-detail", args=[issue.pk]), + json.dumps({ + "version": issue.version, + "comment": "Lorem ipsum dolor sit amet", + }), + content_type="application/json" + ) + + assert 1 == models.WebNotification.objects.count() + + notification = models.WebNotification.objects.first() + notification_data = _notification_data(project, member1.user, issue, + 'issue') + + # Notification comment + assert notification.user == member2.user + assert notification.event_type == WebNotificationType.comment + assert notification.read is None + assert notification.data == notification_data + + +def test_task_updated_generates_web_notifications(client): + project = f.ProjectFactory.create() + role = f.RoleFactory.create( + project=project, + permissions=['view_tasks', 'modify_task'] + ) + member1 = f.MembershipFactory.create(project=project, role=role) + member2 = f.MembershipFactory.create(project=project, role=role) + member3 = f.MembershipFactory.create(project=project, role=role) + member4 = f.MembershipFactory.create(project=project, role=role) + task = f.TaskFactory.create(project=project, owner=member1.user) + + client.login(member1.user) + mock_path = "taiga.projects.tasks.api.TaskViewSet.pre_conditions_on_save" + with patch(mock_path): + client.patch( + reverse("tasks-detail", args=[task.pk]), + json.dumps({ + "description": "Lorem ipsum @%s dolor sit amet" % + member4.user.username, + "assigned_to": member2.user.pk, + "watchers": [member3.user.pk], + "version": task.version + }), + content_type="application/json" + ) + + assert 3 == models.WebNotification.objects.count() + + notifications = models.WebNotification.objects.all() + notification_data = _notification_data(project, member1.user, task, 'task') + + # Notification assigned_to + assert notifications[0].user == member2.user + assert notifications[0].event_type == WebNotificationType.assigned.value + assert notifications[0].read is None + assert notifications[0].data == notification_data + + # Notification added_as_watcher + assert notifications[1].user == member3.user + assert notifications[1].event_type == WebNotificationType.added_as_watcher + assert notifications[1].read is None + assert notifications[1].data == notification_data + + # Notification mentioned + assert notifications[2].user == member4.user + assert notifications[2].event_type == WebNotificationType.mentioned + assert notifications[2].read is None + assert notifications[2].data == notification_data + + +def test_comment_on_task_generates_web_notifications(client): + project = f.ProjectFactory.create() + role = f.RoleFactory.create( + project=project, + permissions=['view_tasks', 'modify_task'] + ) + member1 = f.MembershipFactory.create(project=project, role=role) + member2 = f.MembershipFactory.create(project=project, role=role) + task = f.TaskFactory.create(project=project, owner=member1.user) + task.add_watcher(member2.user) + + client.login(member1.user) + mock_path = "taiga.projects.tasks.api.TaskViewSet.pre_conditions_on_save" + with patch(mock_path): + client.patch( + reverse("tasks-detail", args=[task.pk]), + json.dumps({ + "version": task.version, + "comment": "Lorem ipsum dolor sit amet", + }), + content_type="application/json" + ) + + assert 1 == models.WebNotification.objects.count() + + notification = models.WebNotification.objects.first() + notification_data = _notification_data(project, member1.user, task, 'task') + + # Notification comment + assert notification.user == member2.user + assert notification.event_type == WebNotificationType.comment + assert notification.read is None + assert notification.data == notification_data + + +def test_us_updated_generates_web_notifications(client): + project = f.ProjectFactory.create() + role = f.RoleFactory.create( + project=project, + permissions=['view_us', 'modify_us'] + ) + member1 = f.MembershipFactory.create(project=project, role=role) + member2 = f.MembershipFactory.create(project=project, role=role) + member3 = f.MembershipFactory.create(project=project, role=role) + member4 = f.MembershipFactory.create(project=project, role=role) + us = f.UserStoryFactory.create(project=project, + owner=member1.user, + milestone=None) + + client.login(member1.user) + mock_path = "taiga.projects.userstories.api.UserStoryViewSet." \ + "pre_conditions_on_save" + with patch(mock_path): + client.patch( + reverse("userstories-detail", args=[us.pk]), + json.dumps({ + "description": "Lorem ipsum @%s dolor sit amet" % + member4.user.username, + "assigned_users": [member2.user.pk], + "watchers": [member3.user.pk], + "version": us.version + }), + content_type="application/json" + ) + + assert 3 == models.WebNotification.objects.count() + + notifications = models.WebNotification.objects.all() + notification_data = _notification_data(project, member1.user, us, + 'userstory') + + # Notification added_as_watcher + assert notifications[0].user == member3.user + assert notifications[0].event_type == WebNotificationType.added_as_watcher + assert notifications[0].read is None + assert notifications[0].data == notification_data + + # Notification mentioned + assert notifications[1].user == member4.user + assert notifications[1].event_type == WebNotificationType.mentioned + assert notifications[1].read is None + assert notifications[1].data == notification_data + + # Notification assigned_users + assert notifications[2].user == member2.user + assert notifications[2].event_type == WebNotificationType.assigned.value + assert notifications[2].read is None + assert notifications[2].data == notification_data + + +def test_comment_on_us_generates_web_notifications(client): + project = f.ProjectFactory.create() + role = f.RoleFactory.create( + project=project, + permissions=['view_us', 'modify_us'] + ) + member1 = f.MembershipFactory.create(project=project, role=role) + member2 = f.MembershipFactory.create(project=project, role=role) + us = f.UserStoryFactory.create(project=project, + owner=member1.user, + milestone=None) + us.add_watcher(member2.user) + + client.login(member1.user) + mock_path = "taiga.projects.userstories.api.UserStoryViewSet." \ + "pre_conditions_on_save" + with patch(mock_path): + client.patch( + reverse("userstories-detail", args=[us.pk]), + json.dumps({ + "version": us.version, + "comment": "Lorem ipsum dolor sit amet", + }), + content_type="application/json" + ) + + assert 1 == models.WebNotification.objects.count() + + notification = models.WebNotification.objects.first() + notification_data = _notification_data(project, member1.user, us, + 'userstory') + + # Notification comment + assert notification.user == member2.user + assert notification.event_type == WebNotificationType.comment + assert notification.read is None + assert notification.data == notification_data + + +def test_new_member_generates_web_notifications(client): + project = f.ProjectFactory() + john = f.UserFactory.create() + joseph = f.UserFactory.create() + other = f.UserFactory.create() + tester = f.RoleFactory(project=project, name="Tester", + permissions=["view_project"]) + gamer = f.RoleFactory(project=project, name="Gamer", + permissions=["view_project"]) + f.MembershipFactory(project=project, user=john, role=tester, is_admin=True) + + # John and Other are members from another project + project2 = f.ProjectFactory() + f.MembershipFactory(project=project2, user=john, role=gamer, is_admin=True) + f.MembershipFactory(project=project2, user=other, role=gamer) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": gamer.pk, "username": joseph.email}, + {"role_id": gamer.pk, "username": other.username}, + ] + } + client.login(john) + client.json.post(url, json.dumps(data)) + + assert models.WebNotification.objects.count() == 2 + + notifications = models.WebNotification.objects.all() + + # Notification added_as_member + assert notifications[0].user == joseph + assert notifications[0].event_type == WebNotificationType.added_as_member + assert notifications[0].read is None + + # Notification added_as_member + assert notifications[1].user == other + assert notifications[1].event_type == WebNotificationType.added_as_member + assert notifications[1].read is None