Merge pull request #46 from taigaio/notify-refactor

Notifications refactor and History improvements
remotes/origin/enhancement/email-actions
David Barragán Merino 2014-06-12 11:02:04 +02:00
commit 1de3fa8748
91 changed files with 1345 additions and 390 deletions

View File

@ -11,7 +11,7 @@ install:
- sudo apt-get install postgresql-plpython-9.3
- pip install -r requirements-devel.txt --use-mirrors
script:
- coverage run --source=taiga --omit='*tests*,*commands*,*migrations*,*admin*,*.jinja,*dashboard*,*settings*,*wsgi*,*questions*,*documents*' -m py.test -v
- coverage run --source=taiga --omit='*tests*,*commands*,*migrations*,*admin*,*.jinja,*dashboard*,*settings*,*wsgi*,*questions*,*documents*' -m py.test -v --tb=native
notifications:
email:
recipients:

View File

@ -9,7 +9,7 @@ gunicorn==18.0
psycopg2==2.5.3
pytz==2014.4
six==1.6.1
djmail==0.4
djmail==0.6
django-pgjson==0.1.2
django-jinja==1.0.1
jinja2==2.7.2

27
taiga/base/utils/text.py Normal file
View File

@ -0,0 +1,27 @@
# 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/>.
def strip_lines(text):
"""
Given text, try remove unnecesary spaces and
put text in one unique line.
"""
output = text.replace("\r\n", " ")
output = output.replace("\r", " ")
output = output.replace("\n", " ")
return output.strip()

View File

@ -0,0 +1 @@
from .service import *

View File

@ -22,15 +22,18 @@ from rest_framework.permissions import IsAuthenticated
from taiga.base.api import ModelCrudViewSet
from taiga.base import filters
from taiga.base import exceptions as exc
from taiga.projects.mixins.notifications import NotificationSenderMixin
from taiga.projects.history.services import take_snapshot
from taiga.projects.notifications import WatchedResourceMixin
from taiga.projects.history import HistoryResourceMixin
from . import permissions
from . import serializers
from . import models
class BaseAttachmentViewSet(NotificationSenderMixin, ModelCrudViewSet):
class BaseAttachmentViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudViewSet):
model = models.Attachment
serializer_class = serializers.AttachmentSerializer
permission_classes = (IsAuthenticated, permissions.AttachmentPermission,)
@ -65,22 +68,9 @@ class BaseAttachmentViewSet(NotificationSenderMixin, ModelCrudViewSet):
raise exc.PermissionDenied(_("You don't have permissions for "
"add attachments to this user story"))
def _get_object_for_snapshot(self, obj):
def get_object_for_snapshot(self, obj):
return obj.content_object
def pre_destroy(self, obj):
pass
def post_destroy(self, obj):
user = self.request.user
comment = self.request.DATA.get("comment", "")
obj = self._get_object_for_snapshot(obj)
history = take_snapshot(obj, comment=comment, user=user)
if history:
self._post_save_notification_sender(obj, history)
class UserStoryAttachmentViewSet(BaseAttachmentViewSet):
content_type = "userstories.userstory"

View File

@ -0,0 +1,19 @@
# 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 .mixins import HistoryResourceMixin
__all__ = ["HistoryResourceMixin"]

View File

@ -0,0 +1,29 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# 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/>.
import enum
from django.utils.translation import ugettext_lazy as _
class HistoryType(enum.IntEnum):
change = 1
create = 2
delete = 3
HISTORY_TYPE_CHOICES = ((HistoryType.change, _("Change")),
(HistoryType.create, _("Create")),
(HistoryType.delete, _("Delete")))

View File

@ -150,6 +150,12 @@ def wikipage_values(diff):
# Freezes
####################
def _generic_extract(obj:object, fields:list, default=None) -> dict:
result = {}
for fieldname in fields:
result[fieldname] = getattr(obj, fieldname, default)
return result
@as_tuple
def extract_attachments(obj) -> list:
@ -162,6 +168,22 @@ def extract_attachments(obj) -> list:
"order": attach.order}
def project_freezer(project) -> dict:
fields = ("name",
"slug",
"created_at",
"owner_id",
"public",
"total_milestones",
"total_story_points",
"tags",
"is_backlog_activated",
"is_kanban_activated",
"is_wiki_activated",
"is_issues_activated")
return _generic_extract(project, fields)
def milestone_freezer(milestone) -> dict:
snapshot = {
"name": milestone.name,
@ -175,6 +197,7 @@ def milestone_freezer(milestone) -> dict:
return snapshot
def userstory_freezer(us) -> dict:
rp_cls = get_model("userstories", "RolePoints")
rpqsd = rp_cls.objects.filter(user_story=us)

View File

@ -0,0 +1,76 @@
# 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/>.
import warnings
from .services import take_snapshot
class HistoryResourceMixin(object):
"""
Rest Framework resource mixin for resources
susceptible to have models with history.
"""
# This attribute will store the last history entry
# created for this resource. It is mainly used for
# notifications mixin.
__last_history = None
__object_saved = False
def get_last_history(self):
if not self.__object_saved:
message = ("get_last_history() function called before any object are saved. "
"Seems you have a wrong mixing order on your resource.")
warnings.warn(message, RuntimeWarning)
return self.__last_history
def get_object_for_snapshot(self, obj):
"""
Method that returns a model instance ready to snapshot.
It is by default noop, but should be overwrited when
snapshot ready instance is found in one of foreign key
fields.
"""
return obj
def persist_history_snapshot(self, obj=None, delete:bool=False):
"""
Shortcut for resources with special save/persist
logic.
"""
user = self.request.user
comment = self.request.DATA.get("comment", "")
if obj is None:
obj = self.get_object()
sobj = self.get_object_for_snapshot(obj)
if sobj != obj and delete:
delete = False
self.__last_history = take_snapshot(sobj, comment=comment, user=user, delete=delete)
self.__object_saved = True
def post_save(self, obj, created=False):
self.persist_history_snapshot()
super().post_save(obj, created=created)
def pre_delete(self, obj):
self.persist_history_snapshot(obj, delete=True)
super().pre_delete(obj)

View File

@ -13,7 +13,6 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import uuid
import enum
from django.utils.translation import ugettext_lazy as _
from django.db import models
@ -21,10 +20,8 @@ from django.db.models.loading import get_model
from django.utils.functional import cached_property
from django_pgjson.fields import JsonField
class HistoryType(enum.IntEnum):
change = 1
create = 2
from .choices import HistoryType
from .choices import HISTORY_TYPE_CHOICES
class HistoryEntry(models.Model):
@ -35,16 +32,12 @@ class HistoryEntry(models.Model):
It is used for store object changes and
comments.
"""
TYPE_CHOICES = ((HistoryType.change, _("Change")),
(HistoryType.create, _("Create")))
id = models.CharField(primary_key=True, max_length=255, unique=True,
editable=False, default=lambda: str(uuid.uuid1()))
user = JsonField(blank=True, default=None, null=True)
created_at = models.DateTimeField(auto_now_add=True)
type = models.SmallIntegerField(choices=TYPE_CHOICES)
type = models.SmallIntegerField(choices=HISTORY_TYPE_CHOICES)
is_snapshot = models.BooleanField(default=False)
key = models.CharField(max_length=255, null=True, default=None, blank=True)

View File

@ -25,23 +25,26 @@ This is possible example:
# Do something...
history.persist_history(object, user=request.user)
"""
import logging
from collections import namedtuple
from functools import partial, wraps, lru_cache
from copy import deepcopy
from functools import partial
from functools import wraps
from functools import lru_cache
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.paginator import Paginator, InvalidPage
from django.db.models.loading import get_model
from django.db import transaction as tx
from django.core.paginator import Paginator, InvalidPage
from django.contrib.contenttypes.models import ContentType
from .models import HistoryType
from taiga.mdrender.service import render as mdrender
from taiga.mdrender.service import get_diff_of_htmls
from taiga.base.utils.db import get_typename_for_model_class
from .models import HistoryType
# Type that represents a freezed object
FrozenObj = namedtuple("FrozenObj", ["key", "snapshot"])
FrozenDiff = namedtuple("FrozenDiff", ["key", "diff", "snapshot"])
@ -52,6 +55,8 @@ _freeze_impl_map = {}
# Dict containing registred containing with their values implementation.
_values_impl_map = {}
log = logging.getLogger("taiga.history")
def make_key_from_model_object(obj:object) -> str:
"""
@ -110,7 +115,16 @@ def freeze_model_instance(obj:object) -> FrozenObj:
wrapped into FrozenObj.
"""
typename = get_typename_for_model_class(obj.__class__)
model_cls = obj.__class__
# Additional query for test if object is really exists
# on the database or it is removed.
try:
obj = model_cls.objects.get(pk=obj.pk)
except model_cls.DoesNotExist:
return None
typename = get_typename_for_model_class(model_cls)
if typename not in _freeze_impl_map:
raise RuntimeError("No implementation found for {}".format(typename))
@ -160,10 +174,13 @@ def make_diff(oldobj:FrozenObj, newobj:FrozenObj) -> FrozenDiff:
def make_diff_values(typename:str, fdiff:FrozenDiff) -> dict:
"""
Given a typename and diff, build a values dict for it.
If no implementation found for typename, warnig is raised in
logging and returns empty dict.
"""
if typename not in _values_impl_map:
raise RuntimeError("No implementation found for {}".format(typename))
log.warning("No implementation found of '{}' for values.".format(typename))
return {}
impl_fn = _values_impl_map[typename]
return impl_fn(fdiff.diff)
@ -209,7 +226,7 @@ def get_last_snapshot_for_key(key:str) -> FrozenObj:
# Public api
@tx.atomic
def take_snapshot(obj:object, *, comment:str="", user=None):
def take_snapshot(obj:object, *, comment:str="", user=None, delete:bool=False):
"""
Given any model instance with registred content type,
create new history entry of "change" type.
@ -222,33 +239,53 @@ def take_snapshot(obj:object, *, comment:str="", user=None):
typename = get_typename_for_model_class(obj.__class__)
new_fobj = freeze_model_instance(obj)
old_fobj, need_snapshot = get_last_snapshot_for_key(key)
old_fobj, need_real_snapshot = get_last_snapshot_for_key(key)
entry_model = get_model("history", "HistoryEntry")
user_id = None if user is None else user.id
user_name = "" if user is None else user.get_full_name()
# Determine history type
if delete:
entry_type = HistoryType.delete
elif new_fobj and not old_fobj:
entry_type = HistoryType.create
elif new_fobj and old_fobj:
entry_type = HistoryType.change
else:
raise RuntimeError("Unexpected condition")
kwargs = {
"user": {"pk": user_id, "name": user_name},
"key": key,
"type": entry_type,
"comment": "",
"comment_html": "",
"diff": None,
"values": None,
"snapshot": None,
"is_snapshot": False,
}
fdiff = make_diff(old_fobj, new_fobj)
fvals = make_diff_values(typename, fdiff)
# If diff and comment are empty, do
# not create empty history entry
if not fdiff.diff and not comment and old_fobj != None:
if (not fdiff.diff and not comment
and old_fobj is not None
and entry_type != HistoryType.delete):
return None
entry_type = HistoryType.change if old_fobj else HistoryType.create
entry_model = get_model("history", "HistoryEntry")
user_id = None if user is None else user.id
user_name = "" if user is None else user.get_full_name()
kwargs = {
"user": {"pk": user_id, "name": user_name},
"type": entry_type,
"key": key,
"diff": fdiff.diff,
"snapshot": fdiff.snapshot if need_snapshot else None,
"is_snapshot": need_snapshot,
"comment": comment,
"comment_html": mdrender(obj.project, comment),
kwargs.update({
"snapshot": fdiff.snapshot if need_real_snapshot else None,
"is_snapshot": need_real_snapshot,
"values": fvals,
}
"comment": comment,
"diff": fdiff.diff,
"comment_html": mdrender(obj.project, comment),
})
return entry_model.objects.create(**kwargs)
@ -267,12 +304,14 @@ def get_history_queryset_by_model_instance(obj:object, types=(HistoryType.change
# Freeze implementatitions
from .freeze_impl import project_freezer
from .freeze_impl import milestone_freezer
from .freeze_impl import userstory_freezer
from .freeze_impl import issue_freezer
from .freeze_impl import task_freezer
from .freeze_impl import wikipage_freezer
register_freeze_implementation("projects.project", project_freezer)
register_freeze_implementation("milestones.milestone", milestone_freezer,)
register_freeze_implementation("userstories.userstory", userstory_freezer)
register_freeze_implementation("issues.issue", issue_freezer)

View File

@ -27,7 +27,9 @@ from taiga.base import exceptions as exc
from taiga.base.decorators import list_route, detail_route
from taiga.base.api import ModelCrudViewSet
from taiga.projects.mixins.notifications import NotificationSenderMixin
from taiga.projects.notifications import WatchedResourceMixin
from taiga.projects.history import HistoryResourceMixin
from taiga.projects.votes.utils import attach_votescount_to_queryset
from taiga.projects.votes import services as votes_service
@ -87,6 +89,7 @@ class IssuesFilter(filters.FilterBackend):
return queryset
class IssuesOrdering(filters.FilterBackend):
def filter_queryset(self, request, queryset, view):
order_by = request.QUERY_PARAMS.get('order_by', None)
@ -99,25 +102,27 @@ class IssuesOrdering(filters.FilterBackend):
return queryset
class IssueViewSet(NotificationSenderMixin, ModelCrudViewSet):
model = models.Issue
queryset = models.Issue.objects.all().prefetch_related("attachments")
class IssueViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudViewSet):
serializer_class = serializers.IssueNeighborsSerializer
list_serializer_class = serializers.IssueSerializer
permission_classes = (IsAuthenticated, permissions.IssuePermission)
filter_backends = (filters.IsProjectMemberFilterBackend, IssuesFilter, IssuesOrdering)
retrieve_exclude_filters = (IssuesFilter,)
filter_fields = ("project",)
order_by_fields = ("severity", "status", "priority", "created_date", "modified_date", "owner",
"assigned_to", "subject")
create_notification_template = "create_issue_notification"
update_notification_template = "update_issue_notification"
destroy_notification_template = "destroy_issue_notification"
filter_fields = ("project",)
order_by_fields = ("severity",
"status",
"priority",
"created_date",
"modified_date",
"owner",
"assigned_to",
"subject")
def get_queryset(self):
qs = self.model.objects.all()
qs = models.Issue.objects.all()
qs = qs.prefetch_related("attachments")
qs = attach_votescount_to_queryset(qs, as_field="votes_count")
return qs

View File

@ -24,11 +24,11 @@ from django.utils.translation import ugettext_lazy as _
from picklefield.fields import PickledObjectField
from taiga.base.utils.slug import ref_uniquely
from taiga.projects.notifications.models import WatchedMixin
from taiga.projects.notifications import WatchedModelMixin
from taiga.projects.mixins.blocked import BlockedMixin
class Issue(WatchedMixin, BlockedMixin, models.Model):
class Issue(WatchedModelMixin, BlockedMixin, models.Model):
ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None,
verbose_name=_("ref"))
owner = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, default=None,
@ -58,9 +58,6 @@ class Issue(WatchedMixin, BlockedMixin, models.Model):
assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True,
default=None, related_name="issues_assigned_to_me",
verbose_name=_("assigned to"))
watchers = models.ManyToManyField(settings.AUTH_USER_MODEL, null=True, blank=True,
related_name="watched_issues",
verbose_name=_("watchers"))
tags = PickledObjectField(null=False, blank=True, verbose_name=_("tags"))
attachments = generic.GenericRelation("attachments.Attachment")
@ -90,14 +87,6 @@ class Issue(WatchedMixin, BlockedMixin, models.Model):
return ", ".join(value)
return value
def _get_watchers_by_role(self):
return {
"owner": self.owner,
"assigned_to": self.assigned_to,
"suscribed_watchers": self.watchers.all(),
"project": self.project,
}
# Model related signals handlers
@receiver(models.signals.pre_save, sender=Issue, dispatch_uid="issue_finished_date_handler")

View File

@ -18,7 +18,7 @@ from rest_framework import serializers
from taiga.base.serializers import PickleField, NeighborsSerializerMixin
from taiga.projects.attachments.serializers import AttachmentSerializer
from taiga.projects.mixins.notifications import WatcherValidationSerializerMixin
# from taiga.projects.mixins.notifications import WatcherValidationSerializerMixin
from taiga.mdrender.service import render as mdrender
from . import models
@ -29,7 +29,9 @@ class IssueAttachmentSerializer(AttachmentSerializer):
fields = ("id", "name", "size", "url", "owner", "created_date", "modified_date", )
class IssueSerializer(WatcherValidationSerializerMixin, serializers.ModelSerializer):
# class IssueSerializer(WatcherValidationSerializerMixin, serializers.ModelSerializer):
class IssueSerializer(serializers.ModelSerializer):
tags = PickleField(required=False)
is_closed = serializers.Field(source="is_closed")
comment = serializers.SerializerMethodField("get_comment")

View File

@ -24,7 +24,10 @@ from taiga.base import filters
from taiga.base import exceptions as exc
from taiga.base.decorators import detail_route
from taiga.base.api import ModelCrudViewSet
from taiga.projects.mixins.notifications import NotificationSenderMixin
from taiga.projects.notifications import WatchedResourceMixin
from taiga.projects.history import HistoryResourceMixin
from . import serializers
from . import models
@ -33,21 +36,20 @@ from . import permissions
import datetime
class MilestoneViewSet(NotificationSenderMixin, ModelCrudViewSet):
# TODO: Refactor this, too much prefetch related
queryset = models.Milestone.objects.all().order_by("-estimated_start").prefetch_related(
"user_stories",
"user_stories__role_points",
"user_stories__role_points__points",
"user_stories__role_points__role",
)
class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudViewSet):
serializer_class = serializers.MilestoneSerializer
permission_classes = (IsAuthenticated, permissions.MilestonePermission)
filter_backends = (filters.IsProjectMemberFilterBackend,)
filter_fields = ("project",)
create_notification_template = "create_milestone_notification"
update_notification_template = "update_milestone_notification"
destroy_notification_template = "destroy_milestone_notification"
def get_queryset(self):
qs = models.Milestone.objects.all()
qs = qs.prefetch_related("user_stories",
"user_stories__role_points",
"user_stories__role_points__points",
"user_stories__role_points__role")
qs = qs.order_by("-estimated_start")
return qs
def pre_conditions_on_save(self, obj):
super().pre_conditions_on_save(obj)

View File

@ -20,15 +20,14 @@ from django.utils.translation import ugettext_lazy as _
from taiga.base.utils.slug import slugify_uniquely
from taiga.base.utils.dicts import dict_sum
from taiga.projects.notifications.models import WatchedMixin
from taiga.projects.notifications import WatchedModelMixin
from taiga.projects.userstories.models import UserStory
import itertools
import datetime
class Milestone(WatchedMixin, models.Model):
class Milestone(WatchedModelMixin, models.Model):
name = models.CharField(max_length=200, db_index=True, null=False, blank=False,
verbose_name=_("name"))
# TODO: Change the unique restriction to a unique together with the project id
@ -124,12 +123,6 @@ class Milestone(WatchedMixin, models.Model):
def shared_increment_points(self):
return self._get_points_increment(True, True)
def _get_watchers_by_role(self):
return {
"owner": self.owner,
"project": self.project,
}
def closed_points_by_date(self, date):
return self._get_user_stories_points([
us for us in self.user_stories.filter(

View File

@ -1,109 +0,0 @@
# 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/>.
#######
# API #
#######
from taiga.projects.notifications import services
from taiga.projects.history.services import take_snapshot
from taiga.projects.history.models import HistoryType
class NotificationSenderMixin(object):
create_notification_template = None
update_notification_template = None
destroy_notification_template = None
notification_service = services.NotificationService()
def _get_object_for_snapshot(self, obj):
return obj
def _post_save_notification_sender(self, obj, history):
users = obj.get_watchers_to_notify(history.owner)
context = {
"object": obj,
"changer": history.owner,
"comment": history.comment,
"changed_fields": history.values_diff
}
if history.type == HistoryType.create:
self.notification_service.send_notification_email(self.create_notification_template,
users=users, context=context)
else:
self.notification_service.send_notification_email(self.update_notification_template,
users=users, context=context)
def post_save(self, obj, created=False):
super().post_save(obj, created)
user = self.request.user
comment = self.request.DATA.get("comment", "")
obj = self._get_object_for_snapshot(obj)
history = take_snapshot(obj, comment=comment, user=user)
if history:
self._post_save_notification_sender(obj, history)
def pre_destroy(self, obj):
obj = self._get_object_for_snapshot(obj)
users = obj.get_watchers_to_notify(self.request.user)
context = {
"object": obj,
"changer": self.request.user
}
self.notification_service.send_notification_email(self.destroy_notification_template,
users=users, context=context)
def post_destroy(self, obj):
pass
def destroy(self, request, *args, **kwargs):
obj = self.get_object()
self.pre_destroy(obj)
result = super().destroy(request, *args, **kwargs)
self.post_destroy(obj)
return result
################
# SERIEALIZERS #
################
from django.db.models.loading import get_model
from rest_framework import serializers
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

@ -187,6 +187,10 @@ class Project(ProjectDefaults, models.Model):
# Get all available roles on this project
roles = self.get_roles().filter(computable=True)
# Do nothing if project does not have roles
if len(roles) == 0:
return
# Get point instance that represent a null/undefined
null_points_value = self.points.get(value=None)

View File

@ -0,0 +1,20 @@
# 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 .mixins import WatchedResourceMixin
from .mixins import WatchedModelMixin
__all__ = ["WatchedModelMixin", "WatchedResourceMixin"]

View File

@ -0,0 +1,31 @@
# 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/>.
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.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"]

View File

@ -14,20 +14,169 @@
# 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 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)

View File

@ -1,9 +1,11 @@
from .models import Reference
from django.db.models.loading import get_model
def get_instance_by_ref(project_id, obj_ref):
model_cls = get_model("references", "Reference")
try:
instance = Reference.objects.get(project_id=project_id, ref=obj_ref)
except Reference.DoesNotExist:
instance = model_cls.objects.get(project_id=project_id, ref=obj_ref)
except model_cls.DoesNotExist:
instance = None
return instance

View File

@ -26,27 +26,26 @@ from taiga.base import exceptions as exc
from taiga.base.decorators import list_route
from taiga.base.permissions import has_project_perm
from taiga.base.api import ModelCrudViewSet
from taiga.projects.mixins.notifications import NotificationSenderMixin
from taiga.projects.models import Project
from taiga.projects.userstories.models import UserStory
from taiga.projects.notifications import WatchedResourceMixin
from taiga.projects.history import HistoryResourceMixin
from . import models
from . import permissions
from . import serializers
from . import services
class TaskViewSet(NotificationSenderMixin, ModelCrudViewSet):
class TaskViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudViewSet):
model = models.Task
serializer_class = serializers.TaskSerializer
permission_classes = (IsAuthenticated, permissions.TaskPermission)
filter_backends = (filters.IsProjectMemberFilterBackend,)
filter_fields = ["user_story", "milestone", "project"]
create_notification_template = "create_task_notification"
update_notification_template = "update_task_notification"
destroy_notification_template = "destroy_task_notification"
def pre_save(self, obj):
if obj.user_story:
obj.milestone = obj.user_story.milestone

View File

@ -24,13 +24,13 @@ from django.utils.translation import ugettext_lazy as _
from picklefield.fields import PickledObjectField
from taiga.base.utils.slug import ref_uniquely
from taiga.projects.notifications.models import WatchedMixin
from taiga.projects.notifications import WatchedModelMixin
from taiga.projects.userstories.models import UserStory
from taiga.projects.milestones.models import Milestone
from taiga.projects.mixins.blocked import BlockedMixin
class Task(WatchedMixin, BlockedMixin):
class Task(WatchedModelMixin, BlockedMixin, models.Model):
user_story = models.ForeignKey("userstories.UserStory", null=True, blank=True,
related_name="tasks", verbose_name=_("user story"))
ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None,
@ -56,8 +56,6 @@ class Task(WatchedMixin, BlockedMixin):
assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True,
default=None, related_name="tasks_assigned_to_me",
verbose_name=_("assigned to"))
watchers = models.ManyToManyField(settings.AUTH_USER_MODEL, null=True, blank=True,
related_name="watched_tasks", verbose_name=_("watchers"))
tags = PickledObjectField(null=False, blank=True, verbose_name=_("tags"))
attachments = generic.GenericRelation("attachments.Attachment")
is_iocaine = models.BooleanField(default=False, null=False, blank=True,
@ -85,15 +83,6 @@ class Task(WatchedMixin, BlockedMixin):
return ", ".join(value)
return value
def _get_watchers_by_role(self):
return {
"owner": self.owner,
"assigned_to": self.assigned_to,
"suscribed_watchers": self.watchers.all(),
"project": self.project,
}
def us_has_open_tasks(us, exclude_task):
qs = us.tasks.all()

View File

@ -29,7 +29,9 @@ from taiga.base.decorators import action
from taiga.base.permissions import has_project_perm
from taiga.base.api import ModelCrudViewSet
from taiga.projects.mixins.notifications import NotificationSenderMixin
from taiga.projects.notifications import WatchedResourceMixin
from taiga.projects.history import HistoryResourceMixin
from taiga.projects.models import Project
from taiga.projects.history.services import take_snapshot
@ -39,7 +41,7 @@ from . import serializers
from . import services
class UserStoryViewSet(NotificationSenderMixin, ModelCrudViewSet):
class UserStoryViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudViewSet):
model = models.UserStory
serializer_class = serializers.UserStoryNeighborsSerializer
list_serializer_class = serializers.UserStorySerializer
@ -49,16 +51,17 @@ class UserStoryViewSet(NotificationSenderMixin, ModelCrudViewSet):
retrieve_exclude_filters = (filters.TagsFilter,)
filter_fields = ['project', 'milestone', 'milestone__isnull', 'status']
create_notification_template = "create_userstory_notification"
update_notification_template = "update_userstory_notification"
destroy_notification_template = "destroy_userstory_notification"
# Specific filter used for filtering neighbor user stories
_neighbor_tags_filter = filters.TagsFilter('neighbor_tags')
def get_queryset(self):
return self.model.objects.prefetch_related("points", "role_points", "role_points__points", "role_points__role").select_related("milestone", "project")
# TODO: Refactor this
qs = self.model.objects.all()
qs = qs.prefetch_related("points",
"role_points",
"role_points__points",
"role_points__role")
qs = qs.select_related("milestone", "project")
return qs
@list_route(methods=["POST"])
def bulk_create(self, request, **kwargs):
@ -119,7 +122,11 @@ class UserStoryViewSet(NotificationSenderMixin, ModelCrudViewSet):
comment = _("Generate the user story [US #{ref} - "
"{subject}](:us:{ref} \"US #{ref} - {subject}\")")
comment = comment.format(ref=self.object.ref, subject=self.object.subject)
take_snapshot(self.object.generated_from_issue, comment=comment, user=self.request.user)
history = take_snapshot(self.object.generated_from_issue,
comment=comment,
user=self.request.user)
self.send_notifications(self.object.generated_from_issue, history)
return response

View File

@ -23,7 +23,7 @@ from django.utils.translation import ugettext_lazy as _
from picklefield.fields import PickledObjectField
from taiga.base.utils.slug import ref_uniquely
from taiga.projects.notifications.models import WatchedMixin
from taiga.projects.notifications import WatchedModelMixin
from taiga.projects.mixins.blocked import BlockedMixin
@ -51,8 +51,7 @@ class RolePoints(models.Model):
return "{}: {}".format(self.role.name, self.points.name)
class UserStory(WatchedMixin, BlockedMixin, models.Model):
class UserStory(WatchedModelMixin, BlockedMixin, models.Model):
ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None,
verbose_name=_("ref"))
milestone = models.ForeignKey("milestones.Milestone", null=True, blank=True,
@ -84,8 +83,6 @@ class UserStory(WatchedMixin, BlockedMixin, models.Model):
assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True,
default=None, related_name="userstories_assigned_to_me",
verbose_name=_("assigned to"))
watchers = models.ManyToManyField(settings.AUTH_USER_MODEL, null=True, blank=True,
related_name="watched_user_stories", verbose_name=_("watchers"))
client_requirement = models.BooleanField(default=False, null=False, blank=True,
verbose_name=_("is client requirement"))
team_requirement = models.BooleanField(default=False, null=False, blank=True,
@ -137,18 +134,8 @@ class UserStory(WatchedMixin, BlockedMixin, models.Model):
if isinstance(value, models.manager.Manager):
return ", ".join(["{}: {}".format(rp.role.name, rp.points.name)
for rp in self.role_points.all().order_by("role")])
return None
def _get_watchers_by_role(self):
return {
"owner": self.owner,
"assigned_to": self.assigned_to,
"suscribed_watchers": self.watchers.all(),
"project": self.project,
}
@receiver(models.signals.post_save, sender=UserStory,
dispatch_uid="user_story_create_role_points_handler")

View File

@ -25,26 +25,24 @@ from taiga.base import filters
from taiga.base import exceptions as exc
from taiga.base.api import ModelCrudViewSet
from taiga.base.decorators import list_route
from taiga.projects.mixins.notifications import NotificationSenderMixin
from taiga.projects.attachments.api import BaseAttachmentViewSet
from taiga.projects.models import Project
from taiga.mdrender.service import render as mdrender
from taiga.projects.notifications import WatchedResourceMixin
from taiga.projects.history import HistoryResourceMixin
from . import models
from . import permissions
from . import serializers
class WikiViewSet(NotificationSenderMixin, ModelCrudViewSet):
class WikiViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudViewSet):
model = models.WikiPage
serializer_class = serializers.WikiPageSerializer
permission_classes = (IsAuthenticated,)
filter_backends = (filters.IsProjectMemberFilterBackend,)
filter_fields = ["project", "slug"]
create_notification_template = "create_wiki_notification"
update_notification_template = "update_wiki_notification"
destroy_notification_template = "destroy_wiki_notification"
filter_fields = ("project", "slug")
@list_route(methods=["POST"])
def render(self, request, **kwargs):

View File

@ -18,10 +18,10 @@ from django.db import models
from django.contrib.contenttypes import generic
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from taiga.projects.notifications.models import WatchedMixin
from taiga.projects.notifications import WatchedModelMixin
class WikiPage(WatchedMixin, models.Model):
class WikiPage(WatchedModelMixin, models.Model):
project = models.ForeignKey("projects.Project", null=False, blank=False,
related_name="wiki_pages", verbose_name=_("project"))
slug = models.SlugField(max_length=500, db_index=True, null=False, blank=False,
@ -30,9 +30,6 @@ class WikiPage(WatchedMixin, models.Model):
verbose_name=_("content"))
owner = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True,
related_name="owned_wiki_pages", verbose_name=_("owner"))
watchers = models.ManyToManyField(settings.AUTH_USER_MODEL, null=True, blank=True,
related_name="watched_wiki_pages",
verbose_name=_("watchers"))
created_date = models.DateTimeField(auto_now_add=True, null=False, blank=False,
verbose_name=_("created date"))
modified_date = models.DateTimeField(auto_now=True, null=False, blank=False,
@ -51,14 +48,6 @@ class WikiPage(WatchedMixin, models.Model):
def __str__(self):
return "project {0} - {1}".format(self.project_id, self.slug)
def _get_watchers_by_role(self):
return {
"owner": self.owner,
"assigned_to": None,
"suscribed_watchers": self.watchers.all(),
"project": self.project,
}
class WikiLink(models.Model):
project = models.ForeignKey("projects.Project", null=False, blank=False,

View File

@ -15,10 +15,10 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django import forms
from django.contrib.auth.forms import (
UserCreationForm as DjangoUserCreationForm,
UserChangeForm as DjangoUserChangeForm
)
from django.contrib.auth.forms import UserCreationForm as DjangoUserCreationForm
from django.contrib.auth.forms import UserChangeForm as DjangoUserChangeForm
from .models import User
@ -39,9 +39,6 @@ class UserCreationForm(DjangoUserCreationForm):
class UserChangeForm(DjangoUserChangeForm):
notify_level = forms.ChoiceField(choices=User.NOTIFY_LEVEL_CHOICES)
notify_changes_by_me = forms.BooleanField(required=False)
class Meta:
model = User

View File

@ -0,0 +1,210 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Deleting field 'User.notify_changes_by_me'
db.delete_column('users_user', 'notify_changes_by_me')
# Deleting field 'User.notify_level'
db.delete_column('users_user', 'notify_level')
def backwards(self, orm):
# Adding field 'User.notify_changes_by_me'
db.add_column('users_user', 'notify_changes_by_me',
self.gf('django.db.models.fields.BooleanField')(default=False),
keep_default=False)
# Adding field 'User.notify_level'
db.add_column('users_user', 'notify_level',
self.gf('django.db.models.fields.CharField')(default='all_owned_projects', max_length=32),
keep_default=False)
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['auth.Permission']", 'blank': 'True'})
},
'auth.permission': {
'Meta': {'object_name': 'Permission', 'unique_together': "(('content_type', 'codename'),)", 'ordering': "('content_type__app_label', 'content_type__model', 'codename')"},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'contenttypes.contenttype': {
'Meta': {'db_table': "'django_content_type'", 'object_name': 'ContentType', 'unique_together': "(('app_label', 'model'),)", 'ordering': "('name',)"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'projects.issuestatus': {
'Meta': {'object_name': 'IssueStatus', 'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']"},
'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'issue_statuses'", 'to': "orm['projects.Project']"})
},
'projects.issuetype': {
'Meta': {'object_name': 'IssueType', 'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']"},
'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'issue_types'", 'to': "orm['projects.Project']"})
},
'projects.membership': {
'Meta': {'object_name': 'Membership', 'unique_together': "(('user', 'project'),)", 'ordering': "['project', 'role']"},
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'default': 'datetime.datetime.now', 'blank': 'True'}),
'email': ('django.db.models.fields.EmailField', [], {'default': 'None', 'max_length': '255', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'memberships'", 'to': "orm['projects.Project']"}),
'role': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'memberships'", 'to': "orm['users.Role']"}),
'token': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '60', 'null': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'memberships'", 'to': "orm['users.User']", 'null': 'True', 'blank': 'True'})
},
'projects.points': {
'Meta': {'object_name': 'Points', 'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']"},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'points'", 'to': "orm['projects.Project']"}),
'value': ('django.db.models.fields.FloatField', [], {'default': 'None', 'null': 'True', 'blank': 'True'})
},
'projects.priority': {
'Meta': {'object_name': 'Priority', 'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']"},
'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'priorities'", 'to': "orm['projects.Project']"})
},
'projects.project': {
'Meta': {'object_name': 'Project', 'ordering': "['name']"},
'created_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'creation_template': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'projects'", 'to': "orm['projects.ProjectTemplate']", 'null': 'True', 'blank': 'True'}),
'default_issue_status': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'+'", 'to': "orm['projects.IssueStatus']", 'blank': 'True', 'unique': 'True', 'on_delete': 'models.SET_NULL', 'null': 'True'}),
'default_issue_type': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'+'", 'to': "orm['projects.IssueType']", 'blank': 'True', 'unique': 'True', 'on_delete': 'models.SET_NULL', 'null': 'True'}),
'default_points': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'+'", 'to': "orm['projects.Points']", 'blank': 'True', 'unique': 'True', 'on_delete': 'models.SET_NULL', 'null': 'True'}),
'default_priority': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'+'", 'to': "orm['projects.Priority']", 'blank': 'True', 'unique': 'True', 'on_delete': 'models.SET_NULL', 'null': 'True'}),
'default_severity': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'+'", 'to': "orm['projects.Severity']", 'blank': 'True', 'unique': 'True', 'on_delete': 'models.SET_NULL', 'null': 'True'}),
'default_task_status': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'+'", 'to': "orm['projects.TaskStatus']", 'blank': 'True', 'unique': 'True', 'on_delete': 'models.SET_NULL', 'null': 'True'}),
'default_us_status': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'+'", 'to': "orm['projects.UserStoryStatus']", 'blank': 'True', 'unique': 'True', 'on_delete': 'models.SET_NULL', 'null': 'True'}),
'description': ('django.db.models.fields.TextField', [], {}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_backlog_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_issues_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_kanban_activated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_wiki_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'members': ('django.db.models.fields.related.ManyToManyField', [], {'through': "orm['projects.Membership']", 'related_name': "'projects'", 'to': "orm['users.User']", 'symmetrical': 'False'}),
'modified_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '250'}),
'owner': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'owned_projects'", 'to': "orm['users.User']"}),
'public': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '250', 'blank': 'True'}),
'tags': ('picklefield.fields.PickledObjectField', [], {'blank': 'True'}),
'total_milestones': ('django.db.models.fields.IntegerField', [], {'default': '0', 'null': 'True', 'blank': 'True'}),
'total_story_points': ('django.db.models.fields.FloatField', [], {'default': 'None', 'null': 'True'}),
'videoconferences': ('django.db.models.fields.CharField', [], {'max_length': '250', 'null': 'True', 'blank': 'True'}),
'videoconferences_salt': ('django.db.models.fields.CharField', [], {'max_length': '250', 'null': 'True', 'blank': 'True'})
},
'projects.projecttemplate': {
'Meta': {'object_name': 'ProjectTemplate', 'ordering': "['name']"},
'created_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'default_options': ('django_pgjson.fields.JsonField', [], {}),
'default_owner_role': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
'description': ('django.db.models.fields.TextField', [], {}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_backlog_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_issues_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_kanban_activated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_wiki_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'issue_statuses': ('django_pgjson.fields.JsonField', [], {}),
'issue_types': ('django_pgjson.fields.JsonField', [], {}),
'modified_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '250'}),
'points': ('django_pgjson.fields.JsonField', [], {}),
'priorities': ('django_pgjson.fields.JsonField', [], {}),
'roles': ('django_pgjson.fields.JsonField', [], {}),
'severities': ('django_pgjson.fields.JsonField', [], {}),
'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '250', 'blank': 'True'}),
'task_statuses': ('django_pgjson.fields.JsonField', [], {}),
'us_statuses': ('django_pgjson.fields.JsonField', [], {}),
'videoconferences': ('django.db.models.fields.CharField', [], {'max_length': '250', 'null': 'True', 'blank': 'True'}),
'videoconferences_salt': ('django.db.models.fields.CharField', [], {'max_length': '250', 'null': 'True', 'blank': 'True'})
},
'projects.severity': {
'Meta': {'object_name': 'Severity', 'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']"},
'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'severities'", 'to': "orm['projects.Project']"})
},
'projects.taskstatus': {
'Meta': {'object_name': 'TaskStatus', 'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']"},
'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'task_statuses'", 'to': "orm['projects.Project']"})
},
'projects.userstorystatus': {
'Meta': {'object_name': 'UserStoryStatus', 'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']"},
'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'us_statuses'", 'to': "orm['projects.Project']"}),
'wip_limit': ('django.db.models.fields.IntegerField', [], {'default': 'None', 'null': 'True', 'blank': 'True'})
},
'users.role': {
'Meta': {'object_name': 'Role', 'unique_together': "(('slug', 'project'),)", 'ordering': "['order', 'slug']"},
'computable': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'roles'", 'to': "orm['auth.Permission']", 'symmetrical': 'False'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'roles'", 'to': "orm['projects.Project']"}),
'slug': ('django.db.models.fields.SlugField', [], {'max_length': '250', 'blank': 'True'})
},
'users.user': {
'Meta': {'object_name': 'User', 'ordering': "['username']"},
'color': ('django.db.models.fields.CharField', [], {'default': "'#e6748a'", 'max_length': '9', 'blank': 'True'}),
'colorize_tags': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'default_language': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '20', 'blank': 'True'}),
'default_timezone': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '20', 'blank': 'True'}),
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'user_set'", 'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'photo': ('django.db.models.fields.files.FileField', [], {'max_length': '500', 'null': 'True', 'blank': 'True'}),
'token': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '200', 'null': 'True', 'blank': 'True'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'user_set'", 'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
}
}
complete_apps = ['users']

View File

@ -20,7 +20,6 @@ from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import UserManager, AbstractUser
from taiga.base.utils.slug import slugify_uniquely
from taiga.projects.notifications.models import WatcherMixin
import random
@ -29,7 +28,7 @@ def generate_random_hex_color():
return "#{:06x}".format(random.randint(0,0xFFFFFF))
class User(AbstractUser, WatcherMixin):
class User(AbstractUser):
color = models.CharField(max_length=9, null=False, blank=True, default=generate_random_hex_color,
verbose_name=_("color"))
description = models.TextField(null=False, blank=True,

View File

@ -26,17 +26,17 @@ from .models import User, Role
class PermissionSerializer(serializers.ModelSerializer):
class Meta:
model = Permission
fields = ['id', 'name', 'codename']
fields = ("id", "name", "codename")
class UserSerializer(serializers.ModelSerializer):
full_name = serializers.CharField(source='get_full_name', required=False)
full_name = serializers.CharField(source="get_full_name", required=False)
class Meta:
model = User
fields = ('id', 'username', 'first_name', 'last_name', 'full_name', 'email',
'color', 'description', 'default_language', 'default_timezone',
'is_active', 'photo', 'notify_level', 'notify_changes_by_me')
fields = ("id", "username", "first_name", "last_name", "full_name", "email",
"color", "description", "default_language", "default_timezone",
"is_active", "photo",)
class RecoverySerializer(serializers.Serializer):

View File

@ -133,6 +133,23 @@ class IssueFactory(Factory):
milestone = factory.SubFactory("tests.factories.MilestoneFactory")
class TaskFactory(Factory):
FACTORY_FOR = get_model("tasks", "Task")
subject = factory.Sequence(lambda n: "Task {}".format(n))
owner = factory.SubFactory("tests.factories.UserFactory")
project = factory.SubFactory("tests.factories.ProjectFactory")
status = factory.SubFactory("tests.factories.TaskStatusFactory")
milestone = factory.SubFactory("tests.factories.MilestoneFactory")
class WikiPageFactory(Factory):
FACTORY_FOR = get_model("wiki", "WikiPage")
project = factory.SubFactory("tests.factories.ProjectFactory")
owner = factory.SubFactory("tests.factories.UserFactory")
class IssueStatusFactory(Factory):
FACTORY_FOR = get_model("projects", "IssueStatus")
@ -140,6 +157,13 @@ class IssueStatusFactory(Factory):
project = factory.SubFactory("tests.factories.ProjectFactory")
class TaskStatusFactory(Factory):
FACTORY_FOR = get_model("projects", "TaskStatus")
name = factory.Sequence(lambda n: "Issue Status {}".format(n))
project = factory.SubFactory("tests.factories.ProjectFactory")
class SeverityFactory(Factory):
FACTORY_FOR = get_model("projects", "Severity")

View File

@ -0,0 +1,159 @@
import json
import pytest
from unittest.mock import MagicMock
from unittest.mock import patch
from django.core.urlresolvers import reverse
from django.db.models.loading import get_model
from .. import factories as f
from taiga.projects.history import services
from taiga.projects.history.models import HistoryEntry
from taiga.projects.history.choices import HistoryType
pytestmark = pytest.mark.django_db
def test_take_snapshot_crete():
issue = f.IssueFactory.create()
qs_all = HistoryEntry.objects.all()
qs_created = qs_all.filter(type=HistoryType.create)
assert qs_all.count() == 0
services.take_snapshot(issue, user=issue.owner)
assert qs_all.count() == 1
assert qs_created.count() == 1
def test_take_two_snapshots_with_changes():
issue = f.IssueFactory.create()
qs_all = HistoryEntry.objects.all()
qs_created = qs_all.filter(type=HistoryType.create)
assert qs_all.count() == 0
# Two snapshots with modification should
# generate two snapshots.
services.take_snapshot(issue, user=issue.owner)
issue.description = "foo1"
issue.save()
services.take_snapshot(issue, user=issue.owner)
assert qs_all.count() == 2
assert qs_created.count() == 1
def test_take_two_snapshots_without_changes():
issue = f.IssueFactory.create()
qs_all = HistoryEntry.objects.all()
qs_created = qs_all.filter(type=HistoryType.create)
assert qs_all.count() == 0
# Two snapshots without modifications only
# generate one unique snapshot.
services.take_snapshot(issue, user=issue.owner)
services.take_snapshot(issue, user=issue.owner)
assert qs_all.count() == 1
assert qs_created.count() == 1
def test_take_snapshot_from_deleted_object():
issue = f.IssueFactory.create()
qs_all = HistoryEntry.objects.all()
qs_deleted = qs_all.filter(type=HistoryType.delete)
assert qs_all.count() == 0
services.take_snapshot(issue, user=issue.owner, delete=True)
assert qs_all.count() == 1
assert qs_deleted.count() == 1
def test_real_snapshot_frequency(settings):
settings.MAX_PARTIAL_DIFFS = 2
issue = f.IssueFactory.create()
counter = 0
qs_all = HistoryEntry.objects.all()
qs_snapshots = qs_all.filter(is_snapshot=True)
qs_partials = qs_all.filter(is_snapshot=False)
assert qs_all.count() == 0
assert qs_snapshots.count() == 0
assert qs_partials.count() == 0
def _make_change():
nonlocal counter
issue.description = "desc{}".format(counter)
issue.save()
services.take_snapshot(issue, user=issue.owner)
counter += 1
_make_change()
assert qs_all.count() == 1
assert qs_snapshots.count() == 1
assert qs_partials.count() == 0
_make_change()
assert qs_all.count() == 2
assert qs_snapshots.count() == 1
assert qs_partials.count() == 1
_make_change()
assert qs_all.count() == 3
assert qs_snapshots.count() == 1
assert qs_partials.count() == 2
_make_change()
assert qs_all.count() == 4
assert qs_snapshots.count() == 2
assert qs_partials.count() == 2
def test_issue_resource_history_test(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)
role = f.RoleFactory.create(project=project)
member = f.MembershipFactory.create(project=project, user=user, role=role)
issue = f.IssueFactory.create(owner=user, project=project)
mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save"
url = reverse("issues-detail", args=[issue.pk])
client.login(user)
qs_all = HistoryEntry.objects.all()
qs_deleted = qs_all.filter(type=HistoryType.delete)
qs_changed = qs_all.filter(type=HistoryType.change)
qs_created = qs_all.filter(type=HistoryType.create)
assert qs_all.count() == 0
with patch(mock_path) as m:
data = {"subject": "Fooooo"}
response = client.patch(url, json.dumps(data), content_type="application/json")
assert response.status_code == 200
assert qs_all.count() == 1
assert qs_created.count() == 1
assert qs_changed.count() == 0
assert qs_deleted.count() == 0
with patch(mock_path) as m:
response = client.delete(url)
assert response.status_code == 204
assert qs_all.count() == 2
assert qs_created.count() == 1
assert qs_changed.count() == 0
assert qs_deleted.count() == 1

View File

@ -0,0 +1,229 @@
import json
import pytest
from unittest.mock import MagicMock, patch
from django.core.urlresolvers import reverse
from django.db.models.loading import get_model
from .. import factories as f
from taiga.projects.notifications import services
from taiga.projects.notifications.choices import NotifyLevel
from taiga.projects.history.choices import HistoryType
pytestmark = pytest.mark.django_db
@pytest.fixture
def mail():
from django.core import mail
mail.outbox = []
return mail
def test_attach_notify_policy_to_project_queryset():
project1 = f.ProjectFactory.create()
project2 = f.ProjectFactory.create()
qs = project1.__class__.objects.order_by("id")
qs = services.attach_notify_policy_to_project_queryset(project1.owner, qs)
assert len(qs) == 2
assert qs[0].notify_level == NotifyLevel.notwatch
assert qs[1].notify_level == NotifyLevel.notwatch
services.create_notify_policy(project1, project1.owner, NotifyLevel.watch)
qs = project1.__class__.objects.order_by("id")
qs = services.attach_notify_policy_to_project_queryset(project1.owner, qs)
assert qs[0].notify_level == NotifyLevel.watch
assert qs[1].notify_level == NotifyLevel.notwatch
def test_create_retrieve_notify_policy():
project = f.ProjectFactory.create()
policy_model_cls = get_model("notifications", "NotifyPolicy")
current_number = policy_model_cls.objects.all().count()
assert current_number == 0
policy = services.get_notify_policy(project, project.owner)
current_number = policy_model_cls.objects.all().count()
assert current_number == 1
assert policy.notify_level == NotifyLevel.notwatch
def test_notify_policy_existence():
project = f.ProjectFactory.create()
assert not services.notify_policy_exists(project, project.owner)
services.create_notify_policy(project, project.owner, NotifyLevel.watch)
assert services.notify_policy_exists(project, project.owner)
def test_analize_object_for_watchers():
user1 = f.UserFactory.create()
user2 = f.UserFactory.create()
issue = MagicMock()
issue.description = "Foo @{0} @{1} ".format(user1.username,
user2.username)
issue.content = ""
history = MagicMock()
history.comment = ""
services.analize_object_for_watchers(issue, history)
assert issue.watchers.add.call_count == 2
def test_users_to_notify():
project = f.ProjectFactory.create()
issue = f.IssueFactory.create(project=project)
member1 = f.MembershipFactory.create(project=project)
member2 = f.MembershipFactory.create(project=project)
member3 = f.MembershipFactory.create(project=project)
policy1 = services.create_notify_policy(project, member1.user)
policy2 = services.create_notify_policy(project, member2.user)
policy3 = services.create_notify_policy(project, member3.user)
history = MagicMock()
history.owner = member2.user
history.comment = ""
# Test basic description modifications
issue.description = "test1"
issue.save()
users = services.get_users_to_notify(issue, history=history)
assert len(users) == 1
assert tuple(users)[0] == issue.get_owner()
# Test watch notify level in one member
policy1.notify_level = NotifyLevel.watch
policy1.save()
users = services.get_users_to_notify(issue, history=history)
assert len(users) == 2
assert users == {member1.user, issue.get_owner()}
# Test with watchers
issue.watchers.add(member3.user)
users = services.get_users_to_notify(issue, history=history)
assert len(users) == 3
assert users == {member1.user, member3.user, issue.get_owner()}
# Test with watchers with ignore policy
policy3.notify_level = NotifyLevel.ignore
policy3.save()
issue.watchers.add(member3.user)
users = services.get_users_to_notify(issue, history=history)
assert len(users) == 2
assert users == {member1.user, issue.get_owner()}
def test_send_notifications_using_services_method(mail):
project = f.ProjectFactory.create()
member1 = f.MembershipFactory.create(project=project)
member2 = f.MembershipFactory.create(project=project)
history_change = MagicMock()
history_change.owner = member1.user
history_change.comment = ""
history_change.type = HistoryType.change
history_create = MagicMock()
history_create.owner = member1.user
history_create.comment = ""
history_create.type = HistoryType.create
history_delete = MagicMock()
history_delete.owner = member1.user
history_delete.comment = ""
history_delete.type = HistoryType.delete
# Issues
issue = f.IssueFactory.create(project=project)
services.send_notifications(issue,
history=history_create,
users={member1.user, member2.user})
services.send_notifications(issue,
history=history_change,
users={member1.user, member2.user})
services.send_notifications(issue,
history=history_delete,
users={member1.user, member2.user})
# Userstories
us = f.UserStoryFactory.create()
services.send_notifications(us,
history=history_create,
users={member1.user, member2.user})
services.send_notifications(us,
history=history_change,
users={member1.user, member2.user})
services.send_notifications(us,
history=history_delete,
users={member1.user, member2.user})
# Tasks
task = f.TaskFactory.create()
services.send_notifications(task,
history=history_create,
users={member1.user, member2.user})
services.send_notifications(task,
history=history_change,
users={member1.user, member2.user})
services.send_notifications(task,
history=history_delete,
users={member1.user, member2.user})
# Wiki pages
wiki = f.WikiPageFactory.create()
services.send_notifications(wiki,
history=history_create,
users={member1.user, member2.user})
services.send_notifications(wiki,
history=history_change,
users={member1.user, member2.user})
services.send_notifications(wiki,
history=history_delete,
users={member1.user, member2.user})
assert len(mail.outbox) == 24
def test_resource_notification_test(client, mail):
user1 = f.UserFactory.create()
user2 = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user1)
role = f.RoleFactory.create(project=project)
member1 = f.MembershipFactory.create(project=project, user=user1, role=role)
member2 = f.MembershipFactory.create(project=project, user=user2, role=role)
issue = f.IssueFactory.create(owner=user2, project=project)
mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save"
url = reverse("issues-detail", args=[issue.pk])
client.login(user1)
with patch(mock_path) as m:
data = {"subject": "Fooooo"}
response = client.patch(url, json.dumps(data), content_type="application/json")
assert len(mail.outbox) == 1
assert response.status_code == 200
with patch(mock_path) as m:
response = client.delete(url)
assert response.status_code == 204
assert len(mail.outbox) == 2