diff --git a/taiga/projects/notifications/__init__.py b/taiga/projects/notifications/__init__.py index e69de29b..8b29de5b 100644 --- a/taiga/projects/notifications/__init__.py +++ b/taiga/projects/notifications/__init__.py @@ -0,0 +1,4 @@ +from .mixins import WatchedResourceMixin +from .mixins import WatchedModelMixin + +__all__ = ["WatchedModelMixin", "WatchedResourceMixin"] diff --git a/taiga/projects/notifications/choices.py b/taiga/projects/notifications/choices.py new file mode 100644 index 00000000..74387c4f --- /dev/null +++ b/taiga/projects/notifications/choices.py @@ -0,0 +1,17 @@ +import enum +from django.utils.translation import ugettext_lazy as _ + + +class NotifyLevel(enum.IntEnum): + notwatch = 1 + watch = 2 + ignore = 3 + + +NOTIFY_LEVEL_CHOICES = ( + (NotifyLevel.notwatch, _("Not watching")), + (NotifyLevel.watch, _("Watching")), + (NotifyLevel.ignore, _("Ignoring")), +) + + diff --git a/taiga/projects/notifications/mixins.py b/taiga/projects/notifications/mixins.py new file mode 100644 index 00000000..e0a796ef --- /dev/null +++ b/taiga/projects/notifications/mixins.py @@ -0,0 +1,157 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from functools import partial +from operator import is_not + +from django.conf import settings +from django.db.models.loading import get_model +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from rest_framework import serializers +from taiga.projects.history.models import HistoryType +from taiga.projects.notifications import services + + +class WatchedResourceMixin(object): + """ + Rest Framework resource mixin for resources susceptible + to be notifiable about their changes. + + NOTE: this mixin has hard dependency on HistoryMixin + defined on history app and should be located always + after it on inheritance definition. + """ + + def send_notifications(self, obj, history=None): + """ + Shortcut method for resources with special save + cases on actions methods that not uses standard + `post_save` hook of drf resources. + """ + + if history is None: + history = self.get_last_history() + + # If not history found, or it is empty. Do notthing. + if not history: + return + + obj = self.get_object_for_snapshot(obj) + + # Process that analizes the corresponding diff and + # 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) + + # Get a complete list of notifiable users for current + # object and send the change notification to them. + users = services.get_users_to_notify(obj, history=history) + services.send_notifications(obj, history=history, users=users) + + def post_save(self, obj, created=False): + self.send_notifications(obj) + super().post_save(obj, created) + + def pre_delete(self, obj): + self.send_notifications(obj) + super().pre_delete(obj) + + +class WatchedModelMixin(models.Model): + """ + Generic model mixin that makes model compatible + with notification system. + + NOTE: is mandatory extend your model class with + this mixin if you want send notifications about + your model class. + """ + watchers = models.ManyToManyField(settings.AUTH_USER_MODEL, null=True, blank=True, + related_name="%(app_label)s_%(class)s+", + verbose_name=_("watchers")) + class Meta: + abstract = True + + def get_project(self) -> object: + """ + Default implementation method for obtain a project + instance from current object. + + It comes with generic default implementation + that should works in almost all cases. + """ + return self.project + + def get_watchers(self) -> frozenset: + """ + Default implementation method for obtain a list of + watchers for current instance. + + NOTE: the default implementation returns frozen + set of all watchers if "watchers" attribute exists + in a model. + + WARNING: it returns a full evaluated set and in + future, for project with 1000k watchers it can be + very inefficient way for obtain watchers but at + this momment is the simplest way. + """ + return frozenset(self.watchers.all()) + + def get_owner(self) -> object: + """ + Default implementation for obtain the owner of + current instance. + """ + return self.owner + + def get_assigned_to(self) -> object: + """ + Default implementation for obtain the assigned + user. + """ + if hasattr(self, "assigned_to"): + return self.assigned_to + return None + + def get_participants(self) -> frozenset: + """ + Default implementation for obtain the list + of participans. It is mainly the owner and + assigned user. + """ + participants = (self.get_assigned_to(), + self.get_owner(),) + is_not_none = partial(is_not, None) + return frozenset(filter(is_not_none, participants)) + + +# class WatcherValidationSerializerMixin(object): +# def validate_watchers(self, attrs, source): +# values = set(attrs.get(source, [])) +# if values: +# project = None +# if "project" in attrs and attrs["project"]: +# project = attrs["project"] +# elif self.object: +# project = self.object.project +# model_cls = get_model("projects", "Membership") +# if len(values) != model_cls.objects.filter(project=project, user__in=values).count(): +# raise serializers.ValidationError("Error, some watcher user is not a member of the project") +# return attrs diff --git a/taiga/projects/notifications/models.py b/taiga/projects/notifications/models.py index f5fba223..80b84fc1 100644 --- a/taiga/projects/notifications/models.py +++ b/taiga/projects/notifications/models.py @@ -17,95 +17,21 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ +from .choices import NOTIFY_LEVEL_CHOICES -class WatcherMixin(models.Model): - NOTIFY_LEVEL_CHOICES = ( - ("all_owned_projects", _(u"All events on my projects")), - ("only_assigned", _(u"Only events for objects assigned to me")), - ("only_owner", _(u"Only events for objects owned by me")), - ("no_events", _(u"No events")), - ) +class NotifyPolicy(models.Model): + """ + This class represents a persistence for + project user notifications preference. + """ + project = models.ForeignKey("projects.Project", related_name="+") + user = models.ForeignKey("users.User", related_name="+") + notify_level = models.SmallIntegerField(choices=NOTIFY_LEVEL_CHOICES) - notify_level = models.CharField(max_length=32, null=False, blank=False, - default="all_owned_projects", - choices=NOTIFY_LEVEL_CHOICES, - verbose_name=_(u"notify level")) - notify_changes_by_me = models.BooleanField(blank=True, default=False, - verbose_name=_(u"notify changes by me")) + created_at = models.DateTimeField(auto_now_add=True) + modified_at = models.DateTimeField(auto_now=True) class Meta: - abstract = True - - def allow_notify_owned(self): - return (self.notify_level in [ - "only_owner", - "only_assigned", - "only_watching", - "all_owned_projects", - ]) - - def allow_notify_assigned_to(self): - return (self.notify_level in [ - "only_assigned", - "only_watching", - "all_owned_projects", - ]) - - def allow_notify_suscribed(self): - return (self.notify_level in [ - "only_watching", - "all_owned_projects", - ]) - - def allow_notify_project(self, project): - return self.notify_level == "all_owned_projects" - - -class WatchedMixin(object): - def get_watchers_to_notify(self, changer): - watchers_to_notify = set() - watchers_by_role = self._get_watchers_by_role() - - owner = watchers_by_role.get("owner", None) - if owner and owner.allow_notify_owned(): - watchers_to_notify.add(owner) - - assigned_to = watchers_by_role.get("assigned_to", None) - if assigned_to and assigned_to.allow_notify_assigned_to(): - watchers_to_notify.add(assigned_to) - - suscribed_watchers = watchers_by_role.get("suscribed_watchers", None) - if suscribed_watchers: - for suscribed_watcher in suscribed_watchers: - if suscribed_watcher and suscribed_watcher.allow_notify_suscribed(): - watchers_to_notify.add(suscribed_watcher) - - project = watchers_by_role.get("project", None) - if project: - for member in project.members.all(): - if member and member.allow_notify_project(project): - watchers_to_notify.add(member) - - if changer.notify_changes_by_me: - watchers_to_notify.add(changer) - else: - if changer in watchers_to_notify: - watchers_to_notify.remove(changer) - - return watchers_to_notify - - def _get_watchers_by_role(self): - """ - Return the actual instances of watchers of this object, classified by role. - For example: - - return { - "owner": self.owner, - "assigned_to": self.assigned_to, - "suscribed_watchers": self.watchers.all(), - "project_owner": (self.project, self.project.owner), - } - """ - raise NotImplementedError("You must subclass WatchedMixin and provide " - "_get_watchers_by_role method") + unique_together = ("project", "user",) + ordering = ["created_at"] diff --git a/taiga/projects/notifications/services.py b/taiga/projects/notifications/services.py index 1f3d273f..eac40e80 100644 --- a/taiga/projects/notifications/services.py +++ b/taiga/projects/notifications/services.py @@ -14,20 +14,170 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . + +from functools import partial + +from django.db.models.loading import get_model +from django.db import IntegrityError +from django.contrib.contenttypes.models import ContentType + from djmail import template_mail -import collections +from taiga.base import exceptions as exc +from taiga.base.utils.text import strip_lines +from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.history.choices import HistoryType -class NotificationService(object): - def send_notification_email(self, template_method, users=None, context=None): - if not users: - return +def notify_policy_exists(project, user) -> bool: + """ + Check if policy exists for specified project + and user. + """ + model_cls = get_model("notifications", "NotifyPolicy") + qs = model_cls.objects.filter(project=project, + user=user) + return qs.exists() - if not isinstance(users, collections.Iterable): - users = (users,) - mails = template_mail.MagicMailBuilder() - for user in users: - email = getattr(mails, template_method)(user, context) - email.send() +def create_notify_policy(project, user, level=NotifyLevel.notwatch): + """ + Given a project and user, create notification policy for it. + """ + model_cls = get_model("notifications", "NotifyPolicy") + try: + return model_cls.objects.create(project=project, + user=user, + notify_level=level) + except IntegrityError as e: + raise exc.IntegrityError("Notify exists for specified user and project") from e + + +def get_notify_policy(project, user): + """ + Get notification level for specified project and user. + """ + model_cls = get_model("notifications", "NotifyPolicy") + instance, _ = model_cls.objects.get_or_create(project=project, user=user, + defaults={"notify_level": NotifyLevel.notwatch}) + return instance + + +def attach_notify_policy_to_project_queryset(current_user, queryset): + """ + Function that attach "notify_level" attribute on each queryset + result for query notification level of current user for each + project in the most efficient way. + """ + + sql = strip_lines(""" + COALESCE((SELECT notifications_notifypolicy.notify_level + FROM notifications_notifypolicy + WHERE notifications_notifypolicy.project_id = projects_project.id + AND notifications_notifypolicy.user_id = {userid}), {default_level}) + """) + + sql = sql.format(userid=current_user.pk, + default_level=NotifyLevel.notwatch) + return queryset.extra(select={"notify_level": sql}) + + +def analize_object_for_watchers(obj:object, history:object): + """ + Generic implementation for analize model objects and + extract mentions from it and add it to watchers. + """ + from taiga import mdrender as mdr + + texts = (getattr(obj, "description", ""), + getattr(obj, "content", ""), + getattr(history, "comment", ""),) + + _, data = mdr.render_and_extract(obj.get_project(), "\n".join(texts)) + + if data["mentions"]: + for user in data["mentions"]: + obj.watchers.add(user) + + +def get_users_to_notify(obj, *, history) -> list: + """ + Get filtered set of users to notify for specified + model instance and changer. + + NOTE: changer at this momment is not used. + NOTE: analogouts to obj.get_watchers_to_notify(changer) + """ + project = obj.get_project() + + def _check_level(project:object, user:object, levels:tuple) -> bool: + policy = get_notify_policy(project, user) + return policy.notify_level in [int(x) for x in levels] + + _can_notify_hard = partial(_check_level, project, + levels=[NotifyLevel.watch]) + _can_notify_light = partial(_check_level, project, + levels=[NotifyLevel.watch, NotifyLevel.notwatch]) + + candidates = set() + candidates.update(filter(_can_notify_hard, project.members.all())) + candidates.update(filter(_can_notify_light, obj.get_watchers())) + candidates.update(filter(_can_notify_light, obj.get_participants())) + + # Remove the changer from candidates + candidates.discard(history.owner) + + return frozenset(candidates) + + +def _resolve_template_name(obj, *, change_type:int) -> str: + """ + Ginven an changed model instance and change type, + return the preformated template name for it. + """ + ct = ContentType.objects.get_for_model(obj.__class__) + + # Resolve integer enum value from "change_type" + # parameter to human readable string + if change_type == HistoryType.create: + change_type = "create" + elif change_type == HistoryType.change: + change_type = "change" + else: + change_type = "delete" + + tmpl = "{app_label}/{model}-{change}" + return tmpl.format(app_label=ct.app_label, + model=ct.model, + change=change_type) + + +def _make_template_mail(name:str): + """ + Helper that creates a adhoc djmail template email + instance for specified name, and return an instance + of it. + """ + cls = type("TemplateMail", + (template_mail.TemplateMail,), + {"name": name}) + + return cls() + + +def send_notifications(obj, *, history, users): + """ + Given changed instance, history entry and + a complete list for users to notify, send + email to all users. + """ + context = {"object": obj, + "changer": history.owner, + "comment": history.comment, + "changed_fields": history.values_diff} + + template_name = _resolve_template_name(obj, change_type=history.type) + email = _make_template_mail(template_name) + + for user in users: + email.send(user.email, context)