Browse Source

feat(notifications): Provider Selector (#25880)

Marcos Gaeta 3 years ago
parent
commit
91f3a97ecf

+ 1 - 1
src/sentry/api/exceptions.py

@@ -34,7 +34,7 @@ class ParameterValidationError(SentryAPIException):
     code = "parameter-validation-error"
 
     def __init__(self, message: str, context: Optional[List[str]] = None) -> None:
-        super().__init__(message=message, context=".".join(context))
+        super().__init__(message=message, context=".".join(context or []))
 
 
 class ProjectMoved(SentryAPIException):

+ 0 - 5
src/sentry/api/serializers/models/notification_setting.py

@@ -25,7 +25,6 @@ class NotificationSettingsSerializer(Serializer):  # type: ignore
         :param user: The user who will be viewing the notification settings.
         :param kwargs: Dict of optional filter options:
             - type: NotificationSettingTypes enum value. e.g. WORKFLOW, DEPLOY.
-            - provider: ExternalProvider enum value. e.g. SLACK, EMAIL.
         """
         from sentry.models import NotificationSetting
 
@@ -36,10 +35,6 @@ class NotificationSettingsSerializer(Serializer):  # type: ignore
         if type_option:
             filter_kwargs["type"] = type_option
 
-        provider_option = kwargs.get("provider")
-        if provider_option:
-            filter_kwargs["provider"] = provider_option
-
         notifications_settings = NotificationSetting.objects._filter(**filter_kwargs)
 
         results = defaultdict(list)

+ 7 - 4
src/sentry/api/validators/notifications.py

@@ -4,6 +4,9 @@ from sentry.api.exceptions import ParameterValidationError
 from sentry.api.validators.integrations import validate_provider
 from sentry.notifications.helpers import validate as helper_validate
 from sentry.notifications.types import (
+    NOTIFICATION_SCOPE_TYPE,
+    NOTIFICATION_SETTING_OPTION_VALUES,
+    NOTIFICATION_SETTING_TYPES,
     NotificationScopeType,
     NotificationSettingOptionValues,
     NotificationSettingTypes,
@@ -67,7 +70,7 @@ def validate_type_option(type: Optional[str]) -> Optional[NotificationSettingTyp
 
 def validate_type(type: str, context: Optional[List[str]] = None) -> NotificationSettingTypes:
     try:
-        return NotificationSettingTypes[type.upper()]
+        return {v: k for k, v in NOTIFICATION_SETTING_TYPES.items()}[type]
     except KeyError:
         raise ParameterValidationError(f"Unknown type: {type}", context)
 
@@ -76,7 +79,7 @@ def validate_scope_type(
     scope_type: str, context: Optional[List[str]] = None
 ) -> NotificationScopeType:
     try:
-        return NotificationScopeType[scope_type.upper()]
+        return {v: k for k, v in NOTIFICATION_SCOPE_TYPE.items()}[scope_type]
     except KeyError:
         raise ParameterValidationError(f"Unknown scope_type: {scope_type}", context)
 
@@ -101,11 +104,11 @@ def validate_value(
     type: NotificationSettingTypes, value_param: str, context: Optional[List[str]] = None
 ) -> NotificationSettingOptionValues:
     try:
-        value = NotificationSettingOptionValues[value_param.upper()]
+        value = {v: k for k, v in NOTIFICATION_SETTING_OPTION_VALUES.items()}[value_param]
     except KeyError:
         raise ParameterValidationError(f"Unknown value: {value_param}", context)
 
-    if not helper_validate(type, value):
+    if value != NotificationSettingOptionValues.DEFAULT and not helper_validate(type, value):
         raise ParameterValidationError(f"Invalid value for type {type}: {value}", context)
     return value
 

+ 3 - 3
src/sentry/notifications/types.py

@@ -45,7 +45,7 @@ class NotificationSettingTypes(Enum):
 NOTIFICATION_SETTING_TYPES = {
     NotificationSettingTypes.DEFAULT: "default",
     NotificationSettingTypes.DEPLOY: "deploy",
-    NotificationSettingTypes.ISSUE_ALERTS: "issue",
+    NotificationSettingTypes.ISSUE_ALERTS: "alerts",
     NotificationSettingTypes.WORKFLOW: "workflow",
 }
 
@@ -76,8 +76,8 @@ class NotificationSettingOptionValues(Enum):
 
 NOTIFICATION_SETTING_OPTION_VALUES = {
     NotificationSettingOptionValues.DEFAULT: "default",
-    NotificationSettingOptionValues.NEVER: "off",
-    NotificationSettingOptionValues.ALWAYS: "on",
+    NotificationSettingOptionValues.NEVER: "never",
+    NotificationSettingOptionValues.ALWAYS: "always",
     NotificationSettingOptionValues.SUBSCRIBE_ONLY: "subscribe_only",
     NotificationSettingOptionValues.COMMITTED_ONLY: "committed_only",
 }

+ 1 - 1
static/app/components/acl/feature.tsx

@@ -77,7 +77,7 @@ type FeatureRenderProps = {
 
 /**
  * When a feature is disabled the caller of Feature may provide a `renderDisabled`
- * prop. This prop can be overriden by getsentry via hooks. Often getsentry will
+ * prop. This prop can be overridden by getsentry via hooks. Often getsentry will
  * call the original children function  but override the `renderDisabled`
  * with another function/component.
  */

+ 1 - 1
static/app/components/panels/panelHeader.tsx

@@ -11,7 +11,7 @@ type Props = {
   /**
    * Usually we place controls at the right of a panel header, to make the
    * spacing between the edges correct we will want less padding on the right.
-   * Use this when the panel has somthing such as buttons living there.
+   * Use this when the panel has something such as buttons living there.
    */
   hasButtons?: boolean;
   /**

+ 24 - 24
static/app/views/settings/account/accountNotificationFineTuning.tsx

@@ -13,6 +13,11 @@ import {
   ACCOUNT_NOTIFICATION_FIELDS,
   FineTuneField,
 } from 'app/views/settings/account/notifications/fields';
+import NotificationSettings from 'app/views/settings/account/notifications/notificationSettings';
+import {
+  groupByOrganization,
+  isGroupedByProject,
+} from 'app/views/settings/account/notifications/utils';
 import EmptyMessage from 'app/views/settings/components/emptyMessage';
 import Form from 'app/views/settings/components/forms/form';
 import JsonForm from 'app/views/settings/components/forms/jsonForm';
@@ -27,27 +32,6 @@ const PanelBodyLineItem = styled(PanelBody)`
   }
 `;
 
-// Which fine tuning parts are grouped by project
-const isGroupedByProject = (type: string) =>
-  ['alerts', 'workflow', 'email'].indexOf(type) > -1;
-
-function groupByOrganization(projects: Project[]) {
-  return projects.reduce<
-    Record<string, {organization: Organization; projects: Project[]}>
-  >((acc, project) => {
-    const orgSlug = project.organization.slug;
-    if (acc.hasOwnProperty(orgSlug)) {
-      acc[orgSlug].projects.push(project);
-    } else {
-      acc[orgSlug] = {
-        organization: project.organization,
-        projects: [project],
-      };
-    }
-    return acc;
-  }, {});
-}
-
 type ANBPProps = {
   projects: Project[];
   field: FineTuneField;
@@ -130,7 +114,10 @@ const AccountNotificationsByOrganizationContainer = withOrganizations(
   AccountNotificationsByOrganization
 );
 
-type Props = AsyncView['props'] & RouteComponentProps<{fineTuneType: string}, {}>;
+type Props = AsyncView['props'] &
+  RouteComponentProps<{fineTuneType: string}, {}> & {
+    organizations: Organization[];
+  };
 
 type State = AsyncView['state'] & {
   emails: UserEmail[] | null;
@@ -139,7 +126,7 @@ type State = AsyncView['state'] & {
   fineTuneData: Record<string, any> | null;
 };
 
-export default class AccountNotificationFineTuning extends AsyncView<Props, State> {
+class AccountNotificationFineTuning extends AsyncView<Props, State> {
   getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
     const {fineTuneType} = this.props.params;
     const endpoints = [
@@ -178,7 +165,18 @@ export default class AccountNotificationFineTuning extends AsyncView<Props, Stat
   }
 
   renderBody() {
-    const {fineTuneType} = this.props.params;
+    const {params, organizations} = this.props;
+    const {fineTuneType} = params;
+
+    if (
+      ['alerts', 'deploy', 'workflow'].includes(fineTuneType) &&
+      organizations.some(organization =>
+        organization.features.includes('notification-platform')
+      )
+    ) {
+      return <NotificationSettings notificationType={fineTuneType} />;
+    }
+
     const {notifications, projects, fineTuneData, projectsPageLinks} = this.state;
 
     const isProject = isGroupedByProject(fineTuneType);
@@ -262,3 +260,5 @@ export default class AccountNotificationFineTuning extends AsyncView<Props, Stat
 const Heading = styled('div')`
   flex: 1;
 `;
+
+export default withOrganizations(AccountNotificationFineTuning);

+ 52 - 0
static/app/views/settings/account/notifications/fields2.tsx

@@ -0,0 +1,52 @@
+import {t} from 'app/locale';
+
+export type NotificationSettingField = {
+  name: string;
+  type: 'select';
+  label: string;
+  choices?: string[][];
+  defaultValue?: string;
+  defaultFieldName?: string;
+};
+
+export const NOTIFICATION_SETTING_FIELDS: Record<string, NotificationSettingField> = {
+  alerts: {
+    name: 'alerts',
+    type: 'select',
+    label: t('Issue Alert Notifications'),
+    choices: [
+      ['always', t('Always')],
+      ['never', t('Never')],
+    ],
+  },
+  deploy: {
+    name: 'deploy',
+    type: 'select',
+    label: t('Deploy Notifications'),
+    choices: [
+      ['always', t('Always')],
+      ['committed_only', t('Only Committed Issues')],
+      ['never', t('Never')],
+    ],
+  },
+  provider: {
+    name: 'provider',
+    type: 'select',
+    label: t('Delivery Method'),
+    choices: [
+      ['email', t('Send to Email')],
+      ['slack', t('Send to Slack')],
+      ['email+slack', t('Send to Email and Slack')],
+    ],
+  },
+  workflow: {
+    name: 'workflow',
+    type: 'select',
+    label: t('Workflow Notifications'),
+    choices: [
+      ['always', t('Always')],
+      ['subscribe_only', t('Only Subscribed Issues')],
+      ['never', t('Never')],
+    ],
+  },
+};

+ 313 - 0
static/app/views/settings/account/notifications/notificationSettings.tsx

@@ -0,0 +1,313 @@
+import React from 'react';
+
+import AsyncComponent from 'app/components/asyncComponent';
+import Avatar from 'app/components/avatar';
+import {t} from 'app/locale';
+import {Organization, Project} from 'app/types';
+import withOrganizations from 'app/utils/withOrganizations';
+import {ACCOUNT_NOTIFICATION_FIELDS} from 'app/views/settings/account/notifications/fields';
+import {NOTIFICATION_SETTING_FIELDS} from 'app/views/settings/account/notifications/fields2';
+import {
+  backfillMissingProvidersWithFallback,
+  getChoiceString,
+  getFallBackValue,
+  groupByOrganization,
+  isGroupedByProject,
+  providerListToString,
+} from 'app/views/settings/account/notifications/utils';
+import Form from 'app/views/settings/components/forms/form';
+import JsonForm from 'app/views/settings/components/forms/jsonForm';
+import {FieldObject} from 'app/views/settings/components/forms/type';
+import SettingsPageHeader from 'app/views/settings/components/settingsPageHeader';
+import TextBlock from 'app/views/settings/components/text/textBlock';
+
+type Props = {
+  notificationType: string;
+  organizations: Organization[];
+} & AsyncComponent['props'];
+
+type State = {
+  notificationSettings: {
+    [key: string]: {[key: string]: {[key: string]: {[key: string]: string}}};
+  };
+  projects: Project[];
+} & AsyncComponent['state'];
+
+class NotificationSettings extends AsyncComponent<Props, State> {
+  getDefaultState(): State {
+    return {
+      ...super.getDefaultState(),
+      notificationSettings: {},
+      projects: [],
+    };
+  }
+
+  getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
+    const {notificationType} = this.props;
+
+    const query = {type: notificationType};
+    const endpoints: ReturnType<AsyncComponent['getEndpoints']> = [
+      ['notificationSettings', `/users/me/notification-settings/`, {query}],
+    ];
+    if (this.isGroupedByProject()) {
+      endpoints.push(['projects', '/projects/']);
+    }
+    return endpoints;
+  }
+
+  isGroupedByProject() {
+    /** We can infer the parent type by the `notificationType` key. */
+    const {notificationType} = this.props;
+    return isGroupedByProject(notificationType);
+  }
+
+  getParents(): Organization[] | Project[] {
+    /** Use the `notificationType` key to decide which parent objects to use */
+    const {organizations} = this.props;
+    const {projects} = this.state;
+
+    return this.isGroupedByProject() ? projects : organizations;
+  }
+
+  getUserDefaultValues = (): {[key: string]: string} => {
+    /**
+     * Get the mapping of providers to values that describe a user's parent-
+     * independent notification preferences. The data from the API uses the user
+     * ID rather than "me" so we assume the first ID is the user's.
+     */
+    const {notificationType} = this.props;
+    const {notificationSettings} = this.state;
+
+    return (
+      Object.values(notificationSettings[notificationType]?.user || {}).pop() || {
+        email: getFallBackValue(notificationType),
+      }
+    );
+  };
+
+  getParentData = (): {[key: string]: string} => {
+    return Object.fromEntries(
+      this.getParents().map(parent => [
+        parent.id,
+        Object.values(this.getParentValues(parent.id))[0],
+      ])
+    );
+  };
+
+  getStateToPutForProvider = changedData => {
+    /**
+     * I don't need to update the provider for EVERY once of the user's projects
+     * and organizations, just the user and parents that have explicit settings.
+     */
+    const {notificationType} = this.props;
+    const {notificationSettings} = this.state;
+
+    const providerList: string[] = changedData.provider.split('+');
+    const fallbackValue = getFallBackValue(notificationType);
+
+    let updatedNotificationSettings;
+    if (Object.keys(notificationSettings).length) {
+      updatedNotificationSettings = {
+        [notificationType]: Object.fromEntries(
+          Object.entries(
+            notificationSettings[notificationType]
+          ).map(([scopeType, scopeTypeData]) => [
+            scopeType,
+            Object.fromEntries(
+              Object.entries(scopeTypeData).map(([scopeId, scopeIdData]) => [
+                scopeId,
+                backfillMissingProvidersWithFallback(
+                  scopeIdData,
+                  providerList,
+                  fallbackValue
+                ),
+              ])
+            ),
+          ])
+        ),
+      };
+    } else {
+      // If the user has no settings, we need to create them.
+      updatedNotificationSettings = {
+        [notificationType]: {
+          user: {
+            me: Object.fromEntries(
+              providerList.map(provider => [provider, fallbackValue])
+            ),
+          },
+        },
+      };
+    }
+
+    this.setState({notificationSettings: updatedNotificationSettings});
+
+    return updatedNotificationSettings;
+  };
+
+  getParentValues = (parentId: string): {[key: string]: string} => {
+    const {notificationType} = this.props;
+    const {notificationSettings} = this.state;
+
+    const parentKey = this.isGroupedByProject() ? 'project' : 'organization';
+
+    return (
+      notificationSettings[notificationType]?.[parentKey]?.[parentId] || {
+        email: 'default',
+      }
+    );
+  };
+
+  getStateToPutForDefault = (changedData: {[key: string]: string}) => {
+    /** This always updates "user:me". */
+    const {notificationType} = this.props;
+
+    const newValue = Object.values(changedData)[0];
+    const previousData = this.getUserDefaultValues();
+
+    const notificationSettings = {
+      [notificationType]: {
+        user: {
+          me: Object.fromEntries(
+            Object.keys(previousData).map(provider => [provider, newValue])
+          ),
+        },
+      },
+    };
+
+    this.setState({notificationSettings});
+
+    return notificationSettings;
+  };
+
+  getStateToPutForParent = (changedData: {[key: string]: string}, parentId: string) => {
+    /** Get the diff of the Notification Settings for this parent ID. */
+    const {notificationType} = this.props;
+
+    const parentKey = this.isGroupedByProject() ? 'project' : 'organization';
+    const newValue = Object.values(changedData)[0];
+    const previousData = this.getParentValues(parentId);
+
+    const notificationSettings = {
+      [notificationType]: {
+        [parentKey]: {
+          [parentId]: Object.fromEntries(
+            Object.entries(previousData).map(([provider, _]) => [provider, newValue])
+          ),
+        },
+      },
+    };
+    this.setState({notificationSettings});
+    return notificationSettings;
+  };
+
+  getGroupedParents = (): {[key: string]: Organization[] | Project[]} => {
+    /**
+     * The UI expects projects to be grouped by organization but can also use
+     * this function to make a single group with all organizations.
+     */
+    const {organizations} = this.props;
+    const {projects: stateProjects} = this.state;
+
+    return this.isGroupedByProject()
+      ? Object.fromEntries(
+          Object.values(
+            groupByOrganization(stateProjects)
+          ).map(({organization, projects}) => [`${organization.name} Projects`, projects])
+        )
+      : {organizations};
+  };
+
+  getParentField = (parent: Organization | Project): FieldObject => {
+    const {notificationType} = this.props;
+
+    const defaultFields = NOTIFICATION_SETTING_FIELDS[notificationType];
+    const currentDefault = Object.values(this.getUserDefaultValues())[0];
+
+    return Object.assign({}, defaultFields, {
+      label: (
+        <React.Fragment>
+          <Avatar
+            {...{[this.isGroupedByProject() ? 'project' : 'organization']: parent}}
+          />
+          {parent.slug}
+        </React.Fragment>
+      ),
+      getData: data => this.getStateToPutForParent(data, parent.id),
+      name: parent.id,
+      choices: defaultFields.choices?.concat([
+        [
+          'default',
+          `${getChoiceString(defaultFields.choices, currentDefault)} (${t('default')})`,
+        ],
+      ]),
+      defaultValue: 'default',
+    }) as any;
+  };
+
+  getDefaultSettings = (): [string, FieldObject[]] => {
+    const {notificationType} = this.props;
+
+    const title = this.isGroupedByProject() ? t('All Projects') : t('All Organizations');
+    const fields = [
+      Object.assign(
+        {
+          help: t('This is the default for all projects.'),
+          getData: data => this.getStateToPutForDefault(data),
+        },
+        NOTIFICATION_SETTING_FIELDS[notificationType]
+      ),
+      Object.assign(
+        {
+          help: t('Where personal notifications will be sent.'),
+          getData: data => this.getStateToPutForProvider(data),
+        },
+        NOTIFICATION_SETTING_FIELDS.provider
+      ),
+    ] as FieldObject[];
+    return [title, fields];
+  };
+
+  renderBody() {
+    const {notificationType} = this.props;
+
+    const {title, description} = ACCOUNT_NOTIFICATION_FIELDS[notificationType];
+    const groupedParents = this.getGroupedParents();
+    const userData = this.getUserDefaultValues();
+    const parentData = this.getParentData();
+    const [formTitle, fields] = this.getDefaultSettings();
+
+    return (
+      <React.Fragment>
+        <SettingsPageHeader title={title} />
+        {description && <TextBlock>{description}</TextBlock>}
+        <Form
+          saveOnBlur
+          apiMethod="PUT"
+          apiEndpoint="/users/me/notification-settings/"
+          initialData={{
+            [notificationType]: Object.values(userData)[0],
+            provider: providerListToString(Object.keys(userData)),
+          }}
+        >
+          <JsonForm title={formTitle} fields={fields} />
+        </Form>
+        <Form
+          saveOnBlur
+          apiMethod="PUT"
+          apiEndpoint="/users/me/notification-settings/"
+          initialData={parentData}
+        >
+          {Object.entries(groupedParents).map(([groupTitle, parents]) => (
+            <JsonForm
+              key={groupTitle}
+              title={groupTitle}
+              fields={parents.map(parent => this.getParentField(parent))}
+            />
+          ))}
+        </Form>
+      </React.Fragment>
+    );
+  }
+}
+
+export default withOrganizations(NotificationSettings);

+ 80 - 0
static/app/views/settings/account/notifications/utils.tsx

@@ -0,0 +1,80 @@
+import {Organization, Project} from 'app/types';
+
+// Which fine tuning parts are grouped by project
+export const isGroupedByProject = (type: string): boolean =>
+  ['alerts', 'email', 'workflow'].includes(type);
+
+export const groupByOrganization = (projects: Project[]) => {
+  return projects.reduce<
+    Record<string, {organization: Organization; projects: Project[]}>
+  >((acc, project) => {
+    const orgSlug = project.organization.slug;
+    if (acc.hasOwnProperty(orgSlug)) {
+      acc[orgSlug].projects.push(project);
+    } else {
+      acc[orgSlug] = {
+        organization: project.organization,
+        projects: [project],
+      };
+    }
+    return acc;
+  }, {});
+};
+
+export const getFallBackValue = (notificationType: string): string => {
+  switch (notificationType) {
+    case 'alerts':
+      return 'always';
+    case 'deploy':
+      return 'committed_only';
+    case 'workflow':
+      return 'subscribe_only';
+    default:
+      return '';
+  }
+};
+
+export const providerListToString = (providers: string[]): string => {
+  return providers.sort().join('+');
+};
+
+export const getChoiceString = (choices: string[][], key: string): string => {
+  if (!choices) {
+    return 'default';
+  }
+  const found = choices.find(row => row[0] === key);
+  if (!found) {
+    throw new Error(`Could not find ${key}`);
+  }
+
+  return found[1];
+};
+
+export const backfillMissingProvidersWithFallback = (
+  data: {[key: string]: string},
+  providerList: string[],
+  fallbackValue: string
+): {[key: string]: string} => {
+  /**
+   * Transform `data` to include only providers expected in `providerList`.
+   * Everything not in that list is set to "never". Missing values will be
+   * backfilled either with a current value from `data` or `fallbackValue` if
+   * none are present.
+   *
+   * For example:
+   * f({}, ["email"], "sometimes") = {"email": "sometimes"}
+   *
+   * f({"email": "always", pagerduty: "always"}, ["email", "slack"], "sometimes") =
+   * {"email": "always", "slack": "always", "pagerduty": "never"}
+   */
+  const entries: string[][] = [];
+  let fallback = fallbackValue;
+  for (const [provider, previousValue] of Object.entries(data)) {
+    fallback = previousValue;
+    entries.push([provider, providerList.includes(provider) ? previousValue : 'never']);
+  }
+  for (const provider of providerList) {
+    entries.push([provider, fallback]);
+  }
+  return Object.fromEntries(entries);
+};