Web notifications

remotes/origin/4.0rc
Daniel García 2018-09-27 14:48:35 +02:00 committed by Alex Hermida
parent bd627d94dd
commit b6d852b5dd
19 changed files with 978 additions and 34 deletions

View File

@ -1,4 +0,0 @@
from .celery import *
# To use celery in memory
#task_always_eager = True

View File

@ -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"):
"""

View File

@ -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)

View File

@ -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'])

View File

@ -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, )

View File

@ -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 <http://www.gnu.org/licenses/>.
default_app_config = "taiga.projects.notifications.apps.NotificationsAppConfig"

View File

@ -17,8 +17,12 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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()

View File

@ -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 <http://www.gnu.org/licenses/>.
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)

View File

@ -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")),
)

View File

@ -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),
),
]

View File

@ -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

View File

@ -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()

View File

@ -16,8 +16,13 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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()

View File

@ -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):

View File

@ -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 <http://www.gnu.org/licenses/>.
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)

View File

@ -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,)

View File

@ -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,)

View File

@ -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<resource_id>\d+)/set-as-read", WebNotificationsViewSet, base_name="web-notifications")
# Project settings
from taiga.projects.settings.api import UserProjectSettingsViewSet, SectionsViewSet

View File

@ -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