diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb640e86..05daf163 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@
- Now profile timelines only show content about the objects (US/Tasks/Issues/Wiki pages) you are involved.
- US, tasks and Issues can be upvoted or downvoted and the voters list can be obtained.
- Project can be starred or unstarred and the fans list can be obtained.
+- Now users can watch public issues, tasks and user stories.
+- Add endpoints to show the watchers list for issues, tasks and user stories.
- i18n.
- Add polish (pl) translation.
- Add portuguese (Brazil) (pt_BR) translation.
diff --git a/taiga/base/fields.py b/taiga/base/fields.py
index a1a8c56c..74b27c10 100644
--- a/taiga/base/fields.py
+++ b/taiga/base/fields.py
@@ -110,3 +110,12 @@ class TagsColorsField(serializers.WritableField):
def from_native(self, data):
return list(data.items())
+
+
+
+class WatchersField(serializers.WritableField):
+ def to_native(self, obj):
+ return obj
+
+ def from_native(self, data):
+ return data
diff --git a/taiga/base/filters.py b/taiga/base/filters.py
index cbfdbdef..e0de384c 100644
--- a/taiga/base/filters.py
+++ b/taiga/base/filters.py
@@ -18,6 +18,7 @@ from functools import reduce
import logging
from django.apps import apps
+from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import ugettext as _
@@ -451,6 +452,33 @@ class TagsFilter(FilterBackend):
return super().filter_queryset(request, queryset, view)
+
+class WatchersFilter(FilterBackend):
+ filter_name = 'watchers'
+
+ def __init__(self, filter_name=None):
+ if filter_name:
+ self.filter_name = filter_name
+
+ def _get_watchers_queryparams(self, params):
+ watchers = params.get(self.filter_name, None)
+ if watchers:
+ return watchers.split(",")
+
+ return None
+
+ def filter_queryset(self, request, queryset, view):
+ query_watchers = self._get_watchers_queryparams(request.QUERY_PARAMS)
+ model = queryset.model
+ if query_watchers:
+ WatchedModel = apps.get_model("notifications", "Watched")
+ watched_type = ContentType.objects.get_for_model(queryset.model)
+ watched_ids = WatchedModel.objects.filter(content_type=watched_type, user__id__in=query_watchers).values_list("object_id", flat=True)
+ queryset = queryset.filter(id__in=watched_ids)
+
+ return super().filter_queryset(request, queryset, view)
+
+
#####################################################################
# Text search filters
#####################################################################
diff --git a/taiga/export_import/serializers.py b/taiga/export_import/serializers.py
index ec0ed783..98e9d2cf 100644
--- a/taiga/export_import/serializers.py
+++ b/taiga/export_import/serializers.py
@@ -19,6 +19,7 @@ import copy
import os
from collections import OrderedDict
+from django.apps import apps
from django.core.files.base import ContentFile
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError
@@ -43,6 +44,7 @@ from taiga.projects.attachments import models as attachments_models
from taiga.timeline import models as timeline_models
from taiga.timeline import service as timeline_service
from taiga.users import models as users_models
+from taiga.projects.notifications import services as notifications_services
from taiga.projects.votes import services as votes_service
from taiga.projects.history import services as history_service
@@ -223,6 +225,48 @@ class HistoryDiffField(JsonField):
return data
+class WatcheableObjectModelSerializer(serializers.ModelSerializer):
+ watchers = UserRelatedField(many=True, required=False)
+
+ def __init__(self, *args, **kwargs):
+ self._watchers_field = self.base_fields.pop("watchers", None)
+ super(WatcheableObjectModelSerializer, self).__init__(*args, **kwargs)
+
+ """
+ watchers is not a field from the model so we need to do some magic to make it work like a normal field
+ It's supposed to be represented as an email list but internally it's treated like notifications.Watched instances
+ """
+
+ def restore_object(self, attrs, instance=None):
+ watcher_field = self.fields.pop("watchers", None)
+ instance = super(WatcheableObjectModelSerializer, self).restore_object(attrs, instance)
+ self._watchers = self.init_data.get("watchers", [])
+ return instance
+
+ def save_watchers(self):
+ new_watcher_emails = set(self._watchers)
+ old_watcher_emails = set(notifications_services.get_watchers(self.object).values_list("email", flat=True))
+ adding_watcher_emails = list(new_watcher_emails.difference(old_watcher_emails))
+ removing_watcher_emails = list(old_watcher_emails.difference(new_watcher_emails))
+
+ User = apps.get_model("users", "User")
+ adding_users = User.objects.filter(email__in=adding_watcher_emails)
+ removing_users = User.objects.filter(email__in=removing_watcher_emails)
+
+ for user in adding_users:
+ notifications_services.add_watcher(self.object, user)
+
+ for user in removing_users:
+ notifications_services.remove_watcher(self.object, user)
+
+ self.object.watchers = notifications_services.get_watchers(self.object)
+
+ def to_native(self, obj):
+ ret = super(WatcheableObjectModelSerializer, self).to_native(obj)
+ ret["watchers"] = [user.email for user in notifications_services.get_watchers(obj)]
+ return ret
+
+
class HistoryExportSerializer(serializers.ModelSerializer):
user = HistoryUserField()
diff = HistoryDiffField(required=False)
@@ -243,7 +287,7 @@ class HistoryExportSerializerMixin(serializers.ModelSerializer):
def get_history(self, obj):
history_qs = history_service.get_history_queryset_by_model_instance(obj,
types=(history_models.HistoryType.change, history_models.HistoryType.create,))
-
+
return HistoryExportSerializer(history_qs, many=True).data
@@ -447,9 +491,8 @@ class RolePointsExportSerializer(serializers.ModelSerializer):
exclude = ('id', 'user_story')
-class MilestoneExportSerializer(serializers.ModelSerializer):
+class MilestoneExportSerializer(WatcheableObjectModelSerializer):
owner = UserRelatedField(required=False)
- watchers = UserRelatedField(many=True, required=False)
modified_date = serializers.DateTimeField(required=False)
def __init__(self, *args, **kwargs):
@@ -475,13 +518,12 @@ class MilestoneExportSerializer(serializers.ModelSerializer):
class TaskExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryExportSerializerMixin,
- AttachmentExportSerializerMixin, serializers.ModelSerializer):
+ AttachmentExportSerializerMixin, WatcheableObjectModelSerializer):
owner = UserRelatedField(required=False)
status = ProjectRelatedField(slug_field="name")
user_story = ProjectRelatedField(slug_field="ref", required=False)
milestone = ProjectRelatedField(slug_field="name", required=False)
assigned_to = UserRelatedField(required=False)
- watchers = UserRelatedField(many=True, required=False)
modified_date = serializers.DateTimeField(required=False)
class Meta:
@@ -493,13 +535,12 @@ class TaskExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryE
class UserStoryExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryExportSerializerMixin,
- AttachmentExportSerializerMixin, serializers.ModelSerializer):
+ AttachmentExportSerializerMixin, WatcheableObjectModelSerializer):
role_points = RolePointsExportSerializer(many=True, required=False)
owner = UserRelatedField(required=False)
assigned_to = UserRelatedField(required=False)
status = ProjectRelatedField(slug_field="name")
milestone = ProjectRelatedField(slug_field="name", required=False)
- watchers = UserRelatedField(many=True, required=False)
modified_date = serializers.DateTimeField(required=False)
generated_from_issue = ProjectRelatedField(slug_field="ref", required=False)
@@ -512,7 +553,7 @@ class UserStoryExportSerializer(CustomAttributesValuesExportSerializerMixin, His
class IssueExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryExportSerializerMixin,
- AttachmentExportSerializerMixin, serializers.ModelSerializer):
+ AttachmentExportSerializerMixin, WatcheableObjectModelSerializer):
owner = UserRelatedField(required=False)
status = ProjectRelatedField(slug_field="name")
assigned_to = UserRelatedField(required=False)
@@ -520,7 +561,6 @@ class IssueExportSerializer(CustomAttributesValuesExportSerializerMixin, History
severity = ProjectRelatedField(slug_field="name")
type = ProjectRelatedField(slug_field="name")
milestone = ProjectRelatedField(slug_field="name", required=False)
- watchers = UserRelatedField(many=True, required=False)
votes = serializers.SerializerMethodField("get_votes")
modified_date = serializers.DateTimeField(required=False)
@@ -536,10 +576,9 @@ class IssueExportSerializer(CustomAttributesValuesExportSerializerMixin, History
class WikiPageExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerializerMixin,
- serializers.ModelSerializer):
+ WatcheableObjectModelSerializer):
owner = UserRelatedField(required=False)
last_modifier = UserRelatedField(required=False)
- watchers = UserRelatedField(many=True, required=False)
modified_date = serializers.DateTimeField(required=False)
class Meta:
@@ -586,7 +625,7 @@ class TimelineExportSerializer(serializers.ModelSerializer):
exclude = ('id', 'project', 'namespace', 'object_id')
-class ProjectExportSerializer(serializers.ModelSerializer):
+class ProjectExportSerializer(WatcheableObjectModelSerializer):
owner = UserRelatedField(required=False)
default_points = serializers.SlugRelatedField(slug_field="name", required=False)
default_us_status = serializers.SlugRelatedField(slug_field="name", required=False)
diff --git a/taiga/export_import/service.py b/taiga/export_import/service.py
index 40578118..25b9be90 100644
--- a/taiga/export_import/service.py
+++ b/taiga/export_import/service.py
@@ -71,6 +71,7 @@ def store_project(data):
if serialized.is_valid():
serialized.object._importing = True
serialized.object.save()
+ serialized.save_watchers()
return serialized
add_errors("project", serialized.errors)
return None
@@ -217,6 +218,7 @@ def store_task(project, data):
serialized.object._not_notify = True
serialized.save()
+ serialized.save_watchers()
if serialized.object.ref:
sequence_name = refs.make_sequence_name(project)
@@ -257,6 +259,7 @@ def store_milestone(project, milestone):
serialized.object.project = project
serialized.object._importing = True
serialized.save()
+ serialized.save_watchers()
for task_without_us in milestone.get("tasks_without_us", []):
task_without_us["user_story"] = None
@@ -320,6 +323,7 @@ def store_wiki_page(project, wiki_page):
serialized.object._importing = True
serialized.object._not_notify = True
serialized.save()
+ serialized.save_watchers()
for attachment in wiki_page.get("attachments", []):
store_attachment(project, serialized.object, attachment)
@@ -382,6 +386,7 @@ def store_user_story(project, data):
serialized.object._not_notify = True
serialized.save()
+ serialized.save_watchers()
if serialized.object.ref:
sequence_name = refs.make_sequence_name(project)
@@ -442,6 +447,7 @@ def store_issue(project, data):
serialized.object._not_notify = True
serialized.save()
+ serialized.save_watchers()
if serialized.object.ref:
sequence_name = refs.make_sequence_name(project)
diff --git a/taiga/permissions/service.py b/taiga/permissions/service.py
index d9df5bd7..3a79b6ee 100644
--- a/taiga/permissions/service.py
+++ b/taiga/permissions/service.py
@@ -15,11 +15,12 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from taiga.projects.models import Membership, Project
from .permissions import OWNERS_PERMISSIONS, MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS
+from django.apps import apps
def _get_user_project_membership(user, project):
+ Membership = apps.get_model("projects", "Membership")
if user.is_anonymous():
return None
@@ -30,7 +31,7 @@ def _get_user_project_membership(user, project):
def _get_object_project(obj):
project = None
-
+ Project = apps.get_model("projects", "Project")
if isinstance(obj, Project):
project = obj
elif obj and hasattr(obj, 'project'):
diff --git a/taiga/projects/api.py b/taiga/projects/api.py
index 9ff9be8c..6223adb0 100644
--- a/taiga/projects/api.py
+++ b/taiga/projects/api.py
@@ -31,6 +31,7 @@ from taiga.base.api.utils import get_object_or_404
from taiga.base.utils.slug import slugify_uniquely
from taiga.projects.history.mixins import HistoryResourceMixin
+from taiga.projects.notifications.mixins import WatchedResourceMixin
from taiga.projects.mixins.ordering import BulkUpdateOrderMixin
from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin
@@ -50,7 +51,7 @@ from .votes.mixins.viewsets import StarredResourceMixin, VotersViewSetMixin
## Project
######################################################
-class ProjectViewSet(StarredResourceMixin, HistoryResourceMixin, ModelCrudViewSet):
+class ProjectViewSet(StarredResourceMixin, HistoryResourceMixin, WatchedResourceMixin, ModelCrudViewSet):
queryset = models.Project.objects.all()
serializer_class = serializers.ProjectDetailSerializer
admin_serializer_class = serializers.ProjectDetailAdminSerializer
@@ -62,7 +63,8 @@ class ProjectViewSet(StarredResourceMixin, HistoryResourceMixin, ModelCrudViewSe
def get_queryset(self):
qs = super().get_queryset()
- return self.attach_votes_attrs_to_queryset(qs)
+ qs = self.attach_votes_attrs_to_queryset(qs)
+ return self.attach_watchers_attrs_to_queryset(qs)
@list_route(methods=["POST"])
def bulk_update_order(self, request, **kwargs):
diff --git a/taiga/projects/apps.py b/taiga/projects/apps.py
index acdaa7da..06b42de8 100644
--- a/taiga/projects/apps.py
+++ b/taiga/projects/apps.py
@@ -27,12 +27,7 @@ def connect_memberships_signals():
sender=apps.get_model("projects", "Membership"),
dispatch_uid='membership_pre_delete')
- # On membership object is deleted, update watchers of all objects relation.
- signals.post_delete.connect(handlers.update_watchers_on_membership_post_delete,
- sender=apps.get_model("projects", "Membership"),
- dispatch_uid='update_watchers_on_membership_post_delete')
-
- # On membership object is deleted, update watchers of all objects relation.
+ # On membership object is deleted, update notify policies of all objects relation.
signals.post_save.connect(handlers.create_notify_policy,
sender=apps.get_model("projects", "Membership"),
dispatch_uid='create-notify-policy')
@@ -67,7 +62,6 @@ def connect_task_status_signals():
def disconnect_memberships_signals():
signals.pre_delete.disconnect(sender=apps.get_model("projects", "Membership"), dispatch_uid='membership_pre_delete')
- signals.post_delete.disconnect(sender=apps.get_model("projects", "Membership"), dispatch_uid='update_watchers_on_membership_post_delete')
signals.post_save.disconnect(sender=apps.get_model("projects", "Membership"), dispatch_uid='create-notify-policy')
diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py
index a591c666..d5ecc800 100644
--- a/taiga/projects/history/freeze_impl.py
+++ b/taiga/projects/history/freeze_impl.py
@@ -288,7 +288,7 @@ def userstory_freezer(us) -> dict:
"milestone": us.milestone_id,
"client_requirement": us.client_requirement,
"team_requirement": us.team_requirement,
- "watchers": [x.id for x in us.watchers.all()],
+ "watchers": [x.id for x in us.get_watchers()],
"attachments": extract_attachments(us),
"tags": us.tags,
"points": points,
@@ -315,7 +315,7 @@ def issue_freezer(issue) -> dict:
"description": issue.description,
"description_html": mdrender(issue.project, issue.description),
"assigned_to": issue.assigned_to_id,
- "watchers": [x.pk for x in issue.watchers.all()],
+ "watchers": [x.pk for x in issue.get_watchers()],
"attachments": extract_attachments(issue),
"tags": issue.tags,
"is_blocked": issue.is_blocked,
@@ -337,7 +337,7 @@ def task_freezer(task) -> dict:
"description": task.description,
"description_html": mdrender(task.project, task.description),
"assigned_to": task.assigned_to_id,
- "watchers": [x.pk for x in task.watchers.all()],
+ "watchers": [x.pk for x in task.get_watchers()],
"attachments": extract_attachments(task),
"taskboard_order": task.taskboard_order,
"us_order": task.us_order,
@@ -359,7 +359,7 @@ def wikipage_freezer(wiki) -> dict:
"owner": wiki.owner_id,
"content": wiki.content,
"content_html": mdrender(wiki.project, wiki.content),
- "watchers": [x.pk for x in wiki.watchers.all()],
+ "watchers": [x.pk for x in wiki.get_watchers()],
"attachments": extract_attachments(wiki),
}
diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py
index 6fc78ce3..1007bdf1 100644
--- a/taiga/projects/issues/api.py
+++ b/taiga/projects/issues/api.py
@@ -53,6 +53,7 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W
filters.SeveritiesFilter,
filters.PrioritiesFilter,
filters.TagsFilter,
+ filters.WatchersFilter,
filters.QFilter,
filters.OrderByFilterMixin)
retrieve_exclude_filters = (filters.OwnersFilter,
@@ -61,11 +62,11 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W
filters.IssueTypesFilter,
filters.SeveritiesFilter,
filters.PrioritiesFilter,
- filters.TagsFilter,)
+ filters.TagsFilter,
+ filters.WatchersFilter,)
filter_fields = ("project",
- "status__is_closed",
- "watchers")
+ "status__is_closed")
order_by_fields = ("type",
"status",
"severity",
@@ -142,7 +143,8 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W
def get_queryset(self):
qs = super().get_queryset()
qs = qs.prefetch_related("attachments")
- return self.attach_votes_attrs_to_queryset(qs)
+ qs = self.attach_votes_attrs_to_queryset(qs)
+ return self.attach_watchers_attrs_to_queryset(qs)
def pre_save(self, obj):
if not obj.id:
diff --git a/taiga/projects/issues/migrations/0006_remove_issue_watchers.py b/taiga/projects/issues/migrations/0006_remove_issue_watchers.py
new file mode 100644
index 00000000..c41e387e
--- /dev/null
+++ b/taiga/projects/issues/migrations/0006_remove_issue_watchers.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.contenttypes.management import update_all_contenttypes
+
+def create_notifications(apps, schema_editor):
+ update_all_contenttypes()
+ migrations.RunSQL(sql="""
+INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id)
+SELECT issue_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id
+FROM issues_issue_watchers""".format(content_type_id=ContentType.objects.get(model='issue').id))
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('notifications', '0004_watched'),
+ ('issues', '0005_auto_20150623_1923'),
+ ]
+
+ operations = [
+ migrations.RunPython(create_notifications),
+ migrations.RemoveField(
+ model_name='issue',
+ name='watchers',
+ ),
+ ]
diff --git a/taiga/projects/issues/permissions.py b/taiga/projects/issues/permissions.py
index 423866bc..82120e14 100644
--- a/taiga/projects/issues/permissions.py
+++ b/taiga/projects/issues/permissions.py
@@ -35,6 +35,8 @@ class IssuePermission(TaigaResourcePermission):
delete_comment_perms= HasProjectPerm('modify_issue')
upvote_perms = IsAuthenticated() & HasProjectPerm('view_issues')
downvote_perms = IsAuthenticated() & HasProjectPerm('view_issues')
+ watch_perms = IsAuthenticated() & HasProjectPerm('view_issues')
+ unwatch_perms = IsAuthenticated() & HasProjectPerm('view_issues')
class HasIssueIdUrlParam(PermissionComponent):
diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py
index 76023cf0..64c83f17 100644
--- a/taiga/projects/issues/serializers.py
+++ b/taiga/projects/issues/serializers.py
@@ -23,6 +23,7 @@ from taiga.mdrender.service import render as mdrender
from taiga.projects.validators import ProjectExistsValidator
from taiga.projects.notifications.validators import WatchersValidator
from taiga.projects.serializers import BasicIssueStatusSerializer
+from taiga.projects.notifications.mixins import WatchedResourceModelSerializer
from taiga.projects.votes.mixins.serializers import VotedResourceSerializerMixin
from taiga.users.serializers import UserBasicInfoSerializer
@@ -30,7 +31,7 @@ from taiga.users.serializers import UserBasicInfoSerializer
from . import models
-class IssueSerializer(WatchersValidator, VotedResourceSerializerMixin, serializers.ModelSerializer):
+class IssueSerializer(WatchersValidator, VotedResourceSerializerMixin, WatchedResourceModelSerializer, serializers.ModelSerializer):
tags = TagsField(required=False)
external_reference = PgArrayField(required=False)
is_closed = serializers.Field(source="is_closed")
diff --git a/taiga/projects/migrations/0024_auto_20150810_1247.py b/taiga/projects/migrations/0024_auto_20150810_1247.py
new file mode 100644
index 00000000..ebe758fc
--- /dev/null
+++ b/taiga/projects/migrations/0024_auto_20150810_1247.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import djorm_pgarray.fields
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('projects', '0023_auto_20150721_1511'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='project',
+ name='watchers',
+ field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, related_name='projects_project+', null=True, verbose_name='watchers'),
+ preserve_default=True,
+ ),
+ migrations.AlterField(
+ model_name='project',
+ name='public_permissions',
+ field=djorm_pgarray.fields.TextArrayField(default=[], dbtype='text', choices=[('view_project', 'View project'), ('star_project', 'Star project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('delete_us', 'Delete user story'), ('vote_us', 'Vote user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('delete_task', 'Delete task'), ('vote_task', 'Vote task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('delete_issue', 'Delete issue'), ('vote_issue', 'Vote issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')], verbose_name='user permissions'),
+ preserve_default=True,
+ ),
+ ]
diff --git a/taiga/projects/migrations/0025_remove_project_watchers.py b/taiga/projects/migrations/0025_remove_project_watchers.py
new file mode 100644
index 00000000..5748edc9
--- /dev/null
+++ b/taiga/projects/migrations/0025_remove_project_watchers.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.contenttypes.management import update_all_contenttypes
+
+def create_notifications(apps, schema_editor):
+ update_all_contenttypes()
+ migrations.RunSQL(sql="""
+INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id)
+SELECT project_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id
+FROM projects_project_watchers""".format(content_type_id=ContentType.objects.get(model='project').id))
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('notifications', '0004_watched'),
+ ('projects', '0024_auto_20150810_1247'),
+ ]
+
+ operations = [
+ migrations.RunPython(create_notifications),
+ migrations.RemoveField(
+ model_name='project',
+ name='watchers',
+ ),
+ ]
diff --git a/taiga/projects/milestones/api.py b/taiga/projects/milestones/api.py
index 132f9bf2..93a09831 100644
--- a/taiga/projects/milestones/api.py
+++ b/taiga/projects/milestones/api.py
@@ -44,9 +44,7 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudView
"user_stories__role_points__points",
"user_stories__role_points__role",
"user_stories__generated_from_issue",
- "user_stories__project",
- "watchers",
- "user_stories__watchers")
+ "user_stories__project")
qs = qs.select_related("project")
qs = qs.order_by("-estimated_start")
return qs
diff --git a/taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py b/taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py
new file mode 100644
index 00000000..897f47bf
--- /dev/null
+++ b/taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.contenttypes.management import update_all_contenttypes
+
+def create_notifications(apps, schema_editor):
+ update_all_contenttypes()
+ migrations.RunSQL(sql="""
+INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id)
+SELECT milestone_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id
+FROM milestones_milestone_watchers""".format(content_type_id=ContentType.objects.get(model='milestone').id)),
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('notifications', '0004_watched'),
+ ('milestones', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.RunPython(create_notifications),
+ migrations.RemoveField(
+ model_name='milestone',
+ name='watchers',
+ ),
+ ]
diff --git a/taiga/projects/milestones/serializers.py b/taiga/projects/milestones/serializers.py
index 2ffd1d43..a2a483e5 100644
--- a/taiga/projects/milestones/serializers.py
+++ b/taiga/projects/milestones/serializers.py
@@ -19,12 +19,14 @@ from django.utils.translation import ugettext as _
from taiga.base.api import serializers
from taiga.base.utils import json
+from taiga.projects.notifications.mixins import WatchedResourceModelSerializer
+from taiga.projects.notifications.validators import WatchersValidator
from ..userstories.serializers import UserStorySerializer
from . import models
-class MilestoneSerializer(serializers.ModelSerializer):
+class MilestoneSerializer(WatchersValidator, WatchedResourceModelSerializer, serializers.ModelSerializer):
user_stories = UserStorySerializer(many=True, required=False, read_only=True)
total_points = serializers.SerializerMethodField("get_total_points")
closed_points = serializers.SerializerMethodField("get_closed_points")
diff --git a/taiga/projects/models.py b/taiga/projects/models.py
index 145edfb0..b2e2566f 100644
--- a/taiga/projects/models.py
+++ b/taiga/projects/models.py
@@ -40,6 +40,8 @@ from taiga.base.utils.slug import slugify_uniquely_for_queryset
from . import choices
+from . notifications.mixins import WatchedModelMixin
+
class Membership(models.Model):
# This model stores all project memberships. Also
@@ -118,7 +120,7 @@ class ProjectDefaults(models.Model):
abstract = True
-class Project(ProjectDefaults, TaggedMixin, models.Model):
+class Project(ProjectDefaults, WatchedModelMixin, TaggedMixin, models.Model):
name = models.CharField(max_length=250, null=False, blank=False,
verbose_name=_("name"))
slug = models.SlugField(max_length=250, unique=True, null=False, blank=True,
diff --git a/taiga/projects/notifications/migrations/0004_watched.py b/taiga/projects/notifications/migrations/0004_watched.py
new file mode 100644
index 00000000..53c85560
--- /dev/null
+++ b/taiga/projects/notifications/migrations/0004_watched.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.conf import settings
+
+
+def fill_watched_table(apps, schema_editor):
+ Watched = apps.get_model("notifications", "Watched")
+ print("test")
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('contenttypes', '0001_initial'),
+ ('notifications', '0003_auto_20141029_1143'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Watched',
+ fields=[
+ ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)),
+ ('object_id', models.PositiveIntegerField()),
+ ('created_date', models.DateTimeField(verbose_name='created date', auto_now_add=True)),
+ ('content_type', models.ForeignKey(to='contenttypes.ContentType')),
+ ('user', models.ForeignKey(related_name='watched', verbose_name='user', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ },
+ bases=(models.Model,),
+ ),
+ migrations.RunPython(fill_watched_table),
+ ]
diff --git a/taiga/projects/notifications/mixins.py b/taiga/projects/notifications/mixins.py
index 362635a4..c5ffbb84 100644
--- a/taiga/projects/notifications/mixins.py
+++ b/taiga/projects/notifications/mixins.py
@@ -17,14 +17,22 @@
from functools import partial
from operator import is_not
-from django.conf import settings
+from django.apps import apps
+from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils.translation import ugettext_lazy as _
+from taiga.base import response
+from taiga.base.decorators import detail_route
+from taiga.base.api import serializers
+from taiga.base.fields import WatchersField
from taiga.projects.notifications import services
+from taiga.projects.notifications.utils import attach_watchers_to_queryset, attach_is_watched_to_queryset
+from taiga.users.models import User
+from . import models
-class WatchedResourceMixin(object):
+class WatchedResourceMixin:
"""
Rest Framework resource mixin for resources susceptible
to be notifiable about their changes.
@@ -36,6 +44,27 @@ class WatchedResourceMixin(object):
_not_notify = False
+ def attach_watchers_attrs_to_queryset(self, queryset):
+ qs = attach_watchers_to_queryset(queryset)
+ if self.request.user.is_authenticated():
+ qs = attach_is_watched_to_queryset(self.request.user, qs)
+
+ return qs
+
+ @detail_route(methods=["POST"])
+ def watch(self, request, pk=None):
+ obj = self.get_object()
+ self.check_permissions(request, "watch", obj)
+ services.add_watcher(obj, request.user)
+ return response.Ok()
+
+ @detail_route(methods=["POST"])
+ def unwatch(self, request, pk=None):
+ obj = self.get_object()
+ self.check_permissions(request, "unwatch", obj)
+ services.remove_watcher(obj, request.user)
+ return response.Ok()
+
def send_notifications(self, obj, history=None):
"""
Shortcut method for resources with special save
@@ -73,7 +102,7 @@ class WatchedResourceMixin(object):
super().pre_delete(obj)
-class WatchedModelMixin(models.Model):
+class WatchedModelMixin(object):
"""
Generic model mixin that makes model compatible
with notification system.
@@ -82,11 +111,6 @@ class WatchedModelMixin(models.Model):
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:
"""
@@ -97,6 +121,7 @@ class WatchedModelMixin(models.Model):
that should works in almost all cases.
"""
return self.project
+ t
def get_watchers(self) -> frozenset:
"""
@@ -112,7 +137,13 @@ class WatchedModelMixin(models.Model):
very inefficient way for obtain watchers but at
this momment is the simplest way.
"""
- return frozenset(self.watchers.all())
+ return frozenset(services.get_watchers(self))
+
+ def add_watcher(self, user):
+ services.add_watcher(self, user)
+
+ def remove_watcher(self, user):
+ services.remove_watcher(self, user)
def get_owner(self) -> object:
"""
@@ -140,3 +171,79 @@ class WatchedModelMixin(models.Model):
self.get_owner(),)
is_not_none = partial(is_not, None)
return frozenset(filter(is_not_none, participants))
+
+
+class WatchedResourceModelSerializer(serializers.ModelSerializer):
+ is_watched = serializers.SerializerMethodField("get_is_watched")
+ watchers = WatchersField(required=False)
+
+ def get_is_watched(self, obj):
+ # The "is_watched" attribute is attached in the get_queryset of the viewset.
+ return getattr(obj, "is_watched", False) or False
+
+ def restore_object(self, attrs, instance=None):
+ #watchers is not a field from the model but can be attached in the get_queryset of the viewset.
+ #If that's the case we need to remove it before calling the super method
+ watcher_field = self.fields.pop("watchers", None)
+ instance = super(WatchedResourceModelSerializer, self).restore_object(attrs, instance)
+ if instance is not None and self.validate_watchers(attrs, "watchers"):
+ new_watcher_ids = set(attrs.get("watchers", []))
+ old_watcher_ids = set(services.get_watchers(instance).values_list("id", flat=True))
+ adding_watcher_ids = list(new_watcher_ids.difference(old_watcher_ids))
+ removing_watcher_ids = list(old_watcher_ids.difference(new_watcher_ids))
+
+ User = apps.get_model("users", "User")
+ adding_users = User.objects.filter(id__in=adding_watcher_ids)
+ removing_users = User.objects.filter(id__in=removing_watcher_ids)
+ for user in adding_users:
+ services.add_watcher(instance, user)
+
+ for user in removing_users:
+ services.remove_watcher(instance, user)
+
+ instance.watchers = services.get_watchers(instance)
+
+ return instance
+
+
+ def to_native(self, obj):
+ #watchers is wasn't attached via the get_queryset of the viewset we need to manually add it
+ if not hasattr(obj, "watchers"):
+ obj.watchers = services.get_watchers(obj)
+
+ return super(WatchedResourceModelSerializer, self).to_native(obj)
+
+
+class WatchersViewSetMixin:
+ # Is a ModelListViewSet with two required params: permission_classes and resource_model
+ serializer_class = WatcherSerializer
+ list_serializer_class = WatcherSerializer
+ permission_classes = None
+ resource_model = None
+
+ def retrieve(self, request, *args, **kwargs):
+ pk = kwargs.get("pk", None)
+ resource_id = kwargs.get("resource_id", None)
+ resource = get_object_or_404(self.resource_model, pk=resource_id)
+
+ self.check_permissions(request, 'retrieve', resource)
+
+ try:
+ self.object = services.get_watchers(resource).get(pk=pk)
+ except ObjectDoesNotExist: # or User.DoesNotExist
+ return response.NotFound()
+
+ serializer = self.get_serializer(self.object)
+ return response.Ok(serializer.data)
+
+ def list(self, request, *args, **kwargs):
+ resource_id = kwargs.get("resource_id", None)
+ resource = get_object_or_404(self.resource_model, pk=resource_id)
+
+ self.check_permissions(request, 'list', resource)
+
+ return super().list(request, *args, **kwargs)
+
+ def get_queryset(self):
+ resource = self.resource_model.objects.get(pk=self.kwargs.get("resource_id"))
+ return services.get_watchers(resource)
diff --git a/taiga/projects/notifications/models.py b/taiga/projects/notifications/models.py
index 29983f90..753b8878 100644
--- a/taiga/projects/notifications/models.py
+++ b/taiga/projects/notifications/models.py
@@ -14,6 +14,8 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+from django.conf import settings
+from django.contrib.contenttypes import generic
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
@@ -72,3 +74,18 @@ class HistoryChangeNotification(models.Model):
class Meta:
unique_together = ("key", "owner", "project", "history_type")
+
+
+class Watched(models.Model):
+ content_type = models.ForeignKey("contenttypes.ContentType")
+ object_id = models.PositiveIntegerField()
+ content_object = generic.GenericForeignKey("content_type", "object_id")
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=False, null=False,
+ related_name="watched", verbose_name=_("user"))
+ created_date = models.DateTimeField(auto_now_add=True, null=False, blank=False,
+ verbose_name=_("created date"))
+
+ class Meta:
+ verbose_name = _("Watched")
+ verbose_name_plural = _("Watched")
+ unique_together = ("content_type", "object_id", "user")
diff --git a/taiga/projects/notifications/services.py b/taiga/projects/notifications/services.py
index 75f7e48b..be19a493 100644
--- a/taiga/projects/notifications/services.py
+++ b/taiga/projects/notifications/services.py
@@ -17,10 +17,10 @@
from functools import partial
from django.apps import apps
-from django.db import IntegrityError
+from django.db.transaction import atomic
+from django.db import IntegrityError, transaction
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
-from django.db import transaction
from django.conf import settings
from django.utils.translation import ugettext as _
@@ -36,7 +36,7 @@ from taiga.projects.history.services import (make_key_from_model_object,
from taiga.permissions.service import user_has_perm
from taiga.users.models import User
-from .models import HistoryChangeNotification
+from .models import HistoryChangeNotification, Watched
def notify_policy_exists(project, user) -> bool:
@@ -121,11 +121,11 @@ def analize_object_for_watchers(obj:object, history:object):
if data["mentions"]:
for user in data["mentions"]:
- obj.watchers.add(user)
+ obj.add_watcher(user)
# Adding the person who edited the object to the watchers
if history.comment and not history.owner.is_system:
- obj.watchers.add(history.owner)
+ obj.add_watcher(history.owner)
def _filter_by_permissions(obj, user):
UserStory = apps.get_model("userstories", "UserStory")
@@ -282,3 +282,46 @@ def send_sync_notifications(notification_id):
def process_sync_notifications():
for notification in HistoryChangeNotification.objects.all():
send_sync_notifications(notification.pk)
+
+
+def get_watchers(obj):
+ User = apps.get_model("users", "User")
+ Watched = apps.get_model("notifications", "Watched")
+ content_type = ContentType.objects.get_for_model(obj)
+ watching_user_ids = Watched.objects.filter(content_type=content_type, object_id=obj.id).values_list("user__id", flat=True)
+ return User.objects.filter(id__in=watching_user_ids)
+
+
+def add_watcher(obj, user):
+ """Add a watcher to an object.
+
+ If the user is already watching the object nothing happends, so this function can be considered
+ idempotent.
+
+ :param obj: Any Django model instance.
+ :param user: User adding the watch. :class:`~taiga.users.models.User` instance.
+ """
+ obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj)
+ with atomic():
+ watched, created = Watched.objects.get_or_create(content_type=obj_type, object_id=obj.id, user=user)
+ if not created:
+ return
+ return watched
+
+
+def remove_watcher(obj, user):
+ """Remove an watching user from an object.
+
+ If the user has not watched the object nothing happens so this function can be considered
+ idempotent.
+
+ :param obj: Any Django model instance.
+ :param user: User removing the watch. :class:`~taiga.users.models.User` instance.
+ """
+ obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj)
+ with atomic():
+ qs = Watched.objects.filter(content_type=obj_type, object_id=obj.id, user=user)
+ if not qs.exists():
+ return
+
+ qs.delete()
diff --git a/taiga/projects/notifications/utils.py b/taiga/projects/notifications/utils.py
new file mode 100644
index 00000000..14edd0b3
--- /dev/null
+++ b/taiga/projects/notifications/utils.py
@@ -0,0 +1,63 @@
+# Copyright (C) 2014 Andrey Antukh
+# Copyright (C) 2014 Jesús Espino
+# Copyright (C) 2014 David Barragán
+# Copyright (C) 2014 Anler Hernández
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from django.apps import apps
+
+
+def attach_watchers_to_queryset(queryset, as_field="watchers"):
+ """Attach watching user ids to each object of the queryset.
+
+ :param queryset: A Django queryset object.
+ :param as_field: Attach the watchers as an attribute with this name.
+
+ :return: Queryset object with the additional `as_field` field.
+ """
+ model = queryset.model
+ type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model)
+
+ sql = ("""SELECT array(SELECT user_id
+ FROM notifications_watched
+ WHERE notifications_watched.content_type_id = {type_id}
+ AND notifications_watched.object_id = {tbl}.id)""")
+ sql = sql.format(type_id=type.id, tbl=model._meta.db_table)
+ qs = queryset.extra(select={as_field: sql})
+
+ return qs
+
+
+def attach_is_watched_to_queryset(user, queryset, as_field="is_watched"):
+ """Attach is_watched boolean to each object of the queryset.
+
+ :param user: A users.User object model
+ :param queryset: A Django queryset object.
+ :param as_field: Attach the boolean as an attribute with this name.
+
+ :return: Queryset object with the additional `as_field` field.
+ """
+ model = queryset.model
+ type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model)
+ sql = ("""SELECT CASE WHEN (SELECT count(*)
+ FROM notifications_watched
+ WHERE notifications_watched.content_type_id = {type_id}
+ AND notifications_watched.object_id = {tbl}.id
+ AND notifications_watched.user_id = {user_id}) > 0
+ THEN TRUE
+ ELSE FALSE
+ END""")
+ sql = sql.format(type_id=type.id, tbl=model._meta.db_table, user_id=user.id)
+ qs = queryset.extra(select={as_field: sql})
+ return qs
diff --git a/taiga/projects/notifications/validators.py b/taiga/projects/notifications/validators.py
index 38c5750b..b28e0712 100644
--- a/taiga/projects/notifications/validators.py
+++ b/taiga/projects/notifications/validators.py
@@ -21,7 +21,7 @@ from taiga.base.api import serializers
class WatchersValidator:
def validate_watchers(self, attrs, source):
- users = attrs[source]
+ users = attrs.get(source, [])
# Try obtain a valid project
if self.object is None and "project" in attrs:
@@ -39,7 +39,8 @@ class WatchersValidator:
# Check if incoming watchers are contained
# in project members list
- result = set(users).difference(set(project.members.all()))
+ member_ids = project.members.values_list("id", flat=True)
+ result = set(users).difference(member_ids)
if result:
raise serializers.ValidationError(_("Watchers contains invalid users"))
diff --git a/taiga/projects/permissions.py b/taiga/projects/permissions.py
index 236bc182..9cabdc97 100644
--- a/taiga/projects/permissions.py
+++ b/taiga/projects/permissions.py
@@ -62,6 +62,8 @@ class ProjectPermission(TaigaResourcePermission):
tags_colors_perms = HasProjectPerm('view_project')
star_perms = IsAuthenticated() & HasProjectPerm('view_project')
unstar_perms = IsAuthenticated() & HasProjectPerm('view_project')
+ watch_perms = IsAuthenticated() & HasProjectPerm('view_project')
+ unwatch_perms = IsAuthenticated() & HasProjectPerm('view_project')
create_template_perms = IsSuperUser()
leave_perms = CanLeaveProject()
diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py
index a7e96834..9a13c6e7 100644
--- a/taiga/projects/serializers.py
+++ b/taiga/projects/serializers.py
@@ -25,6 +25,8 @@ from taiga.base.fields import PgArrayField
from taiga.base.fields import TagsField
from taiga.base.fields import TagsColorsField
+from taiga.projects.notifications.validators import WatchersValidator
+
from taiga.users.services import get_photo_or_gravatar_url
from taiga.users.serializers import UserSerializer
from taiga.users.serializers import UserBasicInfoSerializer
@@ -40,6 +42,7 @@ from .validators import ProjectExistsValidator
from .custom_attributes.serializers import UserStoryCustomAttributeSerializer
from .custom_attributes.serializers import TaskCustomAttributeSerializer
from .custom_attributes.serializers import IssueCustomAttributeSerializer
+from .notifications.mixins import WatchedResourceModelSerializer
from .votes.mixins.serializers import StarredResourceSerializerMixin
######################################################
@@ -305,7 +308,7 @@ class ProjectMemberSerializer(serializers.ModelSerializer):
## Projects
######################################################
-class ProjectSerializer(StarredResourceSerializerMixin, serializers.ModelSerializer):
+class ProjectSerializer(WatchersValidator, StarredResourceSerializerMixin, WatchedResourceModelSerializer, serializers.ModelSerializer):
tags = TagsField(default=[], required=False)
anon_permissions = PgArrayField(required=False)
public_permissions = PgArrayField(required=False)
diff --git a/taiga/projects/signals.py b/taiga/projects/signals.py
index f6cb0b1a..6cfb0ddf 100644
--- a/taiga/projects/signals.py
+++ b/taiga/projects/signals.py
@@ -45,24 +45,6 @@ def membership_post_delete(sender, instance, using, **kwargs):
instance.project.update_role_points()
-def update_watchers_on_membership_post_delete(sender, instance, using, **kwargs):
- models = [apps.get_model("userstories", "UserStory"),
- apps.get_model("tasks", "Task"),
- apps.get_model("issues", "Issue")]
-
- # `user_id` is used beacuse in some momments
- # instance.user can contain pointer to now
- # removed object from a database.
- for model in models:
- #filter(project=instance.project)
- filter = {
- "user_id": instance.user_id,
- "%s__project"%(model._meta.model_name): instance.project,
- }
-
- model.watchers.through.objects.filter(**filter).delete()
-
-
def create_notify_policy(sender, instance, using, **kwargs):
if instance.user:
create_notify_policy_if_not_exists(instance.project, instance.user)
diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py
index e2262632..2e2ad619 100644
--- a/taiga/projects/tasks/api.py
+++ b/taiga/projects/tasks/api.py
@@ -40,9 +40,10 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa
ModelCrudViewSet):
queryset = models.Task.objects.all()
permission_classes = (permissions.TaskPermission,)
- filter_backends = (filters.CanViewTasksFilterBackend,)
+ filter_backends = (filters.CanViewTasksFilterBackend, filters.WatchersFilter)
+ retrieve_exclude_filters = (filters.WatchersFilter,)
filter_fields = ["user_story", "milestone", "project", "assigned_to",
- "status__is_closed", "watchers"]
+ "status__is_closed"]
def get_serializer_class(self, *args, **kwargs):
if self.action in ["retrieve", "by_ref"]:
@@ -86,7 +87,8 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa
def get_queryset(self):
qs = super().get_queryset()
- return self.attach_votes_attrs_to_queryset(qs)
+ qs = self.attach_votes_attrs_to_queryset(qs)
+ return self.attach_watchers_attrs_to_queryset(qs)
def pre_save(self, obj):
if obj.user_story:
diff --git a/taiga/projects/tasks/migrations/0008_remove_task_watchers.py b/taiga/projects/tasks/migrations/0008_remove_task_watchers.py
new file mode 100644
index 00000000..813eaad9
--- /dev/null
+++ b/taiga/projects/tasks/migrations/0008_remove_task_watchers.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.contenttypes.management import update_all_contenttypes
+
+def create_notifications(apps, schema_editor):
+ update_all_contenttypes()
+ migrations.RunSQL(sql="""
+INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id)
+SELECT task_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id
+FROM tasks_task_watchers""".format(content_type_id=ContentType.objects.get(model='task').id)),
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('notifications', '0004_watched'),
+ ('tasks', '0007_auto_20150629_1556'),
+ ]
+
+ operations = [
+ migrations.RunPython(create_notifications),
+ migrations.RemoveField(
+ model_name='task',
+ name='watchers',
+ ),
+ ]
diff --git a/taiga/projects/tasks/permissions.py b/taiga/projects/tasks/permissions.py
index af5fbd49..c9cbd667 100644
--- a/taiga/projects/tasks/permissions.py
+++ b/taiga/projects/tasks/permissions.py
@@ -33,6 +33,8 @@ class TaskPermission(TaigaResourcePermission):
bulk_update_order_perms = HasProjectPerm('modify_task')
upvote_perms = IsAuthenticated() & HasProjectPerm('view_tasks')
downvote_perms = IsAuthenticated() & HasProjectPerm('view_tasks')
+ watch_perms = IsAuthenticated() & HasProjectPerm('view_tasks')
+ unwatch_perms = IsAuthenticated() & HasProjectPerm('view_tasks')
class TaskVotersPermission(TaigaResourcePermission):
diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py
index 3df25a77..221a188c 100644
--- a/taiga/projects/tasks/serializers.py
+++ b/taiga/projects/tasks/serializers.py
@@ -27,6 +27,7 @@ from taiga.projects.milestones.validators import SprintExistsValidator
from taiga.projects.tasks.validators import TaskExistsValidator
from taiga.projects.notifications.validators import WatchersValidator
from taiga.projects.serializers import BasicTaskStatusSerializerSerializer
+from taiga.projects.notifications.mixins import WatchedResourceModelSerializer
from taiga.projects.votes.mixins.serializers import VotedResourceSerializerMixin
from taiga.users.serializers import UserBasicInfoSerializer
@@ -34,7 +35,7 @@ from taiga.users.serializers import UserBasicInfoSerializer
from . import models
-class TaskSerializer(WatchersValidator, VotedResourceSerializerMixin, serializers.ModelSerializer):
+class TaskSerializer(WatchersValidator, VotedResourceSerializerMixin, WatchedResourceModelSerializer, serializers.ModelSerializer):
tags = TagsField(required=False, default=[])
external_reference = PgArrayField(required=False)
comment = serializers.SerializerMethodField("get_comment")
diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py
index 8ae11d53..653fdc56 100644
--- a/taiga/projects/userstories/api.py
+++ b/taiga/projects/userstories/api.py
@@ -53,19 +53,20 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
filters.AssignedToFilter,
filters.StatusesFilter,
filters.TagsFilter,
+ filters.WatchersFilter,
filters.QFilter,
filters.OrderByFilterMixin)
retrieve_exclude_filters = (filters.OwnersFilter,
filters.AssignedToFilter,
filters.StatusesFilter,
- filters.TagsFilter)
+ filters.TagsFilter,
+ filters.WatchersFilter)
filter_fields = ["project",
"milestone",
"milestone__isnull",
"is_closed",
"status__is_archived",
- "status__is_closed",
- "watchers"]
+ "status__is_closed"]
order_by_fields = ["backlog_order",
"sprint_order",
"kanban_order"]
@@ -113,10 +114,10 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
qs = super().get_queryset()
qs = qs.prefetch_related("role_points",
"role_points__points",
- "role_points__role",
- "watchers")
+ "role_points__role")
qs = qs.select_related("milestone", "project")
- return self.attach_votes_attrs_to_queryset(qs)
+ qs = self.attach_votes_attrs_to_queryset(qs)
+ return self.attach_watchers_attrs_to_queryset(qs)
def pre_save(self, obj):
# This is very ugly hack, but having
diff --git a/taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py b/taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py
new file mode 100644
index 00000000..d3e24f62
--- /dev/null
+++ b/taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.contenttypes.management import update_all_contenttypes
+
+def create_notifications(apps, schema_editor):
+ update_all_contenttypes()
+ migrations.RunSQL(sql="""
+INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id)
+SELECT userstory_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id
+FROM userstories_userstory_watchers""".format(content_type_id=ContentType.objects.get(model='userstory').id)),
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('notifications', '0004_watched'),
+ ('userstories', '0009_remove_userstory_is_archived'),
+ ]
+
+ operations = [
+ migrations.RunPython(create_notifications),
+ migrations.RemoveField(
+ model_name='userstory',
+ name='watchers',
+ ),
+ ]
diff --git a/taiga/projects/userstories/permissions.py b/taiga/projects/userstories/permissions.py
index 00659141..0a1c7b8a 100644
--- a/taiga/projects/userstories/permissions.py
+++ b/taiga/projects/userstories/permissions.py
@@ -32,7 +32,8 @@ class UserStoryPermission(TaigaResourcePermission):
bulk_update_order_perms = HasProjectPerm('modify_us')
upvote_perms = IsAuthenticated() & HasProjectPerm('view_us')
downvote_perms = IsAuthenticated() & HasProjectPerm('view_us')
-
+ watch_perms = IsAuthenticated() & HasProjectPerm('view_us')
+ unwatch_perms = IsAuthenticated() & HasProjectPerm('view_us')
class UserStoryVotersPermission(TaigaResourcePermission):
enought_perms = IsProjectOwner() | IsSuperUser()
diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py
index 6c3a76cd..a461e57b 100644
--- a/taiga/projects/userstories/serializers.py
+++ b/taiga/projects/userstories/serializers.py
@@ -27,6 +27,7 @@ from taiga.projects.validators import UserStoryStatusExistsValidator
from taiga.projects.userstories.validators import UserStoryExistsValidator
from taiga.projects.notifications.validators import WatchersValidator
from taiga.projects.serializers import BasicUserStoryStatusSerializer
+from taiga.projects.notifications.mixins import WatchedResourceModelSerializer
from taiga.projects.votes.mixins.serializers import VotedResourceSerializerMixin
from taiga.users.serializers import UserBasicInfoSerializer
@@ -44,7 +45,7 @@ class RolePointsField(serializers.WritableField):
return json.loads(obj)
-class UserStorySerializer(WatchersValidator, VotedResourceSerializerMixin, serializers.ModelSerializer):
+class UserStorySerializer(WatchersValidator, VotedResourceSerializerMixin, WatchedResourceModelSerializer, serializers.ModelSerializer):
tags = TagsField(default=[], required=False)
external_reference = PgArrayField(required=False)
points = RolePointsField(source="role_points", required=False)
diff --git a/taiga/projects/votes/serializers.py b/taiga/projects/votes/serializers.py
index c72ae91e..b6ab72a8 100644
--- a/taiga/projects/votes/serializers.py
+++ b/taiga/projects/votes/serializers.py
@@ -16,8 +16,10 @@
# along with this program. If not, see .
from taiga.base.api import serializers
+from taiga.base.fields import TagsField
from taiga.users.models import User
+from taiga.users.services import get_photo_or_gravatar_url
class VoterSerializer(serializers.ModelSerializer):
diff --git a/taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py b/taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py
new file mode 100644
index 00000000..d0c1c832
--- /dev/null
+++ b/taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.apps import apps
+from django.db import models, migrations
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.contenttypes.management import update_all_contenttypes
+
+def create_notifications(apps, schema_editor):
+ update_all_contenttypes()
+ migrations.RunSQL(sql="""
+INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id)
+SELECT wikipage_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id
+FROM wiki_wikipage_watchers""".format(content_type_id=ContentType.objects.get(model='wikipage').id)),
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('notifications', '0004_watched'),
+ ('wiki', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.RunPython(create_notifications),
+ migrations.RemoveField(
+ model_name='wikipage',
+ name='watchers',
+ ),
+ ]
diff --git a/taiga/projects/wiki/serializers.py b/taiga/projects/wiki/serializers.py
index 45d3c99b..a528fdd8 100644
--- a/taiga/projects/wiki/serializers.py
+++ b/taiga/projects/wiki/serializers.py
@@ -15,6 +15,10 @@
# along with this program. If not, see .
from taiga.base.api import serializers
+from taiga.projects.history import services as history_service
+from taiga.projects.notifications.mixins import WatchedResourceModelSerializer
+from taiga.projects.notifications.validators import WatchersValidator
+from taiga.mdrender.service import render as mdrender
from . import models
@@ -23,7 +27,7 @@ from taiga.projects.history import services as history_service
from taiga.mdrender.service import render as mdrender
-class WikiPageSerializer(serializers.ModelSerializer):
+class WikiPageSerializer(WatchersValidator, WatchedResourceModelSerializer, serializers.ModelSerializer):
html = serializers.SerializerMethodField("get_html")
editions = serializers.SerializerMethodField("get_editions")
@@ -39,6 +43,5 @@ class WikiPageSerializer(serializers.ModelSerializer):
class WikiLinkSerializer(serializers.ModelSerializer):
-
class Meta:
model = models.WikiLink
diff --git a/taiga/timeline/signals.py b/taiga/timeline/signals.py
index 7769817d..d56db4ac 100644
--- a/taiga/timeline/signals.py
+++ b/taiga/timeline/signals.py
@@ -62,7 +62,7 @@ def _push_to_timelines(project, user, obj, event_type, created_datetime, extra_d
## - Watchers
watchers = getattr(obj, "watchers", None)
if watchers:
- related_people |= obj.watchers.all()
+ related_people |= obj.get_watchers()
## - Exclude inactive and system users and remove duplicate
related_people = related_people.exclude(is_active=False)
diff --git a/taiga/users/migrations/0012_auto_20150812_1142.py b/taiga/users/migrations/0012_auto_20150812_1142.py
new file mode 100644
index 00000000..fff8c17b
--- /dev/null
+++ b/taiga/users/migrations/0012_auto_20150812_1142.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import djorm_pgarray.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0011_user_theme'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='role',
+ name='permissions',
+ field=djorm_pgarray.fields.TextArrayField(choices=[('view_project', 'View project'), ('star_project', 'Star project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('delete_us', 'Delete user story'), ('vote_us', 'Vote user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('delete_task', 'Delete task'), ('vote_task', 'Vote task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('delete_issue', 'Delete issue'), ('vote_issue', 'Vote issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')], verbose_name='permissions', default=[], dbtype='text'),
+ preserve_default=True,
+ ),
+ ]
diff --git a/taiga/webhooks/serializers.py b/taiga/webhooks/serializers.py
index e802c1b3..47d0a145 100644
--- a/taiga/webhooks/serializers.py
+++ b/taiga/webhooks/serializers.py
@@ -23,8 +23,9 @@ from taiga.projects.userstories import models as us_models
from taiga.projects.tasks import models as task_models
from taiga.projects.issues import models as issue_models
from taiga.projects.milestones import models as milestone_models
-from taiga.projects.history import models as history_models
from taiga.projects.wiki import models as wiki_models
+from taiga.projects.history import models as history_models
+from taiga.projects.notifications.mixins import WatchedResourceModelSerializer
from .models import Webhook, WebhookLog
@@ -103,7 +104,8 @@ class PointSerializer(serializers.Serializer):
return obj.value
-class UserStorySerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.ModelSerializer):
+class UserStorySerializer(CustomAttributesValuesWebhookSerializerMixin, WatchedResourceModelSerializer,
+ serializers.ModelSerializer):
tags = TagsField(default=[], required=False)
external_reference = PgArrayField(required=False)
owner = UserSerializer()
@@ -119,7 +121,8 @@ class UserStorySerializer(CustomAttributesValuesWebhookSerializerMixin, serializ
return project.userstorycustomattributes.all()
-class TaskSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.ModelSerializer):
+class TaskSerializer(CustomAttributesValuesWebhookSerializerMixin, WatchedResourceModelSerializer,
+ serializers.ModelSerializer):
tags = TagsField(default=[], required=False)
owner = UserSerializer()
assigned_to = UserSerializer()
@@ -132,7 +135,8 @@ class TaskSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.M
return project.taskcustomattributes.all()
-class IssueSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.ModelSerializer):
+class IssueSerializer(CustomAttributesValuesWebhookSerializerMixin, WatchedResourceModelSerializer,
+ serializers.ModelSerializer):
tags = TagsField(default=[], required=False)
owner = UserSerializer()
assigned_to = UserSerializer()
diff --git a/tests/integration/resources_permissions/test_issues_resources.py b/tests/integration/resources_permissions/test_issues_resources.py
index f5a90e66..d9950e00 100644
--- a/tests/integration/resources_permissions/test_issues_resources.py
+++ b/tests/integration/resources_permissions/test_issues_resources.py
@@ -574,3 +574,45 @@ def test_issues_csv(client, data):
results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users)
assert results == [200, 200, 200, 200, 200]
+
+
+def test_issue_action_watch(client, data):
+ public_url = reverse('issues-watch', kwargs={"pk": data.public_issue.pk})
+ private_url1 = reverse('issues-watch', kwargs={"pk": data.private_issue1.pk})
+ private_url2 = reverse('issues-watch', kwargs={"pk": data.private_issue2.pk})
+
+ users = [
+ None,
+ data.registered_user,
+ data.project_member_without_perms,
+ data.project_member_with_perms,
+ data.project_owner
+ ]
+
+ results = helper_test_http_method(client, 'post', public_url, "", users)
+ assert results == [401, 200, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private_url1, "", users)
+ assert results == [401, 200, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private_url2, "", users)
+ assert results == [404, 404, 404, 200, 200]
+
+
+def test_issue_action_unwatch(client, data):
+ public_url = reverse('issues-unwatch', kwargs={"pk": data.public_issue.pk})
+ private_url1 = reverse('issues-unwatch', kwargs={"pk": data.private_issue1.pk})
+ private_url2 = reverse('issues-unwatch', kwargs={"pk": data.private_issue2.pk})
+
+ users = [
+ None,
+ data.registered_user,
+ data.project_member_without_perms,
+ data.project_member_with_perms,
+ data.project_owner
+ ]
+
+ results = helper_test_http_method(client, 'post', public_url, "", users)
+ assert results == [401, 200, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private_url1, "", users)
+ assert results == [401, 200, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private_url2, "", users)
+ assert results == [404, 404, 404, 200, 200]
diff --git a/tests/integration/resources_permissions/test_projects_resource.py b/tests/integration/resources_permissions/test_projects_resource.py
index e485f497..888b1ef4 100644
--- a/tests/integration/resources_permissions/test_projects_resource.py
+++ b/tests/integration/resources_permissions/test_projects_resource.py
@@ -416,3 +416,41 @@ def test_regenerate_issues_csv_uuid(client, data):
results = helper_test_http_method(client, 'post', private2_url, None, users)
assert results == [404, 404, 403, 200]
+
+
+def test_project_action_watch(client, data):
+ public_url = reverse('projects-watch', kwargs={"pk": data.public_project.pk})
+ private1_url = reverse('projects-watch', kwargs={"pk": data.private_project1.pk})
+ private2_url = reverse('projects-watch', kwargs={"pk": data.private_project2.pk})
+
+ users = [
+ None,
+ data.registered_user,
+ data.project_member_with_perms,
+ data.project_owner
+ ]
+ results = helper_test_http_method(client, 'post', public_url, None, users)
+ assert results == [401, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private1_url, None, users)
+ assert results == [401, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private2_url, None, users)
+ assert results == [404, 404, 200, 200]
+
+
+def test_project_action_unwatch(client, data):
+ public_url = reverse('projects-unwatch', kwargs={"pk": data.public_project.pk})
+ private1_url = reverse('projects-unwatch', kwargs={"pk": data.private_project1.pk})
+ private2_url = reverse('projects-unwatch', kwargs={"pk": data.private_project2.pk})
+
+ users = [
+ None,
+ data.registered_user,
+ data.project_member_with_perms,
+ data.project_owner
+ ]
+ results = helper_test_http_method(client, 'post', public_url, None, users)
+ assert results == [401, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private1_url, None, users)
+ assert results == [401, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private2_url, None, users)
+ assert results == [404, 404, 200, 200]
diff --git a/tests/integration/resources_permissions/test_tasks_resources.py b/tests/integration/resources_permissions/test_tasks_resources.py
index b5c3c4f0..bd4d0e09 100644
--- a/tests/integration/resources_permissions/test_tasks_resources.py
+++ b/tests/integration/resources_permissions/test_tasks_resources.py
@@ -529,3 +529,45 @@ def test_tasks_csv(client, data):
results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users)
assert results == [200, 200, 200, 200, 200]
+
+
+def test_task_action_watch(client, data):
+ public_url = reverse('tasks-watch', kwargs={"pk": data.public_task.pk})
+ private_url1 = reverse('tasks-watch', kwargs={"pk": data.private_task1.pk})
+ private_url2 = reverse('tasks-watch', kwargs={"pk": data.private_task2.pk})
+
+ users = [
+ None,
+ data.registered_user,
+ data.project_member_without_perms,
+ data.project_member_with_perms,
+ data.project_owner
+ ]
+
+ results = helper_test_http_method(client, 'post', public_url, "", users)
+ assert results == [401, 200, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private_url1, "", users)
+ assert results == [401, 200, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private_url2, "", users)
+ assert results == [404, 404, 404, 200, 200]
+
+
+def test_task_action_unwatch(client, data):
+ public_url = reverse('tasks-unwatch', kwargs={"pk": data.public_task.pk})
+ private_url1 = reverse('tasks-unwatch', kwargs={"pk": data.private_task1.pk})
+ private_url2 = reverse('tasks-unwatch', kwargs={"pk": data.private_task2.pk})
+
+ users = [
+ None,
+ data.registered_user,
+ data.project_member_without_perms,
+ data.project_member_with_perms,
+ data.project_owner
+ ]
+
+ results = helper_test_http_method(client, 'post', public_url, "", users)
+ assert results == [401, 200, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private_url1, "", users)
+ assert results == [401, 200, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private_url2, "", users)
+ assert results == [404, 404, 404, 200, 200]
diff --git a/tests/integration/resources_permissions/test_userstories_resources.py b/tests/integration/resources_permissions/test_userstories_resources.py
index c257244b..219c4e2a 100644
--- a/tests/integration/resources_permissions/test_userstories_resources.py
+++ b/tests/integration/resources_permissions/test_userstories_resources.py
@@ -528,3 +528,45 @@ def test_user_stories_csv(client, data):
results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users)
assert results == [200, 200, 200, 200, 200]
+
+
+def test_user_story_action_watch(client, data):
+ public_url = reverse('userstories-watch', kwargs={"pk": data.public_user_story.pk})
+ private_url1 = reverse('userstories-watch', kwargs={"pk": data.private_user_story1.pk})
+ private_url2 = reverse('userstories-watch', kwargs={"pk": data.private_user_story2.pk})
+
+ users = [
+ None,
+ data.registered_user,
+ data.project_member_without_perms,
+ data.project_member_with_perms,
+ data.project_owner
+ ]
+
+ results = helper_test_http_method(client, 'post', public_url, "", users)
+ assert results == [401, 200, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private_url1, "", users)
+ assert results == [401, 200, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private_url2, "", users)
+ assert results == [404, 404, 404, 200, 200]
+
+
+def test_user_story_action_unwatch(client, data):
+ public_url = reverse('userstories-unwatch', kwargs={"pk": data.public_user_story.pk})
+ private_url1 = reverse('userstories-unwatch', kwargs={"pk": data.private_user_story1.pk})
+ private_url2 = reverse('userstories-unwatch', kwargs={"pk": data.private_user_story2.pk})
+
+ users = [
+ None,
+ data.registered_user,
+ data.project_member_without_perms,
+ data.project_member_with_perms,
+ data.project_owner
+ ]
+
+ results = helper_test_http_method(client, 'post', public_url, "", users)
+ assert results == [401, 200, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private_url1, "", users)
+ assert results == [401, 200, 200, 200, 200]
+ results = helper_test_http_method(client, 'post', private_url2, "", users)
+ assert results == [404, 404, 404, 200, 200]
diff --git a/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py
index 96143112..073d7585 100644
--- a/tests/integration/test_importer_api.py
+++ b/tests/integration/test_importer_api.py
@@ -47,13 +47,15 @@ def test_invalid_project_import(client):
def test_valid_project_import_without_extra_data(client):
user = f.UserFactory.create()
+ user_watching = f.UserFactory.create(email="testing@taiga.io")
client.login(user)
url = reverse("importer-list")
data = {
"name": "Imported project",
"description": "Imported project",
- "roles": [{"name": "Role"}]
+ "roles": [{"name": "Role"}],
+ "watchers": ["testing@taiga.io"]
}
response = client.post(url, json.dumps(data), content_type="application/json")
@@ -66,6 +68,7 @@ def test_valid_project_import_without_extra_data(client):
]
assert all(map(lambda x: len(response_data[x]) == 0, must_empty_children))
assert response_data["owner"] == user.email
+ assert response_data["watchers"] == [user_watching.email]
def test_valid_project_import_with_not_existing_memberships(client):
@@ -383,6 +386,7 @@ def test_valid_issue_import_with_custom_attributes_values(client):
def test_valid_issue_import_with_extra_data(client):
user = f.UserFactory.create()
+ user_watching = f.UserFactory.create(email="testing@taiga.io")
project = f.ProjectFactory.create(owner=user)
f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_issue_type = f.IssueTypeFactory.create(project=project)
@@ -403,7 +407,8 @@ def test_valid_issue_import_with_extra_data(client):
"name": "imported attachment",
"data": base64.b64encode(b"TEST").decode("utf-8")
}
- }]
+ }],
+ "watchers": ["testing@taiga.io"]
}
response = client.post(url, json.dumps(data), content_type="application/json")
@@ -413,6 +418,7 @@ def test_valid_issue_import_with_extra_data(client):
assert response_data["owner"] == user.email
assert response_data["ref"] is not None
assert response_data["finished_date"] == "2014-10-24T00:00:00+0000"
+ assert response_data["watchers"] == [user_watching.email]
def test_invalid_issue_import_with_extra_data(client):
@@ -535,6 +541,7 @@ def test_valid_us_import_without_extra_data(client):
def test_valid_us_import_with_extra_data(client):
user = f.UserFactory.create()
+ user_watching = f.UserFactory.create(email="testing@taiga.io")
project = f.ProjectFactory.create(owner=user)
f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_us_status = f.UserStoryStatusFactory.create(project=project)
@@ -551,7 +558,8 @@ def test_valid_us_import_with_extra_data(client):
"name": "imported attachment",
"data": base64.b64encode(b"TEST").decode("utf-8")
}
- }]
+ }],
+ "watchers": ["testing@taiga.io"]
}
response = client.post(url, json.dumps(data), content_type="application/json")
@@ -560,6 +568,7 @@ def test_valid_us_import_with_extra_data(client):
assert len(response_data["attachments"]) == 1
assert response_data["owner"] == user.email
assert response_data["ref"] is not None
+ assert response_data["watchers"] == [user_watching.email]
def test_invalid_us_import_with_extra_data(client):
@@ -664,6 +673,7 @@ def test_valid_task_import_with_custom_attributes_values(client):
def test_valid_task_import_with_extra_data(client):
user = f.UserFactory.create()
+ user_watching = f.UserFactory.create(email="testing@taiga.io")
project = f.ProjectFactory.create(owner=user)
f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_task_status = f.TaskStatusFactory.create(project=project)
@@ -680,7 +690,8 @@ def test_valid_task_import_with_extra_data(client):
"name": "imported attachment",
"data": base64.b64encode(b"TEST").decode("utf-8")
}
- }]
+ }],
+ "watchers": ["testing@taiga.io"]
}
response = client.post(url, json.dumps(data), content_type="application/json")
@@ -689,6 +700,7 @@ def test_valid_task_import_with_extra_data(client):
assert len(response_data["attachments"]) == 1
assert response_data["owner"] == user.email
assert response_data["ref"] is not None
+ assert response_data["watchers"] == [user_watching.email]
def test_invalid_task_import_with_extra_data(client):
@@ -787,6 +799,7 @@ def test_valid_wiki_page_import_without_extra_data(client):
def test_valid_wiki_page_import_with_extra_data(client):
user = f.UserFactory.create()
+ user_watching = f.UserFactory.create(email="testing@taiga.io")
project = f.ProjectFactory.create(owner=user)
f.MembershipFactory(project=project, user=user, is_owner=True)
client.login(user)
@@ -801,7 +814,8 @@ def test_valid_wiki_page_import_with_extra_data(client):
"name": "imported attachment",
"data": base64.b64encode(b"TEST").decode("utf-8")
}
- }]
+ }],
+ "watchers": ["testing@taiga.io"]
}
response = client.post(url, json.dumps(data), content_type="application/json")
@@ -809,6 +823,7 @@ def test_valid_wiki_page_import_with_extra_data(client):
response_data = response.data
assert len(response_data["attachments"]) == 1
assert response_data["owner"] == user.email
+ assert response_data["watchers"] == [user_watching.email]
def test_invalid_wiki_page_import_with_extra_data(client):
@@ -877,6 +892,7 @@ def test_invalid_milestone_import(client):
def test_valid_milestone_import(client):
user = f.UserFactory.create()
+ user_watching = f.UserFactory.create(email="testing@taiga.io")
project = f.ProjectFactory.create(owner=user)
f.MembershipFactory(project=project, user=user, is_owner=True)
client.login(user)
@@ -886,11 +902,12 @@ def test_valid_milestone_import(client):
"name": "Imported milestone",
"estimated_start": "2014-10-10",
"estimated_finish": "2014-10-20",
+ "watchers": ["testing@taiga.io"]
}
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 201
- response.data
+ assert response.data["watchers"] == [user_watching.email]
diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py
index a5a9847c..ab0fdb42 100644
--- a/tests/integration/test_notifications.py
+++ b/tests/integration/test_notifications.py
@@ -97,7 +97,7 @@ def test_analize_object_for_watchers():
history.comment = ""
services.analize_object_for_watchers(issue, history)
- assert issue.watchers.add.call_count == 2
+ assert issue.add_watcher.call_count == 2
def test_analize_object_for_watchers_adding_owner_non_empty_comment():
@@ -112,7 +112,7 @@ def test_analize_object_for_watchers_adding_owner_non_empty_comment():
history.owner = user1
services.analize_object_for_watchers(issue, history)
- assert issue.watchers.add.call_count == 1
+ assert issue.add_watcher.call_count == 1
def test_analize_object_for_watchers_no_adding_owner_empty_comment():
@@ -127,7 +127,7 @@ def test_analize_object_for_watchers_no_adding_owner_empty_comment():
history.owner = user1
services.analize_object_for_watchers(issue, history)
- assert issue.watchers.add.call_count == 0
+ assert issue.add_watcher.call_count == 0
def test_users_to_notify():
@@ -180,7 +180,7 @@ def test_users_to_notify():
assert users == {member1.user, issue.get_owner()}
# Test with watchers
- issue.watchers.add(member3.user)
+ issue.add_watcher(member3.user)
users = services.get_users_to_notify(issue)
assert len(users) == 3
assert users == {member1.user, member3.user, issue.get_owner()}
@@ -189,24 +189,24 @@ def test_users_to_notify():
policy2.notify_level = NotifyLevel.ignore
policy2.save()
- issue.watchers.add(member3.user)
+ issue.add_watcher(member3.user)
users = services.get_users_to_notify(issue)
assert len(users) == 2
assert users == {member1.user, issue.get_owner()}
# Test with watchers without permissions
- issue.watchers.add(member5.user)
+ issue.add_watcher(member5.user)
users = services.get_users_to_notify(issue)
assert len(users) == 2
assert users == {member1.user, issue.get_owner()}
# Test with inactive user
- issue.watchers.add(inactive_member1.user)
+ issue.add_watcher(inactive_member1.user)
assert len(users) == 2
assert users == {member1.user, issue.get_owner()}
# Test with system user
- issue.watchers.add(system_member1.user)
+ issue.add_watcher(system_member1.user)
assert len(users) == 2
assert users == {member1.user, issue.get_owner()}
@@ -344,7 +344,7 @@ def test_watchers_assignation_for_issue(client):
issue = f.create_issue(project=project1, owner=user1)
data = {"version": issue.version,
- "watchers": [user1.pk]}
+ "watchersa": [user1.pk]}
url = reverse("issues-detail", args=[issue.pk])
response = client.json.patch(url, json.dumps(data))
diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py
index 45b7c96e..2a4f8e5c 100644
--- a/tests/integration/test_projects.py
+++ b/tests/integration/test_projects.py
@@ -265,7 +265,7 @@ def test_leave_project_respect_watching_items(client):
url = reverse("projects-leave", args=(project.id,))
response = client.post(url)
assert response.status_code == 200
- assert list(issue.watchers.all()) == [user]
+ assert issue.watchers == [user]
def test_delete_membership_only_owner(client):
diff --git a/tests/integration/test_timeline.py b/tests/integration/test_timeline.py
index 957c5c91..00e61fc3 100644
--- a/tests/integration/test_timeline.py
+++ b/tests/integration/test_timeline.py
@@ -384,16 +384,6 @@ def test_assigned_to_user_story_timeline():
assert user_timeline[0].data["userstory"]["subject"] == "test us timeline"
-def test_watchers_to_user_story_timeline():
- membership = factories.MembershipFactory.create()
- user_story = factories.UserStoryFactory.create(subject="test us timeline", project=membership.project)
- user_story.watchers.add(membership.user)
- history_services.take_snapshot(user_story, user=user_story.owner)
- user_timeline = service.get_profile_timeline(membership.user)
- assert user_timeline[0].event_type == "userstories.userstory.create"
- assert user_timeline[0].data["userstory"]["subject"] == "test us timeline"
-
-
def test_user_data_for_non_system_users():
user_story = factories.UserStoryFactory.create(subject="test us timeline")
history_services.take_snapshot(user_story, user=user_story.owner)
diff --git a/tests/integration/test_watch_issues.py b/tests/integration/test_watch_issues.py
new file mode 100644
index 00000000..f51d5075
--- /dev/null
+++ b/tests/integration/test_watch_issues.py
@@ -0,0 +1,47 @@
+# Copyright (C) 2015 Andrey Antukh
+# Copyright (C) 2015 Jesús Espino
+# Copyright (C) 2015 David Barragán
+# Copyright (C) 2015 Anler Hernández
+# 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 .
+
+import pytest
+from django.core.urlresolvers import reverse
+
+from .. import factories as f
+
+pytestmark = pytest.mark.django_db
+
+
+def test_watch_issue(client):
+ user = f.UserFactory.create()
+ issue = f.create_issue(owner=user)
+ f.MembershipFactory.create(project=issue.project, user=user, is_owner=True)
+ url = reverse("issues-watch", args=(issue.id,))
+
+ client.login(user)
+ response = client.post(url)
+
+ assert response.status_code == 200
+
+
+def test_unwatch_issue(client):
+ user = f.UserFactory.create()
+ issue = f.create_issue(owner=user)
+ f.MembershipFactory.create(project=issue.project, user=user, is_owner=True)
+ url = reverse("issues-watch", args=(issue.id,))
+
+ client.login(user)
+ response = client.post(url)
+
+ assert response.status_code == 200
diff --git a/tests/integration/test_watch_milestones.py b/tests/integration/test_watch_milestones.py
new file mode 100644
index 00000000..72fea24d
--- /dev/null
+++ b/tests/integration/test_watch_milestones.py
@@ -0,0 +1,123 @@
+# Copyright (C) 2015 Andrey Antukh
+# Copyright (C) 2015 Jesús Espino
+# Copyright (C) 2015 David Barragán
+# Copyright (C) 2015 Anler Hernández
+# 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 .
+
+import pytest
+import json
+from django.core.urlresolvers import reverse
+
+from .. import factories as f
+
+pytestmark = pytest.mark.django_db
+
+
+def test_watch_milestone(client):
+ user = f.UserFactory.create()
+ milestone = f.MilestoneFactory(owner=user)
+ f.MembershipFactory.create(project=milestone.project, user=user, is_owner=True)
+ url = reverse("milestones-watch", args=(milestone.id,))
+
+ client.login(user)
+ response = client.post(url)
+
+ assert response.status_code == 200
+
+
+def test_unwatch_milestone(client):
+ user = f.UserFactory.create()
+ milestone = f.MilestoneFactory(owner=user)
+ f.MembershipFactory.create(project=milestone.project, user=user, is_owner=True)
+ url = reverse("milestones-watch", args=(milestone.id,))
+
+ client.login(user)
+ response = client.post(url)
+
+ assert response.status_code == 200
+
+
+def test_list_milestone_watchers(client):
+ user = f.UserFactory.create()
+ milestone = f.MilestoneFactory(owner=user)
+ f.MembershipFactory.create(project=milestone.project, user=user, is_owner=True)
+ f.WatchedFactory.create(content_object=milestone, user=user)
+ url = reverse("milestone-watchers-list", args=(milestone.id,))
+
+ client.login(user)
+ response = client.get(url)
+
+ assert response.status_code == 200
+ assert response.data[0]['id'] == user.id
+
+
+def test_get_milestone_watcher(client):
+ user = f.UserFactory.create()
+ milestone = f.MilestoneFactory(owner=user)
+ f.MembershipFactory.create(project=milestone.project, user=user, is_owner=True)
+ watch = f.WatchedFactory.create(content_object=milestone, user=user)
+ url = reverse("milestone-watchers-detail", args=(milestone.id, watch.user.id))
+
+ client.login(user)
+ response = client.get(url)
+
+ assert response.status_code == 200
+ assert response.data['id'] == watch.user.id
+
+
+def test_get_milestone_watchers(client):
+ user = f.UserFactory.create()
+ milestone = f.MilestoneFactory(owner=user)
+ f.MembershipFactory.create(project=milestone.project, user=user, is_owner=True)
+ url = reverse("milestones-detail", args=(milestone.id,))
+
+ f.WatchedFactory.create(content_object=milestone, user=user)
+
+ client.login(user)
+ response = client.get(url)
+
+ assert response.status_code == 200
+ assert response.data['watchers'] == [user.id]
+
+
+def test_get_milestone_is_watched(client):
+ user = f.UserFactory.create()
+ milestone = f.MilestoneFactory(owner=user)
+ f.MembershipFactory.create(project=milestone.project, user=user, is_owner=True)
+ url_detail = reverse("milestones-detail", args=(milestone.id,))
+ url_watch = reverse("milestones-watch", args=(milestone.id,))
+ url_unwatch = reverse("milestones-unwatch", args=(milestone.id,))
+
+ client.login(user)
+
+ response = client.get(url_detail)
+ assert response.status_code == 200
+ assert response.data['watchers'] == []
+ assert response.data['is_watched'] == False
+
+ response = client.post(url_watch)
+ assert response.status_code == 200
+
+ response = client.get(url_detail)
+ assert response.status_code == 200
+ assert response.data['watchers'] == [user.id]
+ assert response.data['is_watched'] == True
+
+ response = client.post(url_unwatch)
+ assert response.status_code == 200
+
+ response = client.get(url_detail)
+ assert response.status_code == 200
+ assert response.data['watchers'] == []
+ assert response.data['is_watched'] == False
diff --git a/tests/integration/test_watch_projects.py b/tests/integration/test_watch_projects.py
new file mode 100644
index 00000000..8bb765ce
--- /dev/null
+++ b/tests/integration/test_watch_projects.py
@@ -0,0 +1,47 @@
+# Copyright (C) 2015 Andrey Antukh
+# Copyright (C) 2015 Jesús Espino
+# Copyright (C) 2015 David Barragán
+# Copyright (C) 2015 Anler Hernández
+# 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 .
+
+import pytest
+from django.core.urlresolvers import reverse
+
+from .. import factories as f
+
+pytestmark = pytest.mark.django_db
+
+
+def test_watch_project(client):
+ user = f.UserFactory.create()
+ project = f.create_project(owner=user)
+ f.MembershipFactory.create(project=project, user=user, is_owner=True)
+ url = reverse("projects-watch", args=(project.id,))
+
+ client.login(user)
+ response = client.post(url)
+
+ assert response.status_code == 200
+
+
+def test_unwacth_project(client):
+ user = f.UserFactory.create()
+ project = f.create_project(owner=user)
+ f.MembershipFactory.create(project=project, user=user, is_owner=True)
+ url = reverse("projects-unwatch", args=(project.id,))
+
+ client.login(user)
+ response = client.post(url)
+
+ assert response.status_code == 200
diff --git a/tests/integration/test_watch_tasks.py b/tests/integration/test_watch_tasks.py
new file mode 100644
index 00000000..f62e4c7b
--- /dev/null
+++ b/tests/integration/test_watch_tasks.py
@@ -0,0 +1,47 @@
+# Copyright (C) 2015 Andrey Antukh
+# Copyright (C) 2015 Jesús Espino
+# Copyright (C) 2015 David Barragán
+# Copyright (C) 2015 Anler Hernández
+# 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 .
+
+import pytest
+from django.core.urlresolvers import reverse
+
+from .. import factories as f
+
+pytestmark = pytest.mark.django_db
+
+
+def test_watch_task(client):
+ user = f.UserFactory.create()
+ task = f.create_task(owner=user)
+ f.MembershipFactory.create(project=task.project, user=user, is_owner=True)
+ url = reverse("tasks-watch", args=(task.id,))
+
+ client.login(user)
+ response = client.post(url)
+
+ assert response.status_code == 200
+
+
+def test_unwatch_task(client):
+ user = f.UserFactory.create()
+ task = f.create_task(owner=user)
+ f.MembershipFactory.create(project=task.project, user=user, is_owner=True)
+ url = reverse("tasks-watch", args=(task.id,))
+
+ client.login(user)
+ response = client.post(url)
+
+ assert response.status_code == 200
diff --git a/tests/integration/test_watch_userstories.py b/tests/integration/test_watch_userstories.py
new file mode 100644
index 00000000..a6a7123e
--- /dev/null
+++ b/tests/integration/test_watch_userstories.py
@@ -0,0 +1,47 @@
+# Copyright (C) 2015 Andrey Antukh
+# Copyright (C) 2015 Jesús Espino
+# Copyright (C) 2015 David Barragán
+# Copyright (C) 2015 Anler Hernández
+# 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 .
+
+import pytest
+from django.core.urlresolvers import reverse
+
+from .. import factories as f
+
+pytestmark = pytest.mark.django_db
+
+
+def test_watch_user_story(client):
+ user = f.UserFactory.create()
+ user_story = f.create_userstory(owner=user)
+ f.MembershipFactory.create(project=user_story.project, user=user, is_owner=True)
+ url = reverse("userstories-watch", args=(user_story.id,))
+
+ client.login(user)
+ response = client.post(url)
+
+ assert response.status_code == 200
+
+
+def test_unwatch_user_story(client):
+ user = f.UserFactory.create()
+ user_story = f.create_userstory(owner=user)
+ f.MembershipFactory.create(project=user_story.project, user=user, is_owner=True)
+ url = reverse("userstories-unwatch", args=(user_story.id,))
+
+ client.login(user)
+ response = client.post(url)
+
+ assert response.status_code == 200
diff --git a/tests/integration/test_watch_wikipages.py b/tests/integration/test_watch_wikipages.py
new file mode 100644
index 00000000..c4f96bb6
--- /dev/null
+++ b/tests/integration/test_watch_wikipages.py
@@ -0,0 +1,123 @@
+# Copyright (C) 2015 Andrey Antukh
+# Copyright (C) 2015 Jesús Espino
+# Copyright (C) 2015 David Barragán
+# Copyright (C) 2015 Anler Hernández
+# 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 .
+
+import pytest
+import json
+from django.core.urlresolvers import reverse
+
+from .. import factories as f
+
+pytestmark = pytest.mark.django_db
+
+
+def test_watch_wikipage(client):
+ user = f.UserFactory.create()
+ wikipage = f.WikiPageFactory(owner=user)
+ f.MembershipFactory.create(project=wikipage.project, user=user, is_owner=True)
+ url = reverse("wiki-watch", args=(wikipage.id,))
+
+ client.login(user)
+ response = client.post(url)
+
+ assert response.status_code == 200
+
+
+def test_unwatch_wikipage(client):
+ user = f.UserFactory.create()
+ wikipage = f.WikiPageFactory(owner=user)
+ f.MembershipFactory.create(project=wikipage.project, user=user, is_owner=True)
+ url = reverse("wiki-watch", args=(wikipage.id,))
+
+ client.login(user)
+ response = client.post(url)
+
+ assert response.status_code == 200
+
+
+def test_list_wikipage_watchers(client):
+ user = f.UserFactory.create()
+ wikipage = f.WikiPageFactory(owner=user)
+ f.MembershipFactory.create(project=wikipage.project, user=user, is_owner=True)
+ f.WatchedFactory.create(content_object=wikipage, user=user)
+ url = reverse("wiki-watchers-list", args=(wikipage.id,))
+
+ client.login(user)
+ response = client.get(url)
+
+ assert response.status_code == 200
+ assert response.data[0]['id'] == user.id
+
+
+def test_get_wikipage_watcher(client):
+ user = f.UserFactory.create()
+ wikipage = f.WikiPageFactory(owner=user)
+ f.MembershipFactory.create(project=wikipage.project, user=user, is_owner=True)
+ watch = f.WatchedFactory.create(content_object=wikipage, user=user)
+ url = reverse("wiki-watchers-detail", args=(wikipage.id, watch.user.id))
+
+ client.login(user)
+ response = client.get(url)
+
+ assert response.status_code == 200
+ assert response.data['id'] == watch.user.id
+
+
+def test_get_wikipage_watchers(client):
+ user = f.UserFactory.create()
+ wikipage = f.WikiPageFactory(owner=user)
+ f.MembershipFactory.create(project=wikipage.project, user=user, is_owner=True)
+ url = reverse("wiki-detail", args=(wikipage.id,))
+
+ f.WatchedFactory.create(content_object=wikipage, user=user)
+
+ client.login(user)
+ response = client.get(url)
+
+ assert response.status_code == 200
+ assert response.data['watchers'] == [user.id]
+
+
+def test_get_wikipage_is_watched(client):
+ user = f.UserFactory.create()
+ wikipage = f.WikiPageFactory(owner=user)
+ f.MembershipFactory.create(project=wikipage.project, user=user, is_owner=True)
+ url_detail = reverse("wiki-detail", args=(wikipage.id,))
+ url_watch = reverse("wiki-watch", args=(wikipage.id,))
+ url_unwatch = reverse("wiki-unwatch", args=(wikipage.id,))
+
+ client.login(user)
+
+ response = client.get(url_detail)
+ assert response.status_code == 200
+ assert response.data['watchers'] == []
+ assert response.data['is_watched'] == False
+
+ response = client.post(url_watch)
+ assert response.status_code == 200
+
+ response = client.get(url_detail)
+ assert response.status_code == 200
+ assert response.data['watchers'] == [user.id]
+ assert response.data['is_watched'] == True
+
+ response = client.post(url_unwatch)
+ assert response.status_code == 200
+
+ response = client.get(url_detail)
+ assert response.status_code == 200
+ assert response.data['watchers'] == []
+ assert response.data['is_watched'] == False