Web notifications
parent
bd627d94dd
commit
b6d852b5dd
|
@ -1,4 +0,0 @@
|
||||||
from .celery import *
|
|
||||||
|
|
||||||
# To use celery in memory
|
|
||||||
#task_always_eager = True
|
|
|
@ -91,6 +91,22 @@ def emit_event_for_model(obj, *, type:str="change", channel:str="events",
|
||||||
sessionid=sessionid,
|
sessionid=sessionid,
|
||||||
data=data)
|
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",
|
def emit_live_notification_for_model(obj, user, history, *, type:str="change", channel:str="events",
|
||||||
sessionid:str="not-existing"):
|
sessionid:str="not-existing"):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -45,6 +45,7 @@ from taiga.projects.epics.models import Epic
|
||||||
from taiga.projects.history.mixins import HistoryResourceMixin
|
from taiga.projects.history.mixins import HistoryResourceMixin
|
||||||
from taiga.projects.issues.models import Issue
|
from taiga.projects.issues.models import Issue
|
||||||
from taiga.projects.likes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin
|
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.mixins import WatchersViewSetMixin
|
||||||
from taiga.projects.notifications.choices import NotifyLevel
|
from taiga.projects.notifications.choices import NotifyLevel
|
||||||
from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin
|
from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin
|
||||||
|
@ -980,6 +981,10 @@ class MembershipViewSet(BlockedByProjectMixin, ModelCrudViewSet):
|
||||||
invitation_extra_text=invitation_extra_text,
|
invitation_extra_text=invitation_extra_text,
|
||||||
callback=self.post_save,
|
callback=self.post_save,
|
||||||
precall=self.pre_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:
|
except exc.ValidationError as err:
|
||||||
return response.BadRequest(err.message_dict)
|
return response.BadRequest(err.message_dict)
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,8 @@ from taiga.base import response
|
||||||
from taiga.base.decorators import detail_route
|
from taiga.base.decorators import detail_route
|
||||||
from taiga.base.api import ReadOnlyListViewSet
|
from taiga.base.api import ReadOnlyListViewSet
|
||||||
from taiga.mdrender.service import render as mdrender
|
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 permissions
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
@ -57,6 +59,11 @@ class HistoryViewSet(ReadOnlyListViewSet):
|
||||||
|
|
||||||
return response.Ok(serializer.data)
|
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'])
|
@detail_route(methods=['get'])
|
||||||
def comment_versions(self, request, pk):
|
def comment_versions(self, request, pk):
|
||||||
obj = self.get_object()
|
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.edit_comment_date = timezone.now()
|
||||||
history_entry.comment = comment
|
history_entry.comment = comment
|
||||||
history_entry.comment_html = mdrender(obj.project, comment)
|
history_entry.comment_html = mdrender(obj.project, comment)
|
||||||
history_entry.comment_versions = comment_versions
|
history_entry.comment_versions = comment_versions
|
||||||
history_entry.save()
|
history_entry.save()
|
||||||
|
|
||||||
|
if new_mentions:
|
||||||
|
signal_mentions.send(sender=self.__class__,
|
||||||
|
user=self.request.user,
|
||||||
|
obj=obj,
|
||||||
|
mentions=new_mentions)
|
||||||
|
|
||||||
return response.Ok()
|
return response.Ok()
|
||||||
|
|
||||||
@detail_route(methods=['post'])
|
@detail_route(methods=['post'])
|
||||||
|
|
|
@ -30,7 +30,9 @@ from taiga.base.api.utils import get_object_or_404
|
||||||
from taiga.projects.history.mixins import HistoryResourceMixin
|
from taiga.projects.history.mixins import HistoryResourceMixin
|
||||||
from taiga.projects.mixins.by_ref import ByRefMixin
|
from taiga.projects.mixins.by_ref import ByRefMixin
|
||||||
from taiga.projects.models import Project, IssueStatus, Severity, Priority, IssueType
|
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.occ import OCCResourceMixin
|
||||||
from taiga.projects.tagging.api import TaggedResourceMixin
|
from taiga.projects.tagging.api import TaggedResourceMixin
|
||||||
from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin
|
from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin
|
||||||
|
@ -44,8 +46,10 @@ from . import serializers
|
||||||
from . import validators
|
from . import validators
|
||||||
|
|
||||||
|
|
||||||
class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
|
class IssueViewSet(AssignedToSignalMixin, OCCResourceMixin, VotedResourceMixin,
|
||||||
ByRefMixin, TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
|
HistoryResourceMixin, WatchedResourceMixin, ByRefMixin,
|
||||||
|
TaggedResourceMixin, BlockedByProjectMixin,
|
||||||
|
ModelCrudViewSet):
|
||||||
validator_class = validators.IssueValidator
|
validator_class = validators.IssueValidator
|
||||||
queryset = models.Issue.objects.all()
|
queryset = models.Issue.objects.all()
|
||||||
permission_classes = (permissions.IssuePermission, )
|
permission_classes = (permissions.IssuePermission, )
|
||||||
|
|
|
@ -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"
|
|
@ -17,8 +17,12 @@
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from django.db.models import Q
|
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 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.notifications.choices import NotifyLevel
|
||||||
from taiga.projects.models import Project
|
from taiga.projects.models import Project
|
||||||
|
@ -50,3 +54,50 @@ class NotifyPolicyViewSet(ModelCrudViewSet):
|
||||||
return models.NotifyPolicy.objects.filter(user=self.request.user).filter(
|
return models.NotifyPolicy.objects.filter(user=self.request.user).filter(
|
||||||
Q(project__owner=self.request.user) | Q(project__memberships__user=self.request.user)
|
Q(project__owner=self.request.user) | Q(project__memberships__user=self.request.user)
|
||||||
).distinct()
|
).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()
|
||||||
|
|
|
@ -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)
|
|
@ -31,3 +31,22 @@ NOTIFY_LEVEL_CHOICES = (
|
||||||
(NotifyLevel.all, _("All")),
|
(NotifyLevel.all, _("All")),
|
||||||
(NotifyLevel.none, _("None")),
|
(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")),
|
||||||
|
)
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -29,6 +29,12 @@ from taiga.base.api.utils import get_object_or_404
|
||||||
from taiga.base.fields import WatchersField, MethodField
|
from taiga.base.fields import WatchersField, MethodField
|
||||||
from taiga.projects.notifications import services
|
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
|
from . serializers import WatcherSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@ -47,6 +53,8 @@ class WatchedResourceMixin:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_not_notify = False
|
_not_notify = False
|
||||||
|
_old_watchers = None
|
||||||
|
_old_mentions = []
|
||||||
|
|
||||||
@detail_route(methods=["POST"])
|
@detail_route(methods=["POST"])
|
||||||
def watch(self, request, pk=None):
|
def watch(self, request, pk=None):
|
||||||
|
@ -86,13 +94,38 @@ class WatchedResourceMixin:
|
||||||
# some text fields for extract mentions and add them
|
# some text fields for extract mentions and add them
|
||||||
# to watchers before obtain a complete list of
|
# to watchers before obtain a complete list of
|
||||||
# notifiable users.
|
# 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
|
# Get a complete list of notifiable users for current
|
||||||
# object and send the change notification to them.
|
# object and send the change notification to them.
|
||||||
services.send_notifications(obj, history=history)
|
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):
|
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)
|
self.send_notifications(obj)
|
||||||
super().post_save(obj, created)
|
super().post_save(obj, created)
|
||||||
|
|
||||||
|
@ -100,6 +133,84 @@ class WatchedResourceMixin:
|
||||||
self.send_notifications(obj)
|
self.send_notifications(obj)
|
||||||
super().pre_delete(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):
|
class WatchedModelMixin(object):
|
||||||
"""
|
"""
|
||||||
|
@ -274,3 +385,47 @@ class WatchersViewSetMixin:
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
resource = self.resource_model.objects.get(pk=self.kwargs.get("resource_id"))
|
resource = self.resource_model.objects.get(pk=self.kwargs.get("resource_id"))
|
||||||
return resource.get_watchers()
|
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
|
||||||
|
|
|
@ -23,6 +23,7 @@ from django.db import models
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from taiga.base.db.models.fields import JSONField
|
||||||
from taiga.projects.history.choices import HISTORY_TYPE_CHOICES
|
from taiga.projects.history.choices import HISTORY_TYPE_CHOICES
|
||||||
|
|
||||||
from .choices import NOTIFY_LEVEL_CHOICES, NotifyLevel
|
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")
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="notify_policies")
|
||||||
notify_level = models.SmallIntegerField(choices=NOTIFY_LEVEL_CHOICES)
|
notify_level = models.SmallIntegerField(choices=NOTIFY_LEVEL_CHOICES)
|
||||||
live_notify_level = models.SmallIntegerField(choices=NOTIFY_LEVEL_CHOICES, default=NotifyLevel.involved)
|
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)
|
created_at = models.DateTimeField(default=timezone.now)
|
||||||
modified_at = models.DateTimeField()
|
modified_at = models.DateTimeField()
|
||||||
|
@ -94,3 +96,11 @@ class Watched(models.Model):
|
||||||
verbose_name = _("Watched")
|
verbose_name = _("Watched")
|
||||||
verbose_name_plural = _("Watched")
|
verbose_name_plural = _("Watched")
|
||||||
unique_together = ("content_type", "object_id", "user", "project")
|
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()
|
||||||
|
|
|
@ -16,8 +16,13 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# 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.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.models import get_user_model_safe
|
||||||
|
from taiga.users.services import get_user_photo_url, get_user_big_photo_url
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
@ -27,7 +32,8 @@ class NotifyPolicySerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.NotifyPolicy
|
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):
|
def get_project_name(self, obj):
|
||||||
return obj.project.name
|
return obj.project.name
|
||||||
|
@ -39,3 +45,67 @@ class WatcherSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = get_user_model_safe()
|
model = get_user_model_safe()
|
||||||
fields = ('id', 'username', 'full_name')
|
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()
|
||||||
|
|
|
@ -73,7 +73,8 @@ def create_notify_policy(project, user, level=NotifyLevel.involved,
|
||||||
|
|
||||||
def create_notify_policy_if_not_exists(project, user,
|
def create_notify_policy_if_not_exists(project, user,
|
||||||
level=NotifyLevel.involved,
|
level=NotifyLevel.involved,
|
||||||
live_level=NotifyLevel.involved):
|
live_level=NotifyLevel.involved,
|
||||||
|
web_level=True):
|
||||||
"""
|
"""
|
||||||
Given a project and user, create notification policy for it.
|
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(
|
result = model_cls.objects.get_or_create(
|
||||||
project=project,
|
project=project,
|
||||||
user=user,
|
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]
|
return result[0]
|
||||||
except IntegrityError as e:
|
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
|
Generic implementation for analize model objects and
|
||||||
extract mentions from it and add it to watchers.
|
extract mentions from it and add it to watchers.
|
||||||
"""
|
"""
|
||||||
|
if not hasattr(obj, "add_watcher"):
|
||||||
if not hasattr(obj, "get_project"):
|
|
||||||
return
|
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
|
return
|
||||||
|
|
||||||
texts = (getattr(obj, "description", ""),
|
texts = (getattr(obj, "description", ""),
|
||||||
getattr(obj, "content", ""),
|
getattr(obj, "content", ""),
|
||||||
comment,)
|
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
|
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"]:
|
return data.get("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)
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_by_permissions(obj, user):
|
def _filter_by_permissions(obj, user):
|
||||||
|
|
|
@ -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)
|
|
@ -30,7 +30,9 @@ from taiga.projects.history.mixins import HistoryResourceMixin
|
||||||
from taiga.projects.milestones.models import Milestone
|
from taiga.projects.milestones.models import Milestone
|
||||||
from taiga.projects.mixins.by_ref import ByRefMixin
|
from taiga.projects.mixins.by_ref import ByRefMixin
|
||||||
from taiga.projects.models import Project, TaskStatus
|
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.occ import OCCResourceMixin
|
||||||
from taiga.projects.tagging.api import TaggedResourceMixin
|
from taiga.projects.tagging.api import TaggedResourceMixin
|
||||||
from taiga.projects.userstories.models import UserStory
|
from taiga.projects.userstories.models import UserStory
|
||||||
|
@ -45,8 +47,10 @@ from . import validators
|
||||||
from . import utils as tasks_utils
|
from . import utils as tasks_utils
|
||||||
|
|
||||||
|
|
||||||
class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
|
class TaskViewSet(AssignedToSignalMixin, OCCResourceMixin, VotedResourceMixin,
|
||||||
ByRefMixin, TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
|
HistoryResourceMixin, WatchedResourceMixin, ByRefMixin,
|
||||||
|
TaggedResourceMixin, BlockedByProjectMixin,
|
||||||
|
ModelCrudViewSet):
|
||||||
validator_class = validators.TaskValidator
|
validator_class = validators.TaskValidator
|
||||||
queryset = models.Task.objects.all()
|
queryset = models.Task.objects.all()
|
||||||
permission_classes = (permissions.TaskPermission,)
|
permission_classes = (permissions.TaskPermission,)
|
||||||
|
|
|
@ -39,6 +39,7 @@ from taiga.projects.history.services import take_snapshot
|
||||||
from taiga.projects.milestones.models import Milestone
|
from taiga.projects.milestones.models import Milestone
|
||||||
from taiga.projects.mixins.by_ref import ByRefMixin
|
from taiga.projects.mixins.by_ref import ByRefMixin
|
||||||
from taiga.projects.models import Project, UserStoryStatus
|
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 WatchedResourceMixin
|
||||||
from taiga.projects.notifications.mixins import WatchersViewSetMixin
|
from taiga.projects.notifications.mixins import WatchersViewSetMixin
|
||||||
from taiga.projects.occ import OCCResourceMixin
|
from taiga.projects.occ import OCCResourceMixin
|
||||||
|
@ -55,8 +56,10 @@ from . import services
|
||||||
from . import validators
|
from . import validators
|
||||||
|
|
||||||
|
|
||||||
class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
|
class UserStoryViewSet(AssignedUsersSignalMixin, OCCResourceMixin,
|
||||||
ByRefMixin, TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
|
VotedResourceMixin, HistoryResourceMixin,
|
||||||
|
WatchedResourceMixin, ByRefMixin, TaggedResourceMixin,
|
||||||
|
BlockedByProjectMixin, ModelCrudViewSet):
|
||||||
validator_class = validators.UserStoryValidator
|
validator_class = validators.UserStoryValidator
|
||||||
queryset = models.UserStory.objects.all()
|
queryset = models.UserStory.objects.all()
|
||||||
permission_classes = (permissions.UserStoryPermission,)
|
permission_classes = (permissions.UserStoryPermission,)
|
||||||
|
|
|
@ -43,11 +43,14 @@ from taiga.userstorage.api import StorageEntriesViewSet
|
||||||
router.register(r"user-storage", StorageEntriesViewSet, base_name="user-storage")
|
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 NotifyPolicyViewSet
|
||||||
|
from taiga.projects.notifications.api import WebNotificationsViewSet
|
||||||
|
|
||||||
router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications")
|
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
|
# Project settings
|
||||||
from taiga.projects.settings.api import UserProjectSettingsViewSet, SectionsViewSet
|
from taiga.projects.settings.api import UserProjectSettingsViewSet, SectionsViewSet
|
||||||
|
|
|
@ -19,7 +19,6 @@
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
import math
|
|
||||||
import base64
|
import base64
|
||||||
import datetime
|
import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
|
@ -35,17 +34,16 @@ from django.utils import timezone
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from .. import factories as f
|
from .. import factories as f
|
||||||
|
|
||||||
|
from taiga.base.api.settings import api_settings
|
||||||
from taiga.base.utils import json
|
from taiga.base.utils import json
|
||||||
from taiga.projects.notifications import services
|
from taiga.projects.notifications import services
|
||||||
from taiga.projects.notifications import utils
|
|
||||||
from taiga.projects.notifications import models
|
from taiga.projects.notifications import models
|
||||||
from taiga.projects.notifications.choices import NotifyLevel
|
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.choices import HistoryType
|
||||||
from taiga.projects.history.services import take_snapshot
|
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.permissions.choices import MEMBERS_PERMISSIONS
|
||||||
|
from taiga.users.gravatar import get_user_gravatar_id
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
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))
|
ts.append(ts[-1] + datetime.timedelta(microseconds=(f << 18)//10))
|
||||||
|
|
||||||
return guid, ts
|
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
|
||||||
|
|
Loading…
Reference in New Issue