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