mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
CloudMigrations: create snapshot for Notification Policies (#94852)
* CloudMigrations: create snapshot for Notification Policy * CloudMigrations: add notification policy constants and components * CloudMigrations: add uid to resources that have it
This commit is contained in:
parent
8f5edb09ef
commit
1051561154
@ -35,6 +35,7 @@ var currentMigrationTypes = []cloudmigration.MigrateDataType{
|
|||||||
cloudmigration.MuteTimingType,
|
cloudmigration.MuteTimingType,
|
||||||
cloudmigration.NotificationTemplateType,
|
cloudmigration.NotificationTemplateType,
|
||||||
cloudmigration.ContactPointType,
|
cloudmigration.ContactPointType,
|
||||||
|
cloudmigration.NotificationPolicyType,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.SignedInUser) (*cloudmigration.MigrateDataRequest, error) {
|
func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.SignedInUser) (*cloudmigration.MigrateDataRequest, error) {
|
||||||
@ -82,6 +83,13 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alerts: Notification Policies
|
||||||
|
notificationPolicies, err := s.getNotificationPolicies(ctx, signedInUser)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("Failed to get alert notification policies", "err", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
migrationDataSlice := make(
|
migrationDataSlice := make(
|
||||||
[]cloudmigration.MigrateDataRequestItem, 0,
|
[]cloudmigration.MigrateDataRequestItem, 0,
|
||||||
len(dataSources)+len(dashs)+len(folders)+len(libraryElements)+
|
len(dataSources)+len(dashs)+len(folders)+len(libraryElements)+
|
||||||
@ -135,7 +143,7 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
|
|||||||
for _, muteTiming := range muteTimings {
|
for _, muteTiming := range muteTimings {
|
||||||
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
|
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
|
||||||
Type: cloudmigration.MuteTimingType,
|
Type: cloudmigration.MuteTimingType,
|
||||||
RefID: muteTiming.Name,
|
RefID: muteTiming.UID,
|
||||||
Name: muteTiming.Name,
|
Name: muteTiming.Name,
|
||||||
Data: muteTiming,
|
Data: muteTiming,
|
||||||
})
|
})
|
||||||
@ -144,7 +152,7 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
|
|||||||
for _, notificationTemplate := range notificationTemplates {
|
for _, notificationTemplate := range notificationTemplates {
|
||||||
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
|
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
|
||||||
Type: cloudmigration.NotificationTemplateType,
|
Type: cloudmigration.NotificationTemplateType,
|
||||||
RefID: notificationTemplate.Name,
|
RefID: notificationTemplate.UID,
|
||||||
Name: notificationTemplate.Name,
|
Name: notificationTemplate.Name,
|
||||||
Data: notificationTemplate,
|
Data: notificationTemplate,
|
||||||
})
|
})
|
||||||
@ -159,6 +167,14 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notification Policy can only be managed by updating its entire tree, so we send the whole thing as one item.
|
||||||
|
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
|
||||||
|
Type: cloudmigration.NotificationPolicyType,
|
||||||
|
RefID: notificationPolicies.Name, // no UID available
|
||||||
|
Name: notificationPolicies.Name,
|
||||||
|
Data: notificationPolicies.Routes,
|
||||||
|
})
|
||||||
|
|
||||||
// Obtain the names of parent elements for Dashboard and Folders data types
|
// Obtain the names of parent elements for Dashboard and Folders data types
|
||||||
parentNamesByType, err := s.getParentNames(ctx, signedInUser, dashs, folders, libraryElements)
|
parentNamesByType, err := s.getParentNames(ctx, signedInUser, dashs, folders, libraryElements)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -8,11 +8,14 @@ import (
|
|||||||
|
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
|
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||||
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
|
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
|
||||||
"github.com/grafana/grafana/pkg/services/user"
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
type muteTimeInterval struct {
|
type muteTimeInterval struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
|
|
||||||
// There is a lot of custom (de)serialization logic from Alertmanager,
|
// There is a lot of custom (de)serialization logic from Alertmanager,
|
||||||
// and this is the same type used by the underlying API, hence we can use the type as-is.
|
// and this is the same type used by the underlying API, hence we can use the type as-is.
|
||||||
config.MuteTimeInterval `json:",inline"`
|
config.MuteTimeInterval `json:",inline"`
|
||||||
@ -32,6 +35,7 @@ func (s *Service) getAlertMuteTimings(ctx context.Context, signedInUser *user.Si
|
|||||||
|
|
||||||
for _, muteTiming := range muteTimings {
|
for _, muteTiming := range muteTimings {
|
||||||
muteTimeIntervals = append(muteTimeIntervals, muteTimeInterval{
|
muteTimeIntervals = append(muteTimeIntervals, muteTimeInterval{
|
||||||
|
UID: muteTiming.UID,
|
||||||
MuteTimeInterval: config.MuteTimeInterval{
|
MuteTimeInterval: config.MuteTimeInterval{
|
||||||
Name: muteTiming.Name,
|
Name: muteTiming.Name,
|
||||||
TimeIntervals: muteTiming.TimeIntervals,
|
TimeIntervals: muteTiming.TimeIntervals,
|
||||||
@ -43,6 +47,7 @@ func (s *Service) getAlertMuteTimings(ctx context.Context, signedInUser *user.Si
|
|||||||
}
|
}
|
||||||
|
|
||||||
type notificationTemplate struct {
|
type notificationTemplate struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Template string `json:"template"`
|
Template string `json:"template"`
|
||||||
}
|
}
|
||||||
@ -61,6 +66,7 @@ func (s *Service) getNotificationTemplates(ctx context.Context, signedInUser *us
|
|||||||
|
|
||||||
for _, template := range templates {
|
for _, template := range templates {
|
||||||
notificationTemplates = append(notificationTemplates, notificationTemplate{
|
notificationTemplates = append(notificationTemplates, notificationTemplate{
|
||||||
|
UID: template.UID,
|
||||||
Name: template.Name,
|
Name: template.Name,
|
||||||
Template: template.Template,
|
Template: template.Template,
|
||||||
})
|
})
|
||||||
@ -106,3 +112,24 @@ func (s *Service) getContactPoints(ctx context.Context, signedInUser *user.Signe
|
|||||||
|
|
||||||
return contactPoints, nil
|
return contactPoints, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type notificationPolicy struct {
|
||||||
|
Name string
|
||||||
|
Routes definitions.Route
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) getNotificationPolicies(ctx context.Context, signedInUser *user.SignedInUser) (notificationPolicy, error) {
|
||||||
|
if !s.features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrationsAlerts) {
|
||||||
|
return notificationPolicy{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
policyTree, _, err := s.ngAlert.Api.Policies.GetPolicyTree(ctx, signedInUser.GetOrgID())
|
||||||
|
if err != nil {
|
||||||
|
return notificationPolicy{}, fmt.Errorf("fetching ngalert notification policy tree: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return notificationPolicy{
|
||||||
|
Name: "Notification Policy Tree",
|
||||||
|
Routes: policyTree,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
@ -5,8 +5,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/prometheus/alertmanager/pkg/labels"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/grafana/alerting/definition"
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
@ -32,10 +34,9 @@ func TestGetAlertMuteTimings(t *testing.T) {
|
|||||||
s := setUpServiceTest(t, false).(*Service)
|
s := setUpServiceTest(t, false).(*Service)
|
||||||
s.features = featuremgmt.WithFeatures(featuremgmt.FlagOnPremToCloudMigrations, featuremgmt.FlagOnPremToCloudMigrationsAlerts)
|
s.features = featuremgmt.WithFeatures(featuremgmt.FlagOnPremToCloudMigrations, featuremgmt.FlagOnPremToCloudMigrationsAlerts)
|
||||||
|
|
||||||
var orgID int64 = 1
|
user := &user.SignedInUser{OrgID: 1}
|
||||||
user := &user.SignedInUser{OrgID: orgID}
|
|
||||||
|
|
||||||
createdMuteTiming := createMuteTiming(t, ctx, s, orgID)
|
createdMuteTiming := createMuteTiming(t, ctx, s, user)
|
||||||
|
|
||||||
muteTimeIntervals, err := s.getAlertMuteTimings(ctx, user)
|
muteTimeIntervals, err := s.getAlertMuteTimings(ctx, user)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@ -62,10 +63,9 @@ func TestGetNotificationTemplates(t *testing.T) {
|
|||||||
s := setUpServiceTest(t, false).(*Service)
|
s := setUpServiceTest(t, false).(*Service)
|
||||||
s.features = featuremgmt.WithFeatures(featuremgmt.FlagOnPremToCloudMigrations, featuremgmt.FlagOnPremToCloudMigrationsAlerts)
|
s.features = featuremgmt.WithFeatures(featuremgmt.FlagOnPremToCloudMigrations, featuremgmt.FlagOnPremToCloudMigrationsAlerts)
|
||||||
|
|
||||||
var orgID int64 = 1
|
user := &user.SignedInUser{OrgID: 1}
|
||||||
user := &user.SignedInUser{OrgID: orgID}
|
|
||||||
|
|
||||||
createdTemplate := createNotificationTemplate(t, ctx, s, orgID)
|
createdTemplate := createNotificationTemplate(t, ctx, s, user)
|
||||||
|
|
||||||
notificationTemplates, err := s.getNotificationTemplates(ctx, user)
|
notificationTemplates, err := s.getNotificationTemplates(ctx, user)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@ -113,7 +113,41 @@ func TestGetContactPoints(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func createMuteTiming(t *testing.T, ctx context.Context, service *Service, orgID int64) definitions.MuteTimeInterval {
|
func TestGetNotificationPolicies(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
t.Run("when the feature flag `onPremToCloudMigrationsAlerts` is not enabled it returns nil", func(t *testing.T) {
|
||||||
|
s := setUpServiceTest(t, false).(*Service)
|
||||||
|
s.features = featuremgmt.WithFeatures(featuremgmt.FlagOnPremToCloudMigrations)
|
||||||
|
|
||||||
|
notificationPolicies, err := s.getNotificationPolicies(ctx, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, notificationPolicies)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("when the feature flag `onPremToCloudMigrationsAlerts` is enabled it returns the contact points", func(t *testing.T) {
|
||||||
|
s := setUpServiceTest(t, false).(*Service)
|
||||||
|
s.features = featuremgmt.WithFeatures(featuremgmt.FlagOnPremToCloudMigrations, featuremgmt.FlagOnPremToCloudMigrationsAlerts)
|
||||||
|
|
||||||
|
user := &user.SignedInUser{OrgID: 1}
|
||||||
|
|
||||||
|
muteTiming := createMuteTiming(t, ctx, s, user)
|
||||||
|
require.NotEmpty(t, muteTiming.Name)
|
||||||
|
|
||||||
|
contactPoints := createContactPoints(t, ctx, s, user)
|
||||||
|
require.GreaterOrEqual(t, len(contactPoints), 1)
|
||||||
|
|
||||||
|
updateNotificationPolicyTree(t, ctx, s, user, contactPoints[0].Name, muteTiming.Name)
|
||||||
|
|
||||||
|
notificationPolicies, err := s.getNotificationPolicies(ctx, user)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, notificationPolicies.Routes.Receiver)
|
||||||
|
require.NotNil(t, notificationPolicies.Routes.Routes)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func createMuteTiming(t *testing.T, ctx context.Context, service *Service, user *user.SignedInUser) definitions.MuteTimeInterval {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
muteTiming := `{
|
muteTiming := `{
|
||||||
@ -133,13 +167,13 @@ func createMuteTiming(t *testing.T, ctx context.Context, service *Service, orgID
|
|||||||
var mt definitions.MuteTimeInterval
|
var mt definitions.MuteTimeInterval
|
||||||
require.NoError(t, json.Unmarshal([]byte(muteTiming), &mt))
|
require.NoError(t, json.Unmarshal([]byte(muteTiming), &mt))
|
||||||
|
|
||||||
createdTiming, err := service.ngAlert.Api.MuteTimings.CreateMuteTiming(ctx, mt, orgID)
|
createdTiming, err := service.ngAlert.Api.MuteTimings.CreateMuteTiming(ctx, mt, user.GetOrgID())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return createdTiming
|
return createdTiming
|
||||||
}
|
}
|
||||||
|
|
||||||
func createNotificationTemplate(t *testing.T, ctx context.Context, service *Service, orgID int64) definitions.NotificationTemplate {
|
func createNotificationTemplate(t *testing.T, ctx context.Context, service *Service, user *user.SignedInUser) definitions.NotificationTemplate {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
tmpl := definitions.NotificationTemplate{
|
tmpl := definitions.NotificationTemplate{
|
||||||
@ -147,7 +181,7 @@ func createNotificationTemplate(t *testing.T, ctx context.Context, service *Serv
|
|||||||
Template: "This is a test template\n{{ .ExternalURL }}",
|
Template: "This is a test template\n{{ .ExternalURL }}",
|
||||||
}
|
}
|
||||||
|
|
||||||
createdTemplate, err := service.ngAlert.Api.Templates.CreateTemplate(ctx, orgID, tmpl)
|
createdTemplate, err := service.ngAlert.Api.Templates.CreateTemplate(ctx, user.GetOrgID(), tmpl)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return createdTemplate
|
return createdTemplate
|
||||||
@ -205,3 +239,25 @@ func createContactPoints(t *testing.T, ctx context.Context, service *Service, us
|
|||||||
createdTelegram,
|
createdTelegram,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateNotificationPolicyTree(t *testing.T, ctx context.Context, service *Service, user *user.SignedInUser, receiverGroup, muteTiming string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
child := definition.Route{
|
||||||
|
Continue: true,
|
||||||
|
MuteTimeIntervals: []string{muteTiming},
|
||||||
|
ObjectMatchers: definition.ObjectMatchers{
|
||||||
|
{Name: "label1", Type: labels.MatchEqual, Value: "value1"},
|
||||||
|
{Name: "label2", Type: labels.MatchNotEqual, Value: "value2"},
|
||||||
|
},
|
||||||
|
Receiver: receiverGroup,
|
||||||
|
}
|
||||||
|
|
||||||
|
tree := definition.Route{
|
||||||
|
Receiver: "grafana-default-email",
|
||||||
|
Routes: []*definition.Route{&child},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := service.ngAlert.Api.Policies.UpdatePolicyTree(ctx, user.GetOrgID(), tree, "", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
@ -225,6 +225,8 @@ function ResourceIcon({ resource }: { resource: ResourceTableItem }) {
|
|||||||
return <Icon size="xl" name="bell" />;
|
return <Icon size="xl" name="bell" />;
|
||||||
case 'CONTACT_POINT':
|
case 'CONTACT_POINT':
|
||||||
return <Icon size="xl" name="bell" />;
|
return <Icon size="xl" name="bell" />;
|
||||||
|
case 'NOTIFICATION_POLICY':
|
||||||
|
return <Icon size="xl" name="bell" />;
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,8 @@ export function prettyTypeName(type: ResourceTableItem['type']) {
|
|||||||
return t('migrate-to-cloud.resource-type.notification_template', 'Notification Template');
|
return t('migrate-to-cloud.resource-type.notification_template', 'Notification Template');
|
||||||
case 'CONTACT_POINT':
|
case 'CONTACT_POINT':
|
||||||
return t('migrate-to-cloud.resource-type.contact_point', 'Contact Point');
|
return t('migrate-to-cloud.resource-type.contact_point', 'Contact Point');
|
||||||
|
case 'NOTIFICATION_POLICY':
|
||||||
|
return t('migrate-to-cloud.resource-type.notification_policy', 'Notification Policy');
|
||||||
default:
|
default:
|
||||||
return t('migrate-to-cloud.resource-type.unknown', 'Unknown');
|
return t('migrate-to-cloud.resource-type.unknown', 'Unknown');
|
||||||
}
|
}
|
||||||
|
@ -58,6 +58,8 @@ function getTranslatedMessage(snapshot: GetSnapshotResponseDto) {
|
|||||||
types.push(t('migrate-to-cloud.migrated-counts.notification_templates', 'notification templates'));
|
types.push(t('migrate-to-cloud.migrated-counts.notification_templates', 'notification templates'));
|
||||||
} else if (type === 'CONTACT_POINT') {
|
} else if (type === 'CONTACT_POINT') {
|
||||||
types.push(t('migrate-to-cloud.migrated-counts.contact_points', 'contact points'));
|
types.push(t('migrate-to-cloud.migrated-counts.contact_points', 'contact points'));
|
||||||
|
} else if (type === 'NOTIFICATION_POLICY') {
|
||||||
|
types.push(t('migrate-to-cloud.migrated-counts.notification_policies', 'notification policies'));
|
||||||
}
|
}
|
||||||
|
|
||||||
distinctItems += 1;
|
distinctItems += 1;
|
||||||
|
@ -1416,6 +1416,7 @@
|
|||||||
"folders": "folders",
|
"folders": "folders",
|
||||||
"library_elements": "library elements",
|
"library_elements": "library elements",
|
||||||
"mute_timings": "mute timings",
|
"mute_timings": "mute timings",
|
||||||
|
"notification_policies": "notification policies",
|
||||||
"notification_templates": "notification templates"
|
"notification_templates": "notification templates"
|
||||||
},
|
},
|
||||||
"migration-token": {
|
"migration-token": {
|
||||||
@ -1502,6 +1503,7 @@
|
|||||||
"folder": "Folder",
|
"folder": "Folder",
|
||||||
"library_element": "Library Element",
|
"library_element": "Library Element",
|
||||||
"mute_timing": "Mute Timing",
|
"mute_timing": "Mute Timing",
|
||||||
|
"notification_policy": "Notification Policy",
|
||||||
"notification_template": "Notification Template",
|
"notification_template": "Notification Template",
|
||||||
"unknown": "Unknown"
|
"unknown": "Unknown"
|
||||||
},
|
},
|
||||||
|
@ -1416,6 +1416,7 @@
|
|||||||
"folders": "ƒőľđęřş",
|
"folders": "ƒőľđęřş",
|
||||||
"library_elements": "ľįþřäřy ęľęmęʼnŧş",
|
"library_elements": "ľįþřäřy ęľęmęʼnŧş",
|
||||||
"mute_timings": "mūŧę ŧįmįʼnģş",
|
"mute_timings": "mūŧę ŧįmįʼnģş",
|
||||||
|
"notification_policies": "ʼnőŧįƒįčäŧįőʼn pőľįčįęş",
|
||||||
"notification_templates": "ʼnőŧįƒįčäŧįőʼn ŧęmpľäŧęş"
|
"notification_templates": "ʼnőŧįƒįčäŧįőʼn ŧęmpľäŧęş"
|
||||||
},
|
},
|
||||||
"migration-token": {
|
"migration-token": {
|
||||||
@ -1502,6 +1503,7 @@
|
|||||||
"folder": "Főľđęř",
|
"folder": "Főľđęř",
|
||||||
"library_element": "Ŀįþřäřy Ēľęmęʼnŧ",
|
"library_element": "Ŀįþřäřy Ēľęmęʼnŧ",
|
||||||
"mute_timing": "Mūŧę Ŧįmįʼnģ",
|
"mute_timing": "Mūŧę Ŧįmįʼnģ",
|
||||||
|
"notification_policy": "Ńőŧįƒįčäŧįőʼn Pőľįčy",
|
||||||
"notification_template": "Ńőŧįƒįčäŧįőʼn Ŧęmpľäŧę",
|
"notification_template": "Ńőŧįƒįčäŧįőʼn Ŧęmpľäŧę",
|
||||||
"unknown": "Ůʼnĸʼnőŵʼn"
|
"unknown": "Ůʼnĸʼnőŵʼn"
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user