Provisioning: Show file path of provisioning file in save/delete dialogs (#16706)
* Add file path to metadata and show it in dialogs * Make path relative to config directory * Fix tests * Add test for the relative path * Refactor to use path relative to provisioner path * Change return types * Rename attribute * Small fixes from review
This commit is contained in:
@@ -24,6 +24,7 @@ type DashboardProvisioningService interface {
|
||||
SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error)
|
||||
SaveFolderForProvisionedDashboards(*SaveDashboardDTO) (*models.Dashboard, error)
|
||||
GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error)
|
||||
GetProvisionedDashboardDataByDashboardId(dashboardId int64) (*models.DashboardProvisioning, error)
|
||||
UnprovisionDashboard(dashboardId int64) error
|
||||
DeleteProvisionedDashboard(dashboardId int64, orgId int64) error
|
||||
}
|
||||
@@ -37,7 +38,9 @@ var NewService = func() DashboardService {
|
||||
|
||||
// NewProvisioningService factory for creating a new dashboard provisioning service
|
||||
var NewProvisioningService = func() DashboardProvisioningService {
|
||||
return &dashboardServiceImpl{}
|
||||
return &dashboardServiceImpl{
|
||||
log: log.New("dashboard-provisioning-service"),
|
||||
}
|
||||
}
|
||||
|
||||
type SaveDashboardDTO struct {
|
||||
@@ -65,6 +68,16 @@ func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*mod
|
||||
return cmd.Result, nil
|
||||
}
|
||||
|
||||
func (dr *dashboardServiceImpl) GetProvisionedDashboardDataByDashboardId(dashboardId int64) (*models.DashboardProvisioning, error) {
|
||||
cmd := &models.GetProvisionedDashboardDataByIdQuery{DashboardId: dashboardId}
|
||||
err := bus.Dispatch(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cmd.Result, nil
|
||||
}
|
||||
|
||||
func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, validateAlerts bool, validateProvisionedDashboard bool) (*models.SaveDashboardCommand, error) {
|
||||
dash := dto.Dashboard
|
||||
|
||||
@@ -123,14 +136,12 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO,
|
||||
}
|
||||
|
||||
if validateProvisionedDashboard {
|
||||
isDashboardProvisioned := &models.IsDashboardProvisionedQuery{DashboardId: dash.Id}
|
||||
err := bus.Dispatch(isDashboardProvisioned)
|
||||
|
||||
provisionedData, err := dr.GetProvisionedDashboardDataByDashboardId(dash.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isDashboardProvisioned.Result {
|
||||
if provisionedData != nil {
|
||||
return nil, models.ErrDashboardCannotSaveProvisionedDashboard
|
||||
}
|
||||
}
|
||||
@@ -258,13 +269,12 @@ func (dr *dashboardServiceImpl) DeleteProvisionedDashboard(dashboardId int64, or
|
||||
|
||||
func (dr *dashboardServiceImpl) deleteDashboard(dashboardId int64, orgId int64, validateProvisionedDashboard bool) error {
|
||||
if validateProvisionedDashboard {
|
||||
isDashboardProvisioned := &models.IsDashboardProvisionedQuery{DashboardId: dashboardId}
|
||||
err := bus.Dispatch(isDashboardProvisioned)
|
||||
provisionedData, err := dr.GetProvisionedDashboardDataByDashboardId(dashboardId)
|
||||
if err != nil {
|
||||
return errutil.Wrap("failed to check if dashboard is provisioned", err)
|
||||
}
|
||||
|
||||
if isDashboardProvisioned.Result {
|
||||
if provisionedData != nil {
|
||||
return models.ErrDashboardCannotDeleteProvisionedDashboard
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ func TestDashboardService(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
cmd.Result = false
|
||||
bus.AddHandler("test", func(cmd *models.GetProvisionedDashboardDataByIdQuery) error {
|
||||
cmd.Result = nil
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -85,9 +85,9 @@ func TestDashboardService(t *testing.T) {
|
||||
|
||||
Convey("Should return validation error if dashboard is provisioned", func() {
|
||||
provisioningValidated := false
|
||||
bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
bus.AddHandler("test", func(cmd *models.GetProvisionedDashboardDataByIdQuery) error {
|
||||
provisioningValidated = true
|
||||
cmd.Result = true
|
||||
cmd.Result = &models.DashboardProvisioning{}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -109,8 +109,8 @@ func TestDashboardService(t *testing.T) {
|
||||
})
|
||||
|
||||
Convey("Should return validation error if alert data is invalid", func() {
|
||||
bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
cmd.Result = false
|
||||
bus.AddHandler("test", func(cmd *models.GetProvisionedDashboardDataByIdQuery) error {
|
||||
cmd.Result = nil
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -129,9 +129,9 @@ func TestDashboardService(t *testing.T) {
|
||||
|
||||
Convey("Should not return validation error if dashboard is provisioned", func() {
|
||||
provisioningValidated := false
|
||||
bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
bus.AddHandler("test", func(cmd *models.GetProvisionedDashboardDataByIdQuery) error {
|
||||
provisioningValidated = true
|
||||
cmd.Result = true
|
||||
cmd.Result = &models.DashboardProvisioning{}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -166,9 +166,9 @@ func TestDashboardService(t *testing.T) {
|
||||
|
||||
Convey("Should return validation error if dashboard is provisioned", func() {
|
||||
provisioningValidated := false
|
||||
bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
bus.AddHandler("test", func(cmd *models.GetProvisionedDashboardDataByIdQuery) error {
|
||||
provisioningValidated = true
|
||||
cmd.Result = true
|
||||
cmd.Result = &models.DashboardProvisioning{}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -241,8 +241,12 @@ type Result struct {
|
||||
}
|
||||
|
||||
func setupDeleteHandlers(provisioned bool) *Result {
|
||||
bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
cmd.Result = provisioned
|
||||
bus.AddHandler("test", func(cmd *models.GetProvisionedDashboardDataByIdQuery) error {
|
||||
if provisioned {
|
||||
cmd.Result = &models.DashboardProvisioning{}
|
||||
} else {
|
||||
cmd.Result = nil
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
@@ -112,8 +112,9 @@ func TestFolderService(t *testing.T) {
|
||||
|
||||
provisioningValidated := false
|
||||
|
||||
bus.AddHandler("test", func(query *models.IsDashboardProvisionedQuery) error {
|
||||
bus.AddHandler("test", func(query *models.GetProvisionedDashboardDataByIdQuery) error {
|
||||
provisioningValidated = true
|
||||
query.Result = nil
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
@@ -7,18 +7,11 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type DashboardProvisioner interface {
|
||||
Provision() error
|
||||
PollChanges(ctx context.Context)
|
||||
}
|
||||
|
||||
type DashboardProvisionerImpl struct {
|
||||
log log.Logger
|
||||
fileReaders []*fileReader
|
||||
}
|
||||
|
||||
type DashboardProvisionerFactory func(string) (DashboardProvisioner, error)
|
||||
|
||||
func NewDashboardProvisionerImpl(configDirectory string) (*DashboardProvisionerImpl, error) {
|
||||
logger := log.New("provisioning.dashboard")
|
||||
cfgReader := &configReader{path: configDirectory, log: logger}
|
||||
@@ -61,6 +54,17 @@ func (provider *DashboardProvisionerImpl) PollChanges(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvisionerResolvedPath returns resolved path for the specified provisioner name. Can be used to generate
|
||||
// relative path to provisioning file from it's external_id.
|
||||
func (provider *DashboardProvisionerImpl) GetProvisionerResolvedPath(name string) string {
|
||||
for _, reader := range provider.fileReaders {
|
||||
if reader.Cfg.Name == name {
|
||||
return reader.resolvedPath()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getFileReaders(configs []*DashboardsAsConfig, logger log.Logger) ([]*fileReader, error) {
|
||||
var readers []*fileReader
|
||||
|
||||
|
||||
@@ -3,14 +3,16 @@ package dashboards
|
||||
import "context"
|
||||
|
||||
type Calls struct {
|
||||
Provision []interface{}
|
||||
PollChanges []interface{}
|
||||
Provision []interface{}
|
||||
PollChanges []interface{}
|
||||
GetProvisionerResolvedPath []interface{}
|
||||
}
|
||||
|
||||
type DashboardProvisionerMock struct {
|
||||
Calls *Calls
|
||||
ProvisionFunc func() error
|
||||
PollChangesFunc func(ctx context.Context)
|
||||
Calls *Calls
|
||||
ProvisionFunc func() error
|
||||
PollChangesFunc func(ctx context.Context)
|
||||
GetProvisionerResolvedPathFunc func(name string) string
|
||||
}
|
||||
|
||||
func NewDashboardProvisionerMock() *DashboardProvisionerMock {
|
||||
@@ -34,3 +36,12 @@ func (dpm *DashboardProvisionerMock) PollChanges(ctx context.Context) {
|
||||
dpm.PollChangesFunc(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (dpm *DashboardProvisionerMock) GetProvisionerResolvedPath(name string) string {
|
||||
dpm.Calls.PollChanges = append(dpm.Calls.GetProvisionerResolvedPath, name)
|
||||
if dpm.GetProvisionerResolvedPathFunc != nil {
|
||||
return dpm.GetProvisionerResolvedPathFunc(name)
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func (fr *fileReader) pollChanges(ctx context.Context) {
|
||||
// to the database.
|
||||
func (fr *fileReader) startWalkingDisk() error {
|
||||
fr.log.Debug("Start walking disk", "path", fr.Path)
|
||||
resolvedPath := fr.resolvePath(fr.Path)
|
||||
resolvedPath := fr.resolvedPath()
|
||||
if _, err := os.Stat(resolvedPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return err
|
||||
@@ -329,24 +329,23 @@ func (fr *fileReader) readDashboardFromFile(path string, lastModified time.Time,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fr *fileReader) resolvePath(path string) string {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
func (fr *fileReader) resolvedPath() string {
|
||||
if _, err := os.Stat(fr.Path); os.IsNotExist(err) {
|
||||
fr.log.Error("Cannot read directory", "error", err)
|
||||
}
|
||||
|
||||
copy := path
|
||||
path, err := filepath.Abs(path)
|
||||
path, err := filepath.Abs(fr.Path)
|
||||
if err != nil {
|
||||
fr.log.Error("Could not create absolute path", "path", copy, "error", err)
|
||||
fr.log.Error("Could not create absolute path", "path", fr.Path, "error", err)
|
||||
}
|
||||
|
||||
path, err = filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
fr.log.Error("Failed to read content of symlinked path", "path", copy, "error", err)
|
||||
fr.log.Error("Failed to read content of symlinked path", "path", fr.Path, "error", err)
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
path = copy
|
||||
path = fr.Path
|
||||
fr.log.Info("falling back to original path due to EvalSymlink/Abs failure")
|
||||
}
|
||||
return path
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestProvsionedSymlinkedFolder(t *testing.T) {
|
||||
t.Errorf("expected err to be nil")
|
||||
}
|
||||
|
||||
resolvedPath := reader.resolvePath(reader.Path)
|
||||
resolvedPath := reader.resolvedPath()
|
||||
if resolvedPath != want {
|
||||
t.Errorf("got %s want %s", resolvedPath, want)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) {
|
||||
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
resolvedPath := reader.resolvePath(reader.Path)
|
||||
resolvedPath := reader.resolvedPath()
|
||||
So(filepath.IsAbs(resolvedPath), ShouldBeTrue)
|
||||
})
|
||||
})
|
||||
@@ -435,6 +435,10 @@ func (s *fakeDashboardProvisioningService) DeleteProvisionedDashboard(dashboardI
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeDashboardProvisioningService) GetProvisionedDashboardDataByDashboardId(dashboardId int64) (*models.DashboardProvisioning, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func mockGetDashboardQuery(cmd *models.GetDashboardQuery) error {
|
||||
for _, d := range fakeService.getDashboard {
|
||||
if d.Slug == cmd.Slug {
|
||||
|
||||
@@ -15,9 +15,17 @@ import (
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
type DashboardProvisioner interface {
|
||||
Provision() error
|
||||
PollChanges(ctx context.Context)
|
||||
GetProvisionerResolvedPath(name string) string
|
||||
}
|
||||
|
||||
type DashboardProvisionerFactory func(string) (DashboardProvisioner, error)
|
||||
|
||||
func init() {
|
||||
registry.RegisterService(NewProvisioningServiceImpl(
|
||||
func(path string) (dashboards.DashboardProvisioner, error) {
|
||||
func(path string) (DashboardProvisioner, error) {
|
||||
return dashboards.NewDashboardProvisionerImpl(path)
|
||||
},
|
||||
notifiers.Provision,
|
||||
@@ -25,14 +33,8 @@ func init() {
|
||||
))
|
||||
}
|
||||
|
||||
type ProvisioningService interface {
|
||||
ProvisionDatasources() error
|
||||
ProvisionNotifications() error
|
||||
ProvisionDashboards() error
|
||||
}
|
||||
|
||||
func NewProvisioningServiceImpl(
|
||||
newDashboardProvisioner dashboards.DashboardProvisionerFactory,
|
||||
newDashboardProvisioner DashboardProvisionerFactory,
|
||||
provisionNotifiers func(string) error,
|
||||
provisionDatasources func(string) error,
|
||||
) *provisioningServiceImpl {
|
||||
@@ -48,8 +50,8 @@ type provisioningServiceImpl struct {
|
||||
Cfg *setting.Cfg `inject:""`
|
||||
log log.Logger
|
||||
pollingCtxCancel context.CancelFunc
|
||||
newDashboardProvisioner dashboards.DashboardProvisionerFactory
|
||||
dashboardProvisioner dashboards.DashboardProvisioner
|
||||
newDashboardProvisioner DashboardProvisionerFactory
|
||||
dashboardProvisioner DashboardProvisioner
|
||||
provisionNotifiers func(string) error
|
||||
provisionDatasources func(string) error
|
||||
mutex sync.Mutex
|
||||
@@ -131,6 +133,10 @@ func (ps *provisioningServiceImpl) ProvisionDashboards() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ps *provisioningServiceImpl) GetDashboardProvisionerResolvedPath(name string) string {
|
||||
return ps.dashboardProvisioner.GetProvisionerResolvedPath(name)
|
||||
}
|
||||
|
||||
func (ps *provisioningServiceImpl) cancelPolling() {
|
||||
if ps.pollingCtxCancel != nil {
|
||||
ps.log.Debug("Stop polling for dashboard changes")
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package provisioning
|
||||
|
||||
type Calls struct {
|
||||
ProvisionDatasources []interface{}
|
||||
ProvisionNotifications []interface{}
|
||||
ProvisionDashboards []interface{}
|
||||
GetDashboardProvisionerResolvedPath []interface{}
|
||||
}
|
||||
|
||||
type ProvisioningServiceMock struct {
|
||||
Calls *Calls
|
||||
ProvisionDatasourcesFunc func() error
|
||||
ProvisionNotificationsFunc func() error
|
||||
ProvisionDashboardsFunc func() error
|
||||
GetDashboardProvisionerResolvedPathFunc func(name string) string
|
||||
}
|
||||
|
||||
func NewProvisioningServiceMock() *ProvisioningServiceMock {
|
||||
return &ProvisioningServiceMock{
|
||||
Calls: &Calls{},
|
||||
}
|
||||
}
|
||||
|
||||
func (mock *ProvisioningServiceMock) ProvisionDatasources() error {
|
||||
mock.Calls.ProvisionDatasources = append(mock.Calls.ProvisionDatasources, nil)
|
||||
if mock.ProvisionDatasourcesFunc != nil {
|
||||
return mock.ProvisionDatasourcesFunc()
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (mock *ProvisioningServiceMock) ProvisionNotifications() error {
|
||||
mock.Calls.ProvisionNotifications = append(mock.Calls.ProvisionNotifications, nil)
|
||||
if mock.ProvisionNotificationsFunc != nil {
|
||||
return mock.ProvisionNotificationsFunc()
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (mock *ProvisioningServiceMock) ProvisionDashboards() error {
|
||||
mock.Calls.ProvisionDashboards = append(mock.Calls.ProvisionDashboards, nil)
|
||||
if mock.ProvisionDashboardsFunc != nil {
|
||||
return mock.ProvisionDashboardsFunc()
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (mock *ProvisioningServiceMock) GetDashboardProvisionerResolvedPath(name string) string {
|
||||
mock.Calls.GetDashboardProvisionerResolvedPath = append(mock.Calls.GetDashboardProvisionerResolvedPath, name)
|
||||
if mock.GetDashboardProvisionerResolvedPathFunc != nil {
|
||||
return mock.GetDashboardProvisionerResolvedPathFunc(name)
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func setup() *serviceTestStruct {
|
||||
}
|
||||
|
||||
serviceTest.service = NewProvisioningServiceImpl(
|
||||
func(path string) (dashboards.DashboardProvisioner, error) {
|
||||
func(path string) (DashboardProvisioner, error) {
|
||||
return serviceTest.mock, nil
|
||||
},
|
||||
nil,
|
||||
|
||||
@@ -19,16 +19,16 @@ type DashboardExtras struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
func GetProvisionedDataByDashboardId(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
func GetProvisionedDataByDashboardId(cmd *models.GetProvisionedDashboardDataByIdQuery) error {
|
||||
result := &models.DashboardProvisioning{}
|
||||
|
||||
exist, err := x.Where("dashboard_id = ?", cmd.DashboardId).Get(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Result = exist
|
||||
|
||||
if exist {
|
||||
cmd.Result = result
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -65,20 +65,20 @@ func TestDashboardProvisioningTest(t *testing.T) {
|
||||
})
|
||||
|
||||
Convey("Can query for one provisioned dashboard", func() {
|
||||
query := &models.IsDashboardProvisionedQuery{DashboardId: cmd.Result.Id}
|
||||
query := &models.GetProvisionedDashboardDataByIdQuery{DashboardId: cmd.Result.Id}
|
||||
|
||||
err := GetProvisionedDataByDashboardId(query)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(query.Result, ShouldBeTrue)
|
||||
So(query.Result, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
Convey("Can query for none provisioned dashboard", func() {
|
||||
query := &models.IsDashboardProvisionedQuery{DashboardId: 3000}
|
||||
query := &models.GetProvisionedDashboardDataByIdQuery{DashboardId: 3000}
|
||||
|
||||
err := GetProvisionedDataByDashboardId(query)
|
||||
So(err, ShouldBeNil)
|
||||
So(query.Result, ShouldBeFalse)
|
||||
So(query.Result, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("Deleting folder should delete provision meta data", func() {
|
||||
@@ -89,11 +89,11 @@ func TestDashboardProvisioningTest(t *testing.T) {
|
||||
|
||||
So(DeleteDashboard(deleteCmd), ShouldBeNil)
|
||||
|
||||
query := &models.IsDashboardProvisionedQuery{DashboardId: cmd.Result.Id}
|
||||
query := &models.GetProvisionedDashboardDataByIdQuery{DashboardId: cmd.Result.Id}
|
||||
|
||||
err = GetProvisionedDataByDashboardId(query)
|
||||
So(err, ShouldBeNil)
|
||||
So(query.Result, ShouldBeFalse)
|
||||
So(query.Result, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("UnprovisionDashboard should delete provisioning metadata", func() {
|
||||
@@ -103,11 +103,11 @@ func TestDashboardProvisioningTest(t *testing.T) {
|
||||
|
||||
So(UnprovisionDashboard(unprovisionCmd), ShouldBeNil)
|
||||
|
||||
query := &models.IsDashboardProvisionedQuery{DashboardId: dashId}
|
||||
query := &models.GetProvisionedDashboardDataByIdQuery{DashboardId: dashId}
|
||||
|
||||
err = GetProvisionedDataByDashboardId(query)
|
||||
So(err, ShouldBeNil)
|
||||
So(query.Result, ShouldBeFalse)
|
||||
So(query.Result, ShouldBeNil)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
cmd.Result = false
|
||||
bus.AddHandler("test", func(cmd *models.GetProvisionedDashboardDataByIdQuery) error {
|
||||
cmd.Result = nil
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user