Notifications: new notification application.

remotes/origin/enhancement/email-actions
Andrey Antukh 2014-06-05 12:46:40 +02:00
parent a4e8bcbe12
commit 1507ee43f2
5 changed files with 352 additions and 98 deletions

View File

@ -0,0 +1,4 @@
from .mixins import WatchedResourceMixin
from .mixins import WatchedModelMixin
__all__ = ["WatchedModelMixin", "WatchedResourceMixin"]

View File

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

View File

@ -0,0 +1,157 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# 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 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

View File

@ -17,95 +17,21 @@
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from .choices import NOTIFY_LEVEL_CHOICES
class WatcherMixin(models.Model): class NotifyPolicy(models.Model):
NOTIFY_LEVEL_CHOICES = ( """
("all_owned_projects", _(u"All events on my projects")), This class represents a persistence for
("only_assigned", _(u"Only events for objects assigned to me")), project user notifications preference.
("only_owner", _(u"Only events for objects owned by me")), """
("no_events", _(u"No events")), 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, created_at = models.DateTimeField(auto_now_add=True)
default="all_owned_projects", modified_at = models.DateTimeField(auto_now=True)
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"))
class Meta: class Meta:
abstract = True unique_together = ("project", "user",)
ordering = ["created_at"]
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")

View File

@ -14,20 +14,170 @@
# 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 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 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 notify_policy_exists(project, user) -> bool:
def send_notification_email(self, template_method, users=None, context=None): """
if not users: Check if policy exists for specified project
return 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() def create_notify_policy(project, user, level=NotifyLevel.notwatch):
for user in users: """
email = getattr(mails, template_method)(user, context) Given a project and user, create notification policy for it.
email.send() """
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)