From f3641f5cfbaaf08d97148ef52a4bf1fd6eef9283 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 18 Aug 2015 09:33:23 +0200 Subject: [PATCH] Refactoring and improving watchers --- CHANGELOG.md | 2 + taiga/base/fields.py | 9 ++ taiga/base/filters.py | 28 ++++ taiga/export_import/serializers.py | 63 +++++++-- taiga/export_import/service.py | 6 + taiga/permissions/service.py | 5 +- taiga/projects/api.py | 6 +- taiga/projects/apps.py | 8 +- taiga/projects/history/freeze_impl.py | 8 +- taiga/projects/issues/api.py | 10 +- .../migrations/0006_remove_issue_watchers.py | 29 ++++ taiga/projects/issues/permissions.py | 2 + taiga/projects/issues/serializers.py | 3 +- .../migrations/0024_auto_20150810_1247.py | 29 ++++ .../0025_remove_project_watchers.py | 28 ++++ taiga/projects/milestones/api.py | 4 +- .../0002_remove_milestone_watchers.py | 29 ++++ taiga/projects/milestones/serializers.py | 4 +- taiga/projects/models.py | 4 +- .../notifications/migrations/0004_watched.py | 35 +++++ taiga/projects/notifications/mixins.py | 125 ++++++++++++++++-- taiga/projects/notifications/models.py | 17 +++ taiga/projects/notifications/services.py | 53 +++++++- taiga/projects/notifications/utils.py | 63 +++++++++ taiga/projects/notifications/validators.py | 5 +- taiga/projects/permissions.py | 2 + taiga/projects/serializers.py | 5 +- taiga/projects/signals.py | 18 --- taiga/projects/tasks/api.py | 8 +- .../migrations/0008_remove_task_watchers.py | 29 ++++ taiga/projects/tasks/permissions.py | 2 + taiga/projects/tasks/serializers.py | 3 +- taiga/projects/userstories/api.py | 13 +- .../0010_remove_userstory_watchers.py | 29 ++++ taiga/projects/userstories/permissions.py | 3 +- taiga/projects/userstories/serializers.py | 3 +- taiga/projects/votes/serializers.py | 2 + .../0002_remove_wikipage_watchers.py | 30 +++++ taiga/projects/wiki/serializers.py | 7 +- taiga/timeline/signals.py | 2 +- .../migrations/0012_auto_20150812_1142.py | 21 +++ taiga/webhooks/serializers.py | 12 +- .../test_issues_resources.py | 42 ++++++ .../test_projects_resource.py | 38 ++++++ .../test_tasks_resources.py | 42 ++++++ .../test_userstories_resources.py | 42 ++++++ tests/integration/test_importer_api.py | 29 +++- tests/integration/test_notifications.py | 18 +-- tests/integration/test_projects.py | 2 +- tests/integration/test_timeline.py | 10 -- tests/integration/test_watch_issues.py | 47 +++++++ tests/integration/test_watch_milestones.py | 123 +++++++++++++++++ tests/integration/test_watch_projects.py | 47 +++++++ tests/integration/test_watch_tasks.py | 47 +++++++ tests/integration/test_watch_userstories.py | 47 +++++++ tests/integration/test_watch_wikipages.py | 123 +++++++++++++++++ 56 files changed, 1304 insertions(+), 117 deletions(-) create mode 100644 taiga/projects/issues/migrations/0006_remove_issue_watchers.py create mode 100644 taiga/projects/migrations/0024_auto_20150810_1247.py create mode 100644 taiga/projects/migrations/0025_remove_project_watchers.py create mode 100644 taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py create mode 100644 taiga/projects/notifications/migrations/0004_watched.py create mode 100644 taiga/projects/notifications/utils.py create mode 100644 taiga/projects/tasks/migrations/0008_remove_task_watchers.py create mode 100644 taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py create mode 100644 taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py create mode 100644 taiga/users/migrations/0012_auto_20150812_1142.py create mode 100644 tests/integration/test_watch_issues.py create mode 100644 tests/integration/test_watch_milestones.py create mode 100644 tests/integration/test_watch_projects.py create mode 100644 tests/integration/test_watch_tasks.py create mode 100644 tests/integration/test_watch_userstories.py create mode 100644 tests/integration/test_watch_wikipages.py 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