Browse Source

feat(notifications): Add the ability to disable workflow notifications (#6361)

ted kaemming 7 years ago
parent
commit
5a4d58f08f

+ 1 - 0
CHANGES

@@ -6,6 +6,7 @@ Version 8.22 (Unreleased)
 - Add support for SAML2 authentication through identity providers that
   implement the ``SAML2AuthProvider``. See getsentry/sentry-auth-saml2.
 - BREAKING: Group share urls have all been invalidated and must be regenerated.
+- Added the ability for users to disable workflow notifications on a per-project basis.
 
 Schema Changes
 ~~~~~~~~~~~~~~

+ 80 - 53
src/sentry/api/serializers/models/group.py

@@ -1,8 +1,8 @@
 from __future__ import absolute_import, print_function
 
+import itertools
 from collections import defaultdict
 from datetime import timedelta
-from itertools import izip
 
 import six
 from django.core.urlresolvers import reverse
@@ -30,6 +30,9 @@ SUBSCRIPTION_REASON_MAP = {
 }
 
 
+disabled = object()
+
+
 @register(Group)
 class GroupSerializer(Serializer):
     def _get_subscriptions(self, item_list, user):
@@ -38,56 +41,70 @@ class GroupSerializer(Serializer):
         subscription: GroupSubscription or None) for the provided user and
         groups.
         """
-        results = {group.id: None for group in item_list}
-
-        # First, the easy part -- if there is a subscription record associated
-        # with the group, we can just use that to know if a user is subscribed
-        # or not.
-        subscriptions = GroupSubscription.objects.filter(
-            group__in=results.keys(),
-            user=user,
-        )
+        if not item_list:
+            return {}
 
-        for subscription in subscriptions:
-            results[subscription.group_id] = (subscription.is_active, subscription)
-
-        # For any group that doesn't have a subscription associated with it,
-        # we'll need to fall back to the project's option value, so here we
-        # collect all of the projects to look up, and keep a set of groups that
+        # Collect all of the projects to look up, and keep a set of groups that
         # are part of that project. (Note that the common -- but not only --
         # case here is that all groups are part of the same project.)
         projects = defaultdict(set)
         for group in item_list:
-            if results[group.id] is None:
-                projects[group.project].add(group.id)
-
-        if projects:
-            # NOTE: This doesn't use `values_list` because that bypasses field
-            # value decoding, so the `value` field would not be unpickled.
-            options = {
-                option.project_id: option.value
-                for option in UserOption.objects.filter(
-                    Q(project__in=projects.keys()) | Q(project__isnull=True),
-                    user=user,
-                    key='workflow:notifications',
-                )
-            }
+            projects[group.project].add(group)
+
+        # Fetch the options for each project -- we'll need this to identify if
+        # a user has totally disabled workflow notifications for a project.
+        # NOTE: This doesn't use `values_list` because that bypasses field
+        # value decoding, so the `value` field would not be unpickled.
+        options = {
+            option.project_id: option.value
+            for option in
+            UserOption.objects.filter(
+                Q(project__in=projects.keys()) | Q(project__isnull=True),
+                user=user,
+                key='workflow:notifications',
+            )
+        }
 
-            # This is the user's default value for any projects that don't have
-            # the option value specifically recorded. (The default "all
-            # conversations" value is convention.)
-            default = options.get(None, UserOptionValue.all_conversations)
-
-            # If you're subscribed to all notifications for the project, that
-            # means you're subscribed to all of the groups. Otherwise you're
-            # not subscribed to any of these leftover groups.
-            for project, group_ids in projects.items():
-                is_subscribed = options.get(
-                    project.id,
-                    default,
-                ) == UserOptionValue.all_conversations
-                for group_id in group_ids:
-                    results[group_id] = (is_subscribed, None)
+        # If there is a subscription record associated with the group, we can
+        # just use that to know if a user is subscribed or not, as long as
+        # notifications aren't disabled for the project.
+        subscriptions = {
+            subscription.group_id: subscription
+            for subscription in
+            GroupSubscription.objects.filter(
+                group__in=list(
+                    itertools.chain.from_iterable(
+                        itertools.imap(
+                            lambda (project, groups): groups if not options.get(
+                                project.id,
+                                options.get(None)
+                            ) == UserOptionValue.no_conversations else [],
+                            projects.items(),
+                        ),
+                    )
+                ),
+                user=user,
+            )
+        }
+
+        # This is the user's default value for any projects that don't have
+        # the option value specifically recorded. (The default "all
+        # conversations" value is convention.)
+        global_default_workflow_option = options.get(None, UserOptionValue.all_conversations)
+
+        results = {}
+        for project, groups in projects.items():
+            project_default_workflow_option = options.get(
+                project.id, global_default_workflow_option)
+            for group in groups:
+                subscription = subscriptions.get(group.id)
+                if subscription is not None:
+                    results[group.id] = (subscription.is_active, subscription)
+                else:
+                    results[group.id] = (
+                        project_default_workflow_option == UserOptionValue.all_conversations,
+                        None,
+                    ) if project_default_workflow_option != UserOptionValue.no_conversations else disabled
 
         return results
 
@@ -148,7 +165,7 @@ class GroupSerializer(Serializer):
                 id__in=actor_ids,
                 is_active=True,
             ))
-            actors = {u.id: d for u, d in izip(users, serialize(users, user))}
+            actors = {u.id: d for u, d in itertools.izip(users, serialize(users, user))}
         else:
             actors = {}
 
@@ -254,7 +271,22 @@ class GroupSerializer(Serializer):
         else:
             permalink = None
 
-        is_subscribed, subscription = attrs['subscription']
+        subscription_details = None
+        if attrs['subscription'] is not disabled:
+            is_subscribed, subscription = attrs['subscription']
+            if subscription is not None and subscription.is_active:
+                subscription_details = {
+                    'reason': SUBSCRIPTION_REASON_MAP.get(
+                        subscription.reason,
+                        'unknown',
+                    ),
+                }
+        else:
+            is_subscribed = False
+            subscription_details = {
+                'disabled': True,
+            }
+
         share_id = attrs['share_id']
 
         return {
@@ -283,12 +315,7 @@ class GroupSerializer(Serializer):
             'assignedTo': attrs['assigned_to'],
             'isBookmarked': attrs['is_bookmarked'],
             'isSubscribed': is_subscribed,
-            'subscriptionDetails': {
-                'reason': SUBSCRIPTION_REASON_MAP.get(
-                    subscription.reason,
-                    'unknown',
-                ),
-            } if is_subscribed and subscription is not None else None,
+            'subscriptionDetails': subscription_details,
             'hasSeen': attrs['has_seen'],
             'annotations': attrs['annotations'],
         }

+ 67 - 50
src/sentry/models/groupsubscription.py

@@ -46,6 +46,33 @@ class GroupSubscriptionReason(object):
     }
 
 
+def get_user_options(key, user_ids, project, default):
+    from sentry.models import UserOption
+
+    options = {
+        (option.user_id, option.project_id): option.value
+        for option in
+        UserOption.objects.filter(
+            Q(project__isnull=True) | Q(project=project),
+            user_id__in=user_ids,
+            key='workflow:notifications',
+        )
+    }
+
+    results = {}
+
+    for user_id in user_ids:
+        results[user_id] = options.get(
+            (user_id, project.id),
+            options.get(
+                (user_id, None),
+                default,
+            ),
+        )
+
+    return results
+
+
 class GroupSubscriptionManager(BaseManager):
     def subscribe(self, group, user, reason=GroupSubscriptionReason.unknown):
         """
@@ -68,67 +95,57 @@ class GroupSubscriptionManager(BaseManager):
         """
         Identify all users who are participating with a given issue.
         """
-        from sentry.models import User, UserOption, UserOptionValue
-
-        # Identify all members of a project -- we'll use this to start figuring
-        # out who could possibly be associated with this group due to implied
-        # subscriptions.
-        users = User.objects.filter(
-            sentry_orgmember_set__teams=group.project.team,
-            is_active=True,
-        )
+        from sentry.models import User, UserOptionValue
 
-        # Obviously, users who have explicitly unsubscribed from this issue
-        # aren't considered participants.
-        users = users.exclude(
-            id__in=GroupSubscription.objects.filter(
-                group=group,
-                is_active=False,
-                user__in=users,
-            ).values('user')
-        )
+        users = {
+            user.id: user
+            for user in
+            User.objects.filter(
+                sentry_orgmember_set__teams=group.project.team,
+                is_active=True,
+            )
+        }
+
+        excluded_ids = set()
 
-        # Fetch all of the users that have been explicitly associated with this
-        # issue.
-        participants = {
-            subscription.user: subscription.reason
-            for subscription in GroupSubscription.objects.filter(
+        subscriptions = {
+            subscription.user_id: subscription
+            for subscription in
+            GroupSubscription.objects.filter(
                 group=group,
-                is_active=True,
-                user__in=users,
-            ).select_related('user')
+                user_id__in=users.keys(),
+            )
         }
 
-        # Find users which by default do not subscribe.
-        participating_only = set(
-            uo.user_id
-            for uo in UserOption.objects.filter(
-                Q(project__isnull=True) | Q(project=group.project),
-                user__in=users,
-                key='workflow:notifications',
-            ).exclude(
-                user__in=[
-                    uo.user_id for uo in UserOption.objects.filter(
-                        project=group.project,
-                        user__in=users,
-                        key='workflow:notifications',
-                    ) if uo.value == UserOptionValue.all_conversations
-                ]
-            ) if uo.value == UserOptionValue.participating_only
+        for user_id, subscription in subscriptions.items():
+            if not subscription.is_active:
+                excluded_ids.add(user_id)
+
+        options = get_user_options(
+            'workflow:notifications',
+            users.keys(),
+            group.project,
+            UserOptionValue.all_conversations,
         )
 
-        if participating_only:
-            excluded = participating_only.difference(participants.keys())
-            if excluded:
-                users = users.exclude(id__in=excluded)
+        for user_id, option in options.items():
+            if option == UserOptionValue.no_conversations:
+                excluded_ids.add(user_id)
+            elif option == UserOptionValue.participating_only:
+                if user_id not in subscriptions:
+                    excluded_ids.add(user_id)
 
         results = {}
 
-        for user in users:
-            results[user] = GroupSubscriptionReason.implicit
+        for user_id, user in users.items():
+            if user_id in excluded_ids:
+                continue
 
-        for user, reason in participants.items():
-            results[user] = reason
+            subscription = subscriptions.get(user_id)
+            if subscription is not None:
+                results[user] = subscription.reason
+            else:
+                results[user] = GroupSubscriptionReason.implicit
 
         return results
 

+ 1 - 11
src/sentry/models/project.py

@@ -8,9 +8,9 @@ sentry.models.project
 from __future__ import absolute_import, print_function
 
 import logging
-import six
 import warnings
 
+import six
 from bitfield import BitField
 from django.conf import settings
 from django.db import IntegrityError, models, transaction
@@ -327,16 +327,6 @@ class Project(Model):
             is_enabled = bool(is_enabled)
         return is_enabled
 
-    def is_user_subscribed_to_workflow(self, user):
-        from sentry.models import UserOption, UserOptionValue
-
-        opt_value = UserOption.objects.get_value(user, 'workflow:notifications', project=self)
-        if opt_value is None:
-            opt_value = UserOption.objects.get_value(
-                user, 'workflow:notifications', UserOptionValue.all_conversations
-            )
-        return opt_value == UserOptionValue.all_conversations
-
     def transfer_to(self, team):
         from sentry.models import ReleaseProject
 

+ 2 - 1
src/sentry/models/useroption.py

@@ -8,8 +8,8 @@ sentry.models.useroption
 from __future__ import absolute_import, print_function
 
 from celery.signals import task_postrun
-from django.core.signals import request_finished
 from django.conf import settings
+from django.core.signals import request_finished
 from django.db import models
 
 from sentry.db.models import FlexibleForeignKey, Model, sane_repr
@@ -21,6 +21,7 @@ class UserOptionValue(object):
     # 'workflow:notifications'
     all_conversations = '0'
     participating_only = '1'
+    no_conversations = '2'
     # 'deploy-emails
     all_deploys = '2'
     committed_deploys_only = '3'

+ 19 - 8
src/sentry/static/sentry/app/components/group/sidebar.jsx

@@ -129,6 +129,10 @@ const GroupSidebar = React.createClass({
     return null;
   },
 
+  canChangeSubscriptionState() {
+    return !(this.getGroup().subscriptionDetails || {disabled: false}).disabled;
+  },
+
   getNotificationText() {
     let group = this.getGroup();
 
@@ -151,7 +155,13 @@ const GroupSidebar = React.createClass({
       }
       return result;
     } else {
-      return t("You're not subscribed to this issue.");
+      if (group.subscriptionDetails && group.subscriptionDetails.disabled) {
+        return tct('You have [link:disabled workflow notifications] for this project.', {
+          link: <a href="/account/settings/notifications/" />
+        });
+      } else {
+        return t("You're not subscribed to this issue.");
+      }
     }
   },
 
@@ -211,13 +221,14 @@ const GroupSidebar = React.createClass({
 
         <h6><span>{t('Notifications')}</span></h6>
         <p className="help-block">{this.getNotificationText()}</p>
-        <a
-          className={`btn btn-default btn-subscribe ${group.isSubscribed && 'subscribed'}`}
-          onClick={this.toggleSubscription}>
-          <span className="icon-signal" />
-          {' '}
-          {group.isSubscribed ? t('Unsubscribe') : t('Subscribe')}
-        </a>
+        {this.canChangeSubscriptionState() &&
+          <a
+            className={`btn btn-default btn-subscribe ${group.isSubscribed && 'subscribed'}`}
+            onClick={this.toggleSubscription}>
+            <span className="icon-signal" />
+            {' '}
+            {group.isSubscribed ? t('Unsubscribe') : t('Subscribe')}
+          </a>}
       </div>
     );
   }

+ 11 - 21
src/sentry/templates/sentry/account/notifications.html

@@ -64,19 +64,20 @@
 
         <h4>{% trans "Workflow" %}</h4>
 
-        <p>{% blocktrans %}Workflow notifications are separate from alerts and are generated for issue updates, such as:{% endblocktrans %}</p>
-
-        <ul>
-          <li>{% trans "Assignment" %}</li>
-          <li>{% trans "Comments" %}</li>
-          <li>{% trans "Regressions" %}</li>
-          <li>{% trans "Resolution" %}</li>
-        </ul>
-
+        <p>
+          {% blocktrans %}
+            Workflow notifications are separate from alerts and are generated
+            for issue updates, such as changes in issue assignment, changes to
+            resolution status (including regressions), and comments.
+          {% endblocktrans %}
+        </p>
         <p>
           {% blocktrans %}
             When workflow notifications are enabled for a project, you'll
             receive an email when your teammates perform any of these actions.
+            You'll be automatically added as a participant on an issue by
+            taking one of the actions listed above. You may subscribe (or
+            unsubscribe) from individual issues on their respective pages.
           {% endblocktrans %}
         </p>
 
@@ -86,16 +87,6 @@
 
         {{ settings_form.self_notifications|as_crispy_field }}
 
-        <p>
-          {% blocktrans %}
-            You'll always receive notifications from issues that you're
-            subscribed to. You may subscribe (or unsubscribe) from individual
-            issues on their respective pages. You'll be automatically
-            subscribed when participating on an issue by taking one of the
-            actions listed above.
-          {% endblocktrans %}
-        </p>
-
         <hr />
 
         <h4>{% trans "Weekly Reports" %}</h4>
@@ -150,7 +141,7 @@
                               <a data-toggle="alert">Alerts</a>
                             </th>
                             <th style="width:50px;text-align:center">
-                              <a data-toggle="workflow">Workflow</a>
+                              {% trans "Workflow" %}
                             </th>
                         </tr>
                     </thead>
@@ -217,7 +208,6 @@
   }
 
   setupChecker('alert');
-  setupChecker('workflow');
 });
 </script>
 {% endblock %}

+ 42 - 27
src/sentry/web/forms/accounts.py

@@ -585,13 +585,18 @@ class NotificationSettingsForm(forms.Form):
         required=False,
     )
 
-    workflow_notifications = forms.BooleanField(
-        label=_('Automatically subscribe to workflow notifications for new projects'),
-        help_text=_(
-            "When enabled, you'll automatically subscribe to workflow notifications when you create or join a project."
-        ),
+    workflow_notifications = forms.ChoiceField(
+        label=_('Preferred workflow subscription level for new projects'),
+        choices=[
+            (UserOptionValue.all_conversations, "Receive workflow updates for all issues."),
+            (UserOptionValue.participating_only,
+             "Receive workflow updates only for issues that I am participating in or have subscribed to."),
+            (UserOptionValue.no_conversations, "Never receive workflow updates."),
+        ],
+        help_text=_("This will be automatically set as your subscription preference when you create or join a project. It has no effect on existing projects."),
         required=False,
     )
+
     self_notifications = forms.BooleanField(
         label=_('Receive notifications about my own activity'),
         help_text=_(
@@ -625,12 +630,11 @@ class NotificationSettingsForm(forms.Form):
             ) == '1'
         )
 
-        self.fields['workflow_notifications'].initial = (
-            UserOption.objects.get_value(
-                user=self.user,
-                key='workflow:notifications',
-                default=UserOptionValue.all_conversations,
-            ) == UserOptionValue.all_conversations
+        self.fields['workflow_notifications'].initial = UserOption.objects.get_value(
+            user=self.user,
+            key='workflow:notifications',
+            default=UserOptionValue.all_conversations,
+            project=None,
         )
 
         self.fields['self_notifications'].initial = UserOption.objects.get_value(
@@ -663,30 +667,33 @@ class NotificationSettingsForm(forms.Form):
             value='1' if self.cleaned_data['self_notifications'] else '0',
         )
 
-        UserOption.objects.set_value(
-            user=self.user,
-            key='self_assign_issue',
-            value='1' if self.cleaned_data['self_assign_issue'] else '0',
-        )
-
-        if self.cleaned_data.get('workflow_notifications') is True:
-            UserOption.objects.set_value(
+        workflow_notifications_value = self.cleaned_data.get('workflow_notifications')
+        if not workflow_notifications_value:
+            UserOption.objects.unset_value(
                 user=self.user,
                 key='workflow:notifications',
-                value=UserOptionValue.all_conversations,
+                project=None,
             )
         else:
             UserOption.objects.set_value(
                 user=self.user,
                 key='workflow:notifications',
-                value=UserOptionValue.participating_only,
+                value=workflow_notifications_value,
+                project=None,
             )
 
 
 class ProjectEmailOptionsForm(forms.Form):
     alert = forms.BooleanField(required=False)
-    workflow = forms.BooleanField(required=False)
-    email = forms.ChoiceField(label="", choices=(), required=False, widget=forms.Select())
+    workflow = forms.ChoiceField(
+        choices=[
+            (UserOptionValue.no_conversations, 'Nothing'),
+            (UserOptionValue.participating_only, 'Participating'),
+            (UserOptionValue.all_conversations, 'Everything'),
+        ],
+    )
+    email = forms.ChoiceField(label="", choices=(), required=False,
+                              widget=forms.Select())
 
     def __init__(self, project, user, *args, **kwargs):
         self.project = project
@@ -695,7 +702,6 @@ class ProjectEmailOptionsForm(forms.Form):
         super(ProjectEmailOptionsForm, self).__init__(*args, **kwargs)
 
         has_alerts = project.is_user_subscribed_to_mail_alerts(user)
-        has_workflow = project.is_user_subscribed_to_workflow(user)
 
         # This allows users who have entered an alert_email value or have specified an email
         # for notifications to keep their settings
@@ -709,7 +715,17 @@ class ProjectEmailOptionsForm(forms.Form):
         self.fields['email'].choices = choices
 
         self.fields['alert'].initial = has_alerts
-        self.fields['workflow'].initial = has_workflow
+        self.fields['workflow'].initial = UserOption.objects.get_value(
+            user=self.user,
+            project=self.project,
+            key='workflow:notifications',
+            default=UserOption.objects.get_value(
+                user=self.user,
+                project=None,
+                key='workflow:notifications',
+                default=UserOptionValue.all_conversations,
+            ),
+        )
         self.fields['email'].initial = specified_email or alert_email or user.email
 
     def save(self):
@@ -723,8 +739,7 @@ class ProjectEmailOptionsForm(forms.Form):
         UserOption.objects.set_value(
             user=self.user,
             key='workflow:notifications',
-            value=UserOptionValue.all_conversations
-            if self.cleaned_data['workflow'] else UserOptionValue.participating_only,
+            value=self.cleaned_data['workflow'],
             project=self.project,
         )
 

+ 77 - 13
tests/sentry/api/serializers/test_group.py

@@ -5,12 +5,14 @@ from __future__ import absolute_import
 import six
 
 from datetime import timedelta
+
 from django.utils import timezone
 from mock import patch
 
 from sentry.api.serializers import serialize
 from sentry.models import (
-    GroupResolution, GroupSnooze, GroupSubscription, GroupStatus, UserOption, UserOptionValue
+    GroupResolution, GroupSnooze, GroupStatus,
+    GroupSubscription, UserOption, UserOptionValue
 )
 from sentry.testutils import TestCase
 
@@ -145,6 +147,9 @@ class GroupSerializerTest(TestCase):
 
         result = serialize(group, user)
         assert result['isSubscribed']
+        assert result['subscriptionDetails'] == {
+            'reason': 'unknown',
+        }
 
     def test_explicit_unsubscribed(self):
         user = self.create_user()
@@ -159,18 +164,30 @@ class GroupSerializerTest(TestCase):
 
         result = serialize(group, user)
         assert not result['isSubscribed']
+        assert not result['subscriptionDetails']
 
     def test_implicit_subscribed(self):
         user = self.create_user()
         group = self.create_group()
 
         combinations = (
-            ((None, None), True), ((UserOptionValue.all_conversations, None), True),
-            ((UserOptionValue.all_conversations, UserOptionValue.all_conversations), True),
-            ((UserOptionValue.all_conversations, UserOptionValue.participating_only),
-             False), ((UserOptionValue.participating_only, None), False),
-            ((UserOptionValue.participating_only, UserOptionValue.all_conversations), True),
-            ((UserOptionValue.participating_only, UserOptionValue.participating_only), False),
+            # ((default, project), (subscribed, details))
+            ((None, None), (True, None)),
+            ((UserOptionValue.all_conversations, None), (True, None)),
+            ((UserOptionValue.all_conversations, UserOptionValue.all_conversations), (True, None)),
+            ((UserOptionValue.all_conversations, UserOptionValue.participating_only), (False, None)),
+            ((UserOptionValue.all_conversations, UserOptionValue.no_conversations),
+             (False, {'disabled': True})),
+            ((UserOptionValue.participating_only, None), (False, None)),
+            ((UserOptionValue.participating_only, UserOptionValue.all_conversations), (True, None)),
+            ((UserOptionValue.participating_only, UserOptionValue.participating_only), (False, None)),
+            ((UserOptionValue.participating_only, UserOptionValue.no_conversations),
+             (False, {'disabled': True})),
+            ((UserOptionValue.no_conversations, None), (False, {'disabled': True})),
+            ((UserOptionValue.no_conversations, UserOptionValue.all_conversations), (True, None)),
+            ((UserOptionValue.no_conversations, UserOptionValue.participating_only), (False, None)),
+            ((UserOptionValue.no_conversations, UserOptionValue.no_conversations),
+             (False, {'disabled': True})),
         )
 
         def maybe_set_value(project, value):
@@ -188,15 +205,62 @@ class GroupSerializerTest(TestCase):
                     key='workflow:notifications',
                 )
 
-        for options, expected_result in combinations:
-            UserOption.objects.clear_cache()
+        for options, (is_subscribed, subscription_details) in combinations:
             default_value, project_value = options
+            UserOption.objects.clear_cache()
             maybe_set_value(None, default_value)
             maybe_set_value(group.project, project_value)
-            assert serialize(group, user
-                             )['isSubscribed'] is expected_result, 'expected {!r} for {!r}'.format(
-                                 expected_result, options
-                             )  # noqa
+            result = serialize(group, user)
+            assert result['isSubscribed'] is is_subscribed
+            assert result.get('subscriptionDetails') == subscription_details
+
+    def test_global_no_conversations_overrides_group_subscription(self):
+        user = self.create_user()
+        group = self.create_group()
+
+        GroupSubscription.objects.create(
+            user=user,
+            group=group,
+            project=group.project,
+            is_active=True,
+        )
+
+        UserOption.objects.set_value(
+            user=user,
+            project=None,
+            key='workflow:notifications',
+            value=UserOptionValue.no_conversations,
+        )
+
+        result = serialize(group, user)
+        assert not result['isSubscribed']
+        assert result['subscriptionDetails'] == {
+            'disabled': True,
+        }
+
+    def test_project_no_conversations_overrides_group_subscription(self):
+        user = self.create_user()
+        group = self.create_group()
+
+        GroupSubscription.objects.create(
+            user=user,
+            group=group,
+            project=group.project,
+            is_active=True,
+        )
+
+        UserOption.objects.set_value(
+            user=user,
+            project=group.project,
+            key='workflow:notifications',
+            value=UserOptionValue.no_conversations,
+        )
+
+        result = serialize(group, user)
+        assert not result['isSubscribed']
+        assert result['subscriptionDetails'] == {
+            'disabled': True,
+        }
 
     def test_no_user_unsubscribed(self):
         group = self.create_group()

+ 281 - 6
tests/sentry/models/test_groupsubscription.py

@@ -1,6 +1,11 @@
 from __future__ import absolute_import
 
-from sentry.models import (GroupSubscription, GroupSubscriptionReason, UserOption, UserOptionValue)
+import functools
+import itertools
+
+from sentry.models import (
+    GroupSubscription, GroupSubscriptionReason, UserOption, UserOptionValue
+)
 from sentry.testutils import TestCase
 
 
@@ -81,24 +86,294 @@ class GetParticipantsTest(TestCase):
             user: GroupSubscriptionReason.comment,
         }
 
-    def test_excludes_project_participating_only(self):
+    def test_no_conversations(self):
         org = self.create_organization()
         team = self.create_team(organization=org)
         project = self.create_project(team=team, organization=org)
         group = self.create_group(project=project)
-        user = self.create_user('foo@example.com')
+        user = self.create_user()
         self.create_member(user=user, organization=org, teams=[team])
 
-        UserOption.objects.set_value(
+        user_option_sequence = itertools.count(300)  # prevent accidental overlap with user id
+
+        def clear_workflow_options():
+            UserOption.objects.filter(
+                user=user,
+                key='workflow:notifications',
+            ).delete()
+
+        get_participants = functools.partial(
+            GroupSubscription.objects.get_participants,
+            group,
+        )
+
+        # Implicit subscription, ensure the project setting overrides the
+        # default global option.
+
+        with self.assertChanges(get_participants,
+                                before={user: GroupSubscriptionReason.implicit},
+                                after={}):
+            UserOption.objects.create(
+                id=next(user_option_sequence),
+                user=user,
+                project=project,
+                key='workflow:notifications',
+                value=UserOptionValue.no_conversations,
+            )
+
+        clear_workflow_options()
+
+        # Implicit subscription, ensure the project setting overrides the
+        # explicit global option.
+
+        UserOption.objects.create(
+            id=next(user_option_sequence),
             user=user,
+            project=None,
+            key='workflow:notifications',
+            value=UserOptionValue.all_conversations,
+        )
+
+        with self.assertChanges(get_participants,
+                                before={user: GroupSubscriptionReason.implicit},
+                                after={}):
+            UserOption.objects.create(
+                id=next(user_option_sequence),
+                user=user,
+                project=project,
+                key='workflow:notifications',
+                value=UserOptionValue.no_conversations,
+            )
+
+        clear_workflow_options()
+
+        # Explicit subscription, overridden by the global option.
+
+        GroupSubscription.objects.create(
+            user=user,
+            group=group,
             project=project,
+            is_active=True,
+            reason=GroupSubscriptionReason.comment,
+        )
+
+        with self.assertChanges(get_participants,
+                                before={user: GroupSubscriptionReason.comment},
+                                after={}):
+            UserOption.objects.create(
+                id=next(user_option_sequence),
+                user=user,
+                project=None,
+                key='workflow:notifications',
+                value=UserOptionValue.no_conversations,
+            )
+
+        clear_workflow_options()
+
+        # Explicit subscription, overridden by the project option.
+
+        UserOption.objects.create(
+            id=next(user_option_sequence),
+            user=user,
+            project=None,
             key='workflow:notifications',
             value=UserOptionValue.participating_only,
         )
 
-        users = GroupSubscription.objects.get_participants(group=group)
+        with self.assertChanges(get_participants,
+                                before={user: GroupSubscriptionReason.comment},
+                                after={}):
+            UserOption.objects.create(
+                id=next(user_option_sequence),
+                user=user,
+                project=project,
+                key='workflow:notifications',
+                value=UserOptionValue.no_conversations,
+            )
+
+        clear_workflow_options()
+
+        # Explicit subscription, overridden by the project option which also
+        # overrides the default option.
+
+        with self.assertChanges(get_participants,
+                                before={user: GroupSubscriptionReason.comment},
+                                after={}):
+            UserOption.objects.create(
+                id=next(user_option_sequence),
+                user=user,
+                project=project,
+                key='workflow:notifications',
+                value=UserOptionValue.no_conversations,
+            )
+
+    def test_participating_only(self):
+        org = self.create_organization()
+        team = self.create_team(organization=org)
+        project = self.create_project(team=team, organization=org)
+        group = self.create_group(project=project)
+        user = self.create_user()
+        self.create_member(user=user, organization=org, teams=[team])
 
-        assert users == {}
+        user_option_sequence = itertools.count(300)  # prevent accidental overlap with user id
+
+        def clear_workflow_options():
+            UserOption.objects.filter(
+                user=user,
+                key='workflow:notifications',
+            ).delete()
+
+        get_participants = functools.partial(
+            GroupSubscription.objects.get_participants,
+            group,
+        )
+
+        # Implicit subscription, ensure the project setting overrides the
+        # default global option.
+
+        with self.assertChanges(get_participants,
+                                before={user: GroupSubscriptionReason.implicit},
+                                after={}):
+            UserOption.objects.create(
+                id=next(user_option_sequence),
+                user=user,
+                project=project,
+                key='workflow:notifications',
+                value=UserOptionValue.participating_only,
+            )
+
+        clear_workflow_options()
+
+        # Implicit subscription, ensure the project setting overrides the
+        # explicit global option.
+
+        UserOption.objects.create(
+            id=next(user_option_sequence),
+            user=user,
+            project=None,
+            key='workflow:notifications',
+            value=UserOptionValue.all_conversations,
+        )
+
+        with self.assertChanges(get_participants,
+                                before={user: GroupSubscriptionReason.implicit},
+                                after={}):
+            UserOption.objects.create(
+                id=next(user_option_sequence),
+                user=user,
+                project=project,
+                key='workflow:notifications',
+                value=UserOptionValue.no_conversations,
+            )
+
+        clear_workflow_options()
+
+        # Ensure the global default is applied.
+
+        UserOption.objects.create(
+            id=next(user_option_sequence),
+            user=user,
+            project=None,
+            key='workflow:notifications',
+            value=UserOptionValue.participating_only,
+        )
+
+        with self.assertChanges(get_participants,
+                                before={},
+                                after={user: GroupSubscriptionReason.comment}):
+            subscription = GroupSubscription.objects.create(
+                user=user,
+                group=group,
+                project=project,
+                is_active=True,
+                reason=GroupSubscriptionReason.comment,
+            )
+
+        subscription.delete()
+        clear_workflow_options()
+
+        # Ensure the project setting overrides the global default.
+
+        UserOption.objects.create(
+            id=next(user_option_sequence),
+            user=user,
+            project=group.project,
+            key='workflow:notifications',
+            value=UserOptionValue.participating_only,
+        )
+
+        with self.assertChanges(get_participants,
+                                before={},
+                                after={user: GroupSubscriptionReason.comment}):
+            subscription = GroupSubscription.objects.create(
+                user=user,
+                group=group,
+                project=project,
+                is_active=True,
+                reason=GroupSubscriptionReason.comment,
+            )
+
+        subscription.delete()
+        clear_workflow_options()
+
+        # Ensure the project setting overrides the global setting.
+
+        UserOption.objects.create(
+            id=next(user_option_sequence),
+            user=user,
+            project=None,
+            key='workflow:notifications',
+            value=UserOptionValue.all_conversations,
+        )
+
+        UserOption.objects.create(
+            id=next(user_option_sequence),
+            user=user,
+            project=group.project,
+            key='workflow:notifications',
+            value=UserOptionValue.participating_only,
+        )
+
+        with self.assertChanges(get_participants,
+                                before={},
+                                after={user: GroupSubscriptionReason.comment}):
+            subscription = GroupSubscription.objects.create(
+                user=user,
+                group=group,
+                project=project,
+                is_active=True,
+                reason=GroupSubscriptionReason.comment,
+            )
+
+        subscription.delete()
+        clear_workflow_options()
+
+        UserOption.objects.create(
+            id=next(user_option_sequence),
+            user=user,
+            project=None,
+            key='workflow:notifications',
+            value=UserOptionValue.participating_only,
+        )
+
+        UserOption.objects.create(
+            id=next(user_option_sequence),
+            user=user,
+            project=group.project,
+            key='workflow:notifications',
+            value=UserOptionValue.all_conversations,
+        )
+
+        with self.assertChanges(get_participants,
+                                before={user: GroupSubscriptionReason.implicit},
+                                after={user: GroupSubscriptionReason.comment}):
+            subscription = GroupSubscription.objects.create(
+                user=user,
+                group=group,
+                project=project,
+                is_active=True,
+                reason=GroupSubscriptionReason.comment,
+            )
 
     def test_does_not_include_nonmember(self):
         org = self.create_organization()

Some files were not shown because too many files changed in this diff