mirror of https://github.com/grafana/grafana.git
				
				
				
			NestedFolders: Add library panels counting and deletion to folder registry (#69149)
* Expose library element service's folder service * Register library panels, add count implementation * Expand folder counts test * Update registry deletion method interface * Allow getting library elements from any folder * Add test for library panel deletion * Add test for library panel counting
This commit is contained in:
		
							parent
							
								
									32e2304f10
								
							
						
					
					
						commit
						20ffbbc41e
					
				|  | @ -1359,7 +1359,7 @@ func (l *mockLibraryElementService) CreateElement(c context.Context, signedInUse | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // GetElement gets an element from a UID.
 | // GetElement gets an element from a UID.
 | ||||||
| func (l *mockLibraryElementService) GetElement(c context.Context, signedInUser *user.SignedInUser, UID string) (model.LibraryElementDTO, error) { | func (l *mockLibraryElementService) GetElement(c context.Context, signedInUser *user.SignedInUser, cmd model.GetLibraryElementCommand) (model.LibraryElementDTO, error) { | ||||||
| 	return model.LibraryElementDTO{}, nil | 	return model.LibraryElementDTO{}, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -281,6 +281,12 @@ func (hs *HTTPServer) DeleteFolder(c *contextmodel.ReqContext) response.Response | ||||||
| 		} | 		} | ||||||
| 		return apierrors.ToFolderErrorResponse(err) | 		return apierrors.ToFolderErrorResponse(err) | ||||||
| 	} | 	} | ||||||
|  | 	/* TODO: after a decision regarding folder deletion permissions has been made | ||||||
|  | 	(https://github.com/grafana/grafana-enterprise/issues/5144),
 | ||||||
|  | 	remove the previous call to hs.LibraryElementService.DeleteLibraryElementsInFolder | ||||||
|  | 	and remove "user" from the signature of DeleteInFolder in the folder RegistryService. | ||||||
|  | 	Context: https://github.com/grafana/grafana/pull/69149#discussion_r1235057903
 | ||||||
|  | 	*/ | ||||||
| 
 | 
 | ||||||
| 	uid := web.Params(c.Req)[":uid"] | 	uid := web.Params(c.Req)[":uid"] | ||||||
| 	err = hs.folderService.Delete(c.Req.Context(), &folder.DeleteFolderCommand{UID: uid, OrgID: c.OrgID, ForceDeleteRules: c.QueryBool("forceDeleteRules"), SignedInUser: c.SignedInUser}) | 	err = hs.folderService.Delete(c.Req.Context(), &folder.DeleteFolderCommand{UID: uid, OrgID: c.OrgID, ForceDeleteRules: c.QueryBool("forceDeleteRules"), SignedInUser: c.SignedInUser}) | ||||||
|  |  | ||||||
|  | @ -635,7 +635,7 @@ func (dr DashboardServiceImpl) CountInFolder(ctx context.Context, orgID int64, f | ||||||
| 	return dr.dashboardStore.CountDashboardsInFolder(ctx, &dashboards.CountDashboardsInFolderRequest{FolderID: folder.ID, OrgID: orgID}) | 	return dr.dashboardStore.CountDashboardsInFolder(ctx, &dashboards.CountDashboardsInFolderRequest{FolderID: folder.ID, OrgID: orgID}) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (dr *DashboardServiceImpl) DeleteInFolder(ctx context.Context, orgID int64, folderUID string) error { | func (dr *DashboardServiceImpl) DeleteInFolder(ctx context.Context, orgID int64, folderUID string, u *user.SignedInUser) error { | ||||||
| 	return dr.dashboardStore.DeleteDashboardsInFolder(ctx, &dashboards.DeleteDashboardsInFolderRequest{FolderUID: folderUID, OrgID: orgID}) | 	return dr.dashboardStore.DeleteDashboardsInFolder(ctx, &dashboards.DeleteDashboardsInFolderRequest{FolderUID: folderUID, OrgID: orgID}) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -244,7 +244,7 @@ func TestDashboardService(t *testing.T) { | ||||||
| 		t.Run("Delete dashboards in folder", func(t *testing.T) { | 		t.Run("Delete dashboards in folder", func(t *testing.T) { | ||||||
| 			args := &dashboards.DeleteDashboardsInFolderRequest{OrgID: 1, FolderUID: "uid"} | 			args := &dashboards.DeleteDashboardsInFolderRequest{OrgID: 1, FolderUID: "uid"} | ||||||
| 			fakeStore.On("DeleteDashboardsInFolder", mock.Anything, args).Return(nil).Once() | 			fakeStore.On("DeleteDashboardsInFolder", mock.Anything, args).Return(nil).Once() | ||||||
| 			err := service.DeleteInFolder(context.Background(), 1, "uid") | 			err := service.DeleteInFolder(context.Background(), 1, "uid", nil) | ||||||
| 			require.NoError(t, err) | 			require.NoError(t, err) | ||||||
| 		}) | 		}) | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
|  | @ -509,7 +509,7 @@ func (s *Service) Delete(ctx context.Context, cmd *folder.DeleteFolderCommand) e | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			if cmd.ForceDeleteRules { | 			if cmd.ForceDeleteRules { | ||||||
| 				if err := s.deleteChildrenInFolder(ctx, dashFolder.OrgID, dashFolder.UID); err != nil { | 				if err := s.deleteChildrenInFolder(ctx, dashFolder.OrgID, dashFolder.UID, cmd.SignedInUser); err != nil { | ||||||
| 					return err | 					return err | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
|  | @ -525,9 +525,9 @@ func (s *Service) Delete(ctx context.Context, cmd *folder.DeleteFolderCommand) e | ||||||
| 	return err | 	return err | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Service) deleteChildrenInFolder(ctx context.Context, orgID int64, folderUID string) error { | func (s *Service) deleteChildrenInFolder(ctx context.Context, orgID int64, folderUID string, user *user.SignedInUser) error { | ||||||
| 	for _, v := range s.registry { | 	for _, v := range s.registry { | ||||||
| 		if err := v.DeleteInFolder(ctx, orgID, folderUID); err != nil { | 		if err := v.DeleteInFolder(ctx, orgID, folderUID, user); err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -12,6 +12,7 @@ import ( | ||||||
| 	"github.com/stretchr/testify/mock" | 	"github.com/stretchr/testify/mock" | ||||||
| 	"github.com/stretchr/testify/require" | 	"github.com/stretchr/testify/require" | ||||||
| 
 | 
 | ||||||
|  | 	"github.com/grafana/grafana/pkg/api/routing" | ||||||
| 	"github.com/grafana/grafana/pkg/bus" | 	"github.com/grafana/grafana/pkg/bus" | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" | 	"github.com/grafana/grafana/pkg/infra/db" | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db/dbtest" | 	"github.com/grafana/grafana/pkg/infra/db/dbtest" | ||||||
|  | @ -28,10 +29,14 @@ import ( | ||||||
| 	"github.com/grafana/grafana/pkg/services/folder" | 	"github.com/grafana/grafana/pkg/services/folder" | ||||||
| 	"github.com/grafana/grafana/pkg/services/folder/foldertest" | 	"github.com/grafana/grafana/pkg/services/folder/foldertest" | ||||||
| 	"github.com/grafana/grafana/pkg/services/guardian" | 	"github.com/grafana/grafana/pkg/services/guardian" | ||||||
|  | 	"github.com/grafana/grafana/pkg/services/libraryelements" | ||||||
|  | 	"github.com/grafana/grafana/pkg/services/libraryelements/model" | ||||||
|  | 	"github.com/grafana/grafana/pkg/services/librarypanels" | ||||||
| 	"github.com/grafana/grafana/pkg/services/ngalert/models" | 	"github.com/grafana/grafana/pkg/services/ngalert/models" | ||||||
| 	ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" | 	ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" | ||||||
| 	"github.com/grafana/grafana/pkg/services/quota/quotatest" | 	"github.com/grafana/grafana/pkg/services/quota/quotatest" | ||||||
| 	"github.com/grafana/grafana/pkg/services/sqlstore" | 	"github.com/grafana/grafana/pkg/services/sqlstore" | ||||||
|  | 	"github.com/grafana/grafana/pkg/services/store/entity" | ||||||
| 	"github.com/grafana/grafana/pkg/services/tag/tagimpl" | 	"github.com/grafana/grafana/pkg/services/tag/tagimpl" | ||||||
| 	"github.com/grafana/grafana/pkg/services/user" | 	"github.com/grafana/grafana/pkg/services/user" | ||||||
| 	"github.com/grafana/grafana/pkg/setting" | 	"github.com/grafana/grafana/pkg/setting" | ||||||
|  | @ -356,7 +361,9 @@ func TestIntegrationNestedFolderService(t *testing.T) { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	signedInUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{ | 	signedInUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{ | ||||||
| 		orgID: {dashboards.ActionFoldersCreate: {}, dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersAll}}, | 		orgID: { | ||||||
|  | 			dashboards.ActionFoldersCreate: {}, | ||||||
|  | 			dashboards.ActionFoldersWrite:  {dashboards.ScopeFoldersAll}}, | ||||||
| 	}} | 	}} | ||||||
| 	createCmd := folder.CreateFolderCommand{ | 	createCmd := folder.CreateFolderCommand{ | ||||||
| 		OrgID:        orgID, | 		OrgID:        orgID, | ||||||
|  | @ -364,6 +371,20 @@ func TestIntegrationNestedFolderService(t *testing.T) { | ||||||
| 		SignedInUser: &signedInUser, | 		SignedInUser: &signedInUser, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	libraryElementCmd := model.CreateLibraryElementCommand{ | ||||||
|  | 		Model: []byte(` | ||||||
|  | 		{ | ||||||
|  | 		  "datasource": "${DS_GDEV-TESTDATA}", | ||||||
|  | 		  "id": 1, | ||||||
|  | 		  "title": "Text - Library Panel", | ||||||
|  | 		  "type": "text", | ||||||
|  | 		  "description": "A description" | ||||||
|  | 		} | ||||||
|  | 	`), | ||||||
|  | 		Kind: int64(model.PanelElement), | ||||||
|  | 	} | ||||||
|  | 	routeRegister := routing.NewRouteRegister() | ||||||
|  | 
 | ||||||
| 	folderPermissions := acmock.NewMockedPermissionsService() | 	folderPermissions := acmock.NewMockedPermissionsService() | ||||||
| 	dashboardPermissions := acmock.NewMockedPermissionsService() | 	dashboardPermissions := acmock.NewMockedPermissionsService() | ||||||
| 
 | 
 | ||||||
|  | @ -371,7 +392,12 @@ func TestIntegrationNestedFolderService(t *testing.T) { | ||||||
| 		depth := 5 | 		depth := 5 | ||||||
| 		t.Run("With nested folder feature flag on", func(t *testing.T) { | 		t.Run("With nested folder feature flag on", func(t *testing.T) { | ||||||
| 			origNewGuardian := guardian.New | 			origNewGuardian := guardian.New | ||||||
| 			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true, CanViewValue: true}) | 			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ | ||||||
|  | 				CanSaveValue: true, | ||||||
|  | 				CanViewValue: true, | ||||||
|  | 				// CanEditValue is required to create library elements
 | ||||||
|  | 				CanEditValue: true, | ||||||
|  | 			}) | ||||||
| 
 | 
 | ||||||
| 			dashSrv, err := service.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, nil, featuresFlagOn, folderPermissions, dashboardPermissions, ac, serviceWithFlagOn) | 			dashSrv, err := service.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, nil, featuresFlagOn, folderPermissions, dashboardPermissions, ac, serviceWithFlagOn) | ||||||
| 			require.NoError(t, err) | 			require.NoError(t, err) | ||||||
|  | @ -379,6 +405,10 @@ func TestIntegrationNestedFolderService(t *testing.T) { | ||||||
| 			alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOn, db, serviceWithFlagOn, ac, dashSrv) | 			alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOn, db, serviceWithFlagOn, ac, dashSrv) | ||||||
| 			require.NoError(t, err) | 			require.NoError(t, err) | ||||||
| 
 | 
 | ||||||
|  | 			elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOn, featuresFlagOn) | ||||||
|  | 			lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, serviceWithFlagOn) | ||||||
|  | 			require.NoError(t, err) | ||||||
|  | 
 | ||||||
| 			ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "getDescendantCountsOn", createCmd) | 			ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "getDescendantCountsOn", createCmd) | ||||||
| 
 | 
 | ||||||
| 			parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) | 			parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) | ||||||
|  | @ -390,6 +420,13 @@ func TestIntegrationNestedFolderService(t *testing.T) { | ||||||
| 			_ = createRule(t, alertStore, parent.UID, "parent alert") | 			_ = createRule(t, alertStore, parent.UID, "parent alert") | ||||||
| 			_ = createRule(t, alertStore, subfolder.UID, "sub alert") | 			_ = createRule(t, alertStore, subfolder.UID, "sub alert") | ||||||
| 
 | 
 | ||||||
|  | 			libraryElementCmd.FolderID = parent.ID | ||||||
|  | 			_, err = lps.LibraryElementService.CreateElement(context.Background(), &signedInUser, libraryElementCmd) | ||||||
|  | 			require.NoError(t, err) | ||||||
|  | 			libraryElementCmd.FolderID = subfolder.ID | ||||||
|  | 			_, err = lps.LibraryElementService.CreateElement(context.Background(), &signedInUser, libraryElementCmd) | ||||||
|  | 			require.NoError(t, err) | ||||||
|  | 
 | ||||||
| 			countCmd := folder.GetDescendantCountsQuery{ | 			countCmd := folder.GetDescendantCountsQuery{ | ||||||
| 				UID:          &ancestorUIDs[0], | 				UID:          &ancestorUIDs[0], | ||||||
| 				OrgID:        orgID, | 				OrgID:        orgID, | ||||||
|  | @ -397,9 +434,10 @@ func TestIntegrationNestedFolderService(t *testing.T) { | ||||||
| 			} | 			} | ||||||
| 			m, err := serviceWithFlagOn.GetDescendantCounts(context.Background(), &countCmd) | 			m, err := serviceWithFlagOn.GetDescendantCounts(context.Background(), &countCmd) | ||||||
| 			require.NoError(t, err) | 			require.NoError(t, err) | ||||||
| 			require.Equal(t, int64(depth-1), m["folder"]) | 			require.Equal(t, int64(depth-1), m[entity.StandardKindFolder]) | ||||||
| 			require.Equal(t, int64(2), m["dashboard"]) | 			require.Equal(t, int64(2), m[entity.StandardKindDashboard]) | ||||||
| 			require.Equal(t, int64(2), m["alertrule"]) | 			require.Equal(t, int64(2), m[entity.StandardKindAlertRule]) | ||||||
|  | 			require.Equal(t, int64(2), m[entity.StandardKindLibraryPanel]) | ||||||
| 
 | 
 | ||||||
| 			t.Cleanup(func() { | 			t.Cleanup(func() { | ||||||
| 				guardian.New = origNewGuardian | 				guardian.New = origNewGuardian | ||||||
|  | @ -428,7 +466,12 @@ func TestIntegrationNestedFolderService(t *testing.T) { | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			origNewGuardian := guardian.New | 			origNewGuardian := guardian.New | ||||||
| 			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true, CanViewValue: true}) | 			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ | ||||||
|  | 				CanSaveValue: true, | ||||||
|  | 				CanViewValue: true, | ||||||
|  | 				// CanEditValue is required to create library elements
 | ||||||
|  | 				CanEditValue: true, | ||||||
|  | 			}) | ||||||
| 
 | 
 | ||||||
| 			dashSrv, err := service.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, nil, featuresFlagOff, | 			dashSrv, err := service.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, nil, featuresFlagOff, | ||||||
| 				folderPermissions, dashboardPermissions, ac, serviceWithFlagOff) | 				folderPermissions, dashboardPermissions, ac, serviceWithFlagOff) | ||||||
|  | @ -437,6 +480,10 @@ func TestIntegrationNestedFolderService(t *testing.T) { | ||||||
| 			alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOff, db, serviceWithFlagOff, ac, dashSrv) | 			alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOff, db, serviceWithFlagOff, ac, dashSrv) | ||||||
| 			require.NoError(t, err) | 			require.NoError(t, err) | ||||||
| 
 | 
 | ||||||
|  | 			elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOff, featuresFlagOff) | ||||||
|  | 			lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, serviceWithFlagOff) | ||||||
|  | 			require.NoError(t, err) | ||||||
|  | 
 | ||||||
| 			ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "getDescendantCountsOff", createCmd) | 			ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "getDescendantCountsOff", createCmd) | ||||||
| 
 | 
 | ||||||
| 			parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) | 			parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) | ||||||
|  | @ -448,6 +495,13 @@ func TestIntegrationNestedFolderService(t *testing.T) { | ||||||
| 			_ = createRule(t, alertStore, parent.UID, "parent alert") | 			_ = createRule(t, alertStore, parent.UID, "parent alert") | ||||||
| 			_ = createRule(t, alertStore, subfolder.UID, "sub alert") | 			_ = createRule(t, alertStore, subfolder.UID, "sub alert") | ||||||
| 
 | 
 | ||||||
|  | 			libraryElementCmd.FolderID = parent.ID | ||||||
|  | 			_, err = lps.LibraryElementService.CreateElement(context.Background(), &signedInUser, libraryElementCmd) | ||||||
|  | 			require.NoError(t, err) | ||||||
|  | 			libraryElementCmd.FolderID = subfolder.ID | ||||||
|  | 			_, err = lps.LibraryElementService.CreateElement(context.Background(), &signedInUser, libraryElementCmd) | ||||||
|  | 			require.NoError(t, err) | ||||||
|  | 
 | ||||||
| 			countCmd := folder.GetDescendantCountsQuery{ | 			countCmd := folder.GetDescendantCountsQuery{ | ||||||
| 				UID:          &ancestorUIDs[0], | 				UID:          &ancestorUIDs[0], | ||||||
| 				OrgID:        orgID, | 				OrgID:        orgID, | ||||||
|  | @ -455,9 +509,10 @@ func TestIntegrationNestedFolderService(t *testing.T) { | ||||||
| 			} | 			} | ||||||
| 			m, err := serviceWithFlagOff.GetDescendantCounts(context.Background(), &countCmd) | 			m, err := serviceWithFlagOff.GetDescendantCounts(context.Background(), &countCmd) | ||||||
| 			require.NoError(t, err) | 			require.NoError(t, err) | ||||||
| 			require.Equal(t, int64(0), m["folder"]) | 			require.Equal(t, int64(0), m[entity.StandardKindFolder]) | ||||||
| 			require.Equal(t, int64(1), m["dashboard"]) | 			require.Equal(t, int64(1), m[entity.StandardKindDashboard]) | ||||||
| 			require.Equal(t, int64(1), m["alertrule"]) | 			require.Equal(t, int64(1), m[entity.StandardKindAlertRule]) | ||||||
|  | 			require.Equal(t, int64(1), m[entity.StandardKindLibraryPanel]) | ||||||
| 
 | 
 | ||||||
| 			t.Cleanup(func() { | 			t.Cleanup(func() { | ||||||
| 				guardian.New = origNewGuardian | 				guardian.New = origNewGuardian | ||||||
|  | @ -470,169 +525,158 @@ func TestIntegrationNestedFolderService(t *testing.T) { | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
| 	t.Run("Should delete folders", func(t *testing.T) { | 	t.Run("Should delete folders", func(t *testing.T) { | ||||||
| 		t.Run("With nested folder feature flag on", func(t *testing.T) { | 		featuresFlagOff := featuremgmt.WithFeatures() | ||||||
| 			dashSrv, err := service.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, nil, featuresFlagOn, folderPermissions, dashboardPermissions, ac, serviceWithFlagOn) | 		serviceWithFlagOff := &Service{ | ||||||
| 			require.NoError(t, err) | 			cfg:                  cfg, | ||||||
|  | 			log:                  log.New("test-folder-service"), | ||||||
|  | 			dashboardFolderStore: folderStore, | ||||||
|  | 			features:             featuresFlagOff, | ||||||
|  | 			bus:                  b, | ||||||
|  | 			db:                   db, | ||||||
|  | 			registry:             make(map[string]folder.RegistryService), | ||||||
|  | 		} | ||||||
| 
 | 
 | ||||||
| 			alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOn, db, serviceWithFlagOn, ac, dashSrv) | 		testCases := []struct { | ||||||
| 			require.NoError(t, err) | 			service           *Service | ||||||
| 			t.Run("With force deletion of rules", func(t *testing.T) { | 			featuresFlag      *featuremgmt.FeatureManager | ||||||
|  | 			prefix            string | ||||||
|  | 			depth             int | ||||||
|  | 			forceDelete       bool | ||||||
|  | 			deletionErr       error | ||||||
|  | 			dashboardErr      error | ||||||
|  | 			folderErr         error | ||||||
|  | 			libPanelParentErr error | ||||||
|  | 			libPanelSubErr    error | ||||||
|  | 			desc              string | ||||||
|  | 		}{ | ||||||
|  | 			{ | ||||||
|  | 				service:           serviceWithFlagOn, | ||||||
|  | 				featuresFlag:      featuresFlagOn, | ||||||
|  | 				prefix:            "flagon-force", | ||||||
|  | 				depth:             3, | ||||||
|  | 				forceDelete:       true, | ||||||
|  | 				dashboardErr:      dashboards.ErrFolderNotFound, | ||||||
|  | 				folderErr:         folder.ErrFolderNotFound, | ||||||
|  | 				libPanelParentErr: model.ErrLibraryElementNotFound, | ||||||
|  | 				libPanelSubErr:    model.ErrLibraryElementNotFound, | ||||||
|  | 				desc:              "With nested folder feature flag on and force deletion of rules", | ||||||
|  | 			}, | ||||||
|  | 			{ | ||||||
|  | 				service:      serviceWithFlagOn, | ||||||
|  | 				featuresFlag: featuresFlagOn, | ||||||
|  | 				prefix:       "flagon-noforce", | ||||||
|  | 				depth:        3, | ||||||
|  | 				forceDelete:  false, | ||||||
|  | 				deletionErr:  dashboards.ErrFolderContainsAlertRules, | ||||||
|  | 				desc:         "With nested folder feature flag on and no force deletion of rules", | ||||||
|  | 			}, | ||||||
|  | 			{ | ||||||
|  | 				service:           serviceWithFlagOff, | ||||||
|  | 				featuresFlag:      featuresFlagOff, | ||||||
|  | 				prefix:            "flagoff-force", | ||||||
|  | 				depth:             1, | ||||||
|  | 				forceDelete:       true, | ||||||
|  | 				dashboardErr:      dashboards.ErrFolderNotFound, | ||||||
|  | 				libPanelParentErr: model.ErrLibraryElementNotFound, | ||||||
|  | 				desc:              "With nested folder feature flag off and force deletion of rules", | ||||||
|  | 			}, | ||||||
|  | 			{ | ||||||
|  | 				service:      serviceWithFlagOff, | ||||||
|  | 				featuresFlag: featuresFlagOff, | ||||||
|  | 				prefix:       "flagoff-noforce", | ||||||
|  | 				depth:        1, | ||||||
|  | 				forceDelete:  false, | ||||||
|  | 				deletionErr:  dashboards.ErrFolderContainsAlertRules, | ||||||
|  | 				desc:         "With nested folder feature flag off and no force deletion of rules", | ||||||
|  | 			}, | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		for _, tc := range testCases { | ||||||
|  | 			t.Run(tc.desc, func(t *testing.T) { | ||||||
| 				origNewGuardian := guardian.New | 				origNewGuardian := guardian.New | ||||||
| 				guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true, CanViewValue: true}) | 				guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ | ||||||
|  | 					CanSaveValue: true, | ||||||
|  | 					CanViewValue: true, | ||||||
|  | 					// CanEditValue is required to create library elements
 | ||||||
|  | 					CanEditValue: true, | ||||||
|  | 				}) | ||||||
| 
 | 
 | ||||||
| 				ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, 3, "with-force", createCmd) | 				elementService := libraryelements.ProvideService(cfg, db, routeRegister, tc.service, tc.featuresFlag) | ||||||
|  | 				lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, tc.service) | ||||||
|  | 				require.NoError(t, err) | ||||||
|  | 
 | ||||||
|  | 				dashStore, err := database.ProvideDashboardStore(db, db.Cfg, tc.featuresFlag, tagimpl.ProvideService(db, db.Cfg), quotaService) | ||||||
|  | 				require.NoError(t, err) | ||||||
|  | 				nestedFolderStore := ProvideStore(db, db.Cfg, tc.featuresFlag) | ||||||
|  | 				tc.service.dashboardStore = dashStore | ||||||
|  | 				tc.service.store = nestedFolderStore | ||||||
|  | 
 | ||||||
|  | 				dashSrv, err := service.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, nil, tc.featuresFlag, folderPermissions, dashboardPermissions, ac, tc.service) | ||||||
|  | 				require.NoError(t, err) | ||||||
|  | 				alertStore, err := ngstore.ProvideDBStore(cfg, tc.featuresFlag, db, tc.service, ac, dashSrv) | ||||||
|  | 				require.NoError(t, err) | ||||||
|  | 
 | ||||||
|  | 				ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, tc.depth, tc.prefix, createCmd) | ||||||
| 
 | 
 | ||||||
| 				parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) | 				parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) | ||||||
| 				require.NoError(t, err) | 				require.NoError(t, err) | ||||||
| 				subfolder, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[1]) |  | ||||||
| 				require.NoError(t, err) |  | ||||||
| 				_ = createRule(t, alertStore, parent.UID, "parent alert") | 				_ = createRule(t, alertStore, parent.UID, "parent alert") | ||||||
| 				_ = createRule(t, alertStore, subfolder.UID, "sub alert") | 
 | ||||||
|  | 				var ( | ||||||
|  | 					subfolder *folder.Folder | ||||||
|  | 					subPanel  model.LibraryElementDTO | ||||||
|  | 				) | ||||||
|  | 				if tc.depth > 1 { | ||||||
|  | 					subfolder, err = serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[1]) | ||||||
|  | 					require.NoError(t, err) | ||||||
|  | 					_ = createRule(t, alertStore, subfolder.UID, "sub alert") | ||||||
|  | 					libraryElementCmd.FolderID = subfolder.ID | ||||||
|  | 					subPanel, err = lps.LibraryElementService.CreateElement(context.Background(), &signedInUser, libraryElementCmd) | ||||||
|  | 					require.NoError(t, err) | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 				libraryElementCmd.FolderID = parent.ID | ||||||
|  | 				parentPanel, err := lps.LibraryElementService.CreateElement(context.Background(), &signedInUser, libraryElementCmd) | ||||||
|  | 				require.NoError(t, err) | ||||||
| 
 | 
 | ||||||
| 				deleteCmd := folder.DeleteFolderCommand{ | 				deleteCmd := folder.DeleteFolderCommand{ | ||||||
| 					UID:              ancestorUIDs[0], | 					UID:              ancestorUIDs[0], | ||||||
| 					OrgID:            orgID, | 					OrgID:            orgID, | ||||||
| 					SignedInUser:     &signedInUser, | 					SignedInUser:     &signedInUser, | ||||||
| 					ForceDeleteRules: true, | 					ForceDeleteRules: tc.forceDelete, | ||||||
| 				} | 				} | ||||||
| 				err = serviceWithFlagOn.Delete(context.Background(), &deleteCmd) | 
 | ||||||
| 				require.NoError(t, err) | 				err = tc.service.Delete(context.Background(), &deleteCmd) | ||||||
|  | 				require.ErrorIs(t, err, tc.deletionErr) | ||||||
| 
 | 
 | ||||||
| 				for i, uid := range ancestorUIDs { | 				for i, uid := range ancestorUIDs { | ||||||
| 					// dashboard table
 | 					// dashboard table
 | ||||||
| 					_, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, uid) | 					_, err := tc.service.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, uid) | ||||||
| 					require.ErrorIs(t, err, dashboards.ErrFolderNotFound) | 					require.ErrorIs(t, err, tc.dashboardErr) | ||||||
| 					// folder table
 | 					// folder table
 | ||||||
| 					_, err = serviceWithFlagOn.store.Get(context.Background(), folder.GetFolderQuery{UID: &ancestorUIDs[i], OrgID: orgID}) | 					_, err = tc.service.store.Get(context.Background(), folder.GetFolderQuery{UID: &ancestorUIDs[i], OrgID: orgID}) | ||||||
| 					require.ErrorIs(t, err, folder.ErrFolderNotFound) | 					require.ErrorIs(t, err, tc.folderErr) | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 				_, err = lps.LibraryElementService.GetElement(context.Background(), &signedInUser, model.GetLibraryElementCommand{ | ||||||
|  | 					FolderName: parent.Title, | ||||||
|  | 					FolderID:   parent.ID, | ||||||
|  | 					UID:        parentPanel.UID, | ||||||
|  | 				}) | ||||||
|  | 				require.ErrorIs(t, err, tc.libPanelParentErr) | ||||||
|  | 				if tc.depth > 1 { | ||||||
|  | 					_, err = lps.LibraryElementService.GetElement(context.Background(), &signedInUser, model.GetLibraryElementCommand{ | ||||||
|  | 						FolderName: subfolder.Title, | ||||||
|  | 						FolderID:   subfolder.ID, | ||||||
|  | 						UID:        subPanel.UID, | ||||||
|  | 					}) | ||||||
|  | 					require.ErrorIs(t, err, tc.libPanelSubErr) | ||||||
| 				} | 				} | ||||||
| 				t.Cleanup(func() { | 				t.Cleanup(func() { | ||||||
| 					guardian.New = origNewGuardian | 					guardian.New = origNewGuardian | ||||||
| 				}) | 				}) | ||||||
| 			}) | 			}) | ||||||
| 			t.Run("Without force deletion of rules", func(t *testing.T) { | 		} | ||||||
| 				origNewGuardian := guardian.New |  | ||||||
| 				guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true, CanViewValue: true}) |  | ||||||
| 
 |  | ||||||
| 				ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, 3, "without-force", createCmd) |  | ||||||
| 
 |  | ||||||
| 				parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) |  | ||||||
| 				require.NoError(t, err) |  | ||||||
| 				subfolder, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[1]) |  | ||||||
| 				require.NoError(t, err) |  | ||||||
| 				_ = createRule(t, alertStore, parent.UID, "parent alert") |  | ||||||
| 				_ = createRule(t, alertStore, subfolder.UID, "sub alert") |  | ||||||
| 
 |  | ||||||
| 				deleteCmd := folder.DeleteFolderCommand{ |  | ||||||
| 					UID:              ancestorUIDs[0], |  | ||||||
| 					OrgID:            orgID, |  | ||||||
| 					SignedInUser:     &signedInUser, |  | ||||||
| 					ForceDeleteRules: false, |  | ||||||
| 				} |  | ||||||
| 				err = serviceWithFlagOn.Delete(context.Background(), &deleteCmd) |  | ||||||
| 				require.Error(t, dashboards.ErrFolderContainsAlertRules, err) |  | ||||||
| 
 |  | ||||||
| 				for i, uid := range ancestorUIDs { |  | ||||||
| 					// dashboard table
 |  | ||||||
| 					_, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, uid) |  | ||||||
| 					require.NoError(t, err) |  | ||||||
| 					// folder table
 |  | ||||||
| 					_, err = serviceWithFlagOn.store.Get(context.Background(), folder.GetFolderQuery{UID: &ancestorUIDs[i], OrgID: orgID}) |  | ||||||
| 					require.NoError(t, err) |  | ||||||
| 				} |  | ||||||
| 				t.Cleanup(func() { |  | ||||||
| 					guardian.New = origNewGuardian |  | ||||||
| 				}) |  | ||||||
| 			}) |  | ||||||
| 		}) |  | ||||||
| 		t.Run("With nested folder feature flag off", func(t *testing.T) { |  | ||||||
| 			featuresFlagOff := featuremgmt.WithFeatures() |  | ||||||
| 			dashStore, err := database.ProvideDashboardStore(db, db.Cfg, featuresFlagOff, tagimpl.ProvideService(db, db.Cfg), quotaService) |  | ||||||
| 			require.NoError(t, err) |  | ||||||
| 			nestedFolderStore := ProvideStore(db, db.Cfg, featuresFlagOff) |  | ||||||
| 
 |  | ||||||
| 			dashSrv, err := service.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, nil, featuresFlagOff, folderPermissions, dashboardPermissions, ac, serviceWithFlagOn) |  | ||||||
| 			require.NoError(t, err) |  | ||||||
| 			alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOff, db, serviceWithFlagOn, ac, dashSrv) |  | ||||||
| 			require.NoError(t, err) |  | ||||||
| 
 |  | ||||||
| 			serviceWithFlagOff := &Service{ |  | ||||||
| 				cfg:                  cfg, |  | ||||||
| 				log:                  log.New("test-folder-service"), |  | ||||||
| 				dashboardStore:       dashStore, |  | ||||||
| 				dashboardFolderStore: folderStore, |  | ||||||
| 				store:                nestedFolderStore, |  | ||||||
| 				features:             featuresFlagOff, |  | ||||||
| 				bus:                  b, |  | ||||||
| 				db:                   db, |  | ||||||
| 			} |  | ||||||
| 			t.Run("With force deletion of rules", func(t *testing.T) { |  | ||||||
| 				origNewGuardian := guardian.New |  | ||||||
| 				guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true, CanViewValue: true}) |  | ||||||
| 
 |  | ||||||
| 				ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, 1, "off-force", createCmd) |  | ||||||
| 
 |  | ||||||
| 				parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) |  | ||||||
| 				require.NoError(t, err) |  | ||||||
| 				_ = createRule(t, alertStore, parent.UID, "parent alert") |  | ||||||
| 
 |  | ||||||
| 				deleteCmd := folder.DeleteFolderCommand{ |  | ||||||
| 					UID:              ancestorUIDs[0], |  | ||||||
| 					OrgID:            orgID, |  | ||||||
| 					SignedInUser:     &signedInUser, |  | ||||||
| 					ForceDeleteRules: true, |  | ||||||
| 				} |  | ||||||
| 				err = serviceWithFlagOff.Delete(context.Background(), &deleteCmd) |  | ||||||
| 				require.NoError(t, err) |  | ||||||
| 
 |  | ||||||
| 				// dashboard table
 |  | ||||||
| 				_, err = serviceWithFlagOff.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) |  | ||||||
| 				require.ErrorIs(t, err, dashboards.ErrFolderNotFound) |  | ||||||
| 				// folder table
 |  | ||||||
| 				_, err = serviceWithFlagOff.store.Get(context.Background(), folder.GetFolderQuery{UID: &ancestorUIDs[0], OrgID: orgID}) |  | ||||||
| 				require.NoError(t, err) |  | ||||||
| 				t.Cleanup(func() { |  | ||||||
| 					guardian.New = origNewGuardian |  | ||||||
| 					for _, uid := range ancestorUIDs { |  | ||||||
| 						err := serviceWithFlagOff.store.Delete(context.Background(), uid, orgID) |  | ||||||
| 						require.NoError(t, err) |  | ||||||
| 					} |  | ||||||
| 				}) |  | ||||||
| 			}) |  | ||||||
| 			t.Run("Without force deletion of rules", func(t *testing.T) { |  | ||||||
| 				origNewGuardian := guardian.New |  | ||||||
| 				guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true, CanViewValue: true}) |  | ||||||
| 
 |  | ||||||
| 				ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, 1, "off-no-force", createCmd) |  | ||||||
| 
 |  | ||||||
| 				parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) |  | ||||||
| 				require.NoError(t, err) |  | ||||||
| 				_ = createRule(t, alertStore, parent.UID, "parent alert") |  | ||||||
| 
 |  | ||||||
| 				deleteCmd := folder.DeleteFolderCommand{ |  | ||||||
| 					UID:              ancestorUIDs[0], |  | ||||||
| 					OrgID:            orgID, |  | ||||||
| 					SignedInUser:     &signedInUser, |  | ||||||
| 					ForceDeleteRules: false, |  | ||||||
| 				} |  | ||||||
| 				err = serviceWithFlagOff.Delete(context.Background(), &deleteCmd) |  | ||||||
| 				require.Error(t, dashboards.ErrFolderContainsAlertRules, err) |  | ||||||
| 
 |  | ||||||
| 				// dashboard table
 |  | ||||||
| 				_, err = serviceWithFlagOff.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) |  | ||||||
| 				require.NoError(t, err) |  | ||||||
| 				// folder table
 |  | ||||||
| 				_, err = serviceWithFlagOff.store.Get(context.Background(), folder.GetFolderQuery{UID: &ancestorUIDs[0], OrgID: orgID}) |  | ||||||
| 				require.NoError(t, err) |  | ||||||
| 				t.Cleanup(func() { |  | ||||||
| 					guardian.New = origNewGuardian |  | ||||||
| 					for _, uid := range ancestorUIDs { |  | ||||||
| 						err := serviceWithFlagOff.store.Delete(context.Background(), uid, orgID) |  | ||||||
| 						require.NoError(t, err) |  | ||||||
| 					} |  | ||||||
| 				}) |  | ||||||
| 			}) |  | ||||||
| 		}) |  | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ import ( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type RegistryService interface { | type RegistryService interface { | ||||||
| 	DeleteInFolder(ctx context.Context, orgID int64, folderUID string) error | 	DeleteInFolder(ctx context.Context, orgID int64, folderUID string, user *user.SignedInUser) error | ||||||
| 	CountInFolder(ctx context.Context, orgID int64, folderUID string, user *user.SignedInUser) (int64, error) | 	CountInFolder(ctx context.Context, orgID int64, folderUID string, user *user.SignedInUser) (int64, error) | ||||||
| 	Kind() string | 	Kind() string | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -112,7 +112,12 @@ func (l *LibraryElementService) deleteHandler(c *contextmodel.ReqContext) respon | ||||||
| // 404: notFoundError
 | // 404: notFoundError
 | ||||||
| // 500: internalServerError
 | // 500: internalServerError
 | ||||||
| func (l *LibraryElementService) getHandler(c *contextmodel.ReqContext) response.Response { | func (l *LibraryElementService) getHandler(c *contextmodel.ReqContext) response.Response { | ||||||
| 	element, err := l.getLibraryElementByUid(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"]) | 	element, err := l.getLibraryElementByUid(c.Req.Context(), c.SignedInUser, | ||||||
|  | 		model.GetLibraryElementCommand{ | ||||||
|  | 			UID:        web.Params(c.Req)[":uid"], | ||||||
|  | 			FolderName: dashboards.RootFolderName, | ||||||
|  | 		}, | ||||||
|  | 	) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return toLibraryElementError(err, "Failed to get library element") | 		return toLibraryElementError(err, "Failed to get library element") | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -228,7 +228,7 @@ func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedIn | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // getLibraryElements gets a Library Element where param == value
 | // getLibraryElements gets a Library Element where param == value
 | ||||||
| func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signedInUser *user.SignedInUser, params []Pair, features featuremgmt.FeatureToggles) ([]model.LibraryElementDTO, error) { | func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signedInUser *user.SignedInUser, params []Pair, features featuremgmt.FeatureToggles, cmd model.GetLibraryElementCommand) ([]model.LibraryElementDTO, error) { | ||||||
| 	libraryElements := make([]model.LibraryElementWithMeta, 0) | 	libraryElements := make([]model.LibraryElementWithMeta, 0) | ||||||
| 
 | 
 | ||||||
| 	recursiveQueriesAreSupported, err := store.RecursiveQueriesAreSupported() | 	recursiveQueriesAreSupported, err := store.RecursiveQueriesAreSupported() | ||||||
|  | @ -239,10 +239,10 @@ func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signed | ||||||
| 	err = store.WithDbSession(c, func(session *db.Session) error { | 	err = store.WithDbSession(c, func(session *db.Session) error { | ||||||
| 		builder := db.NewSqlBuilder(cfg, features, store.GetDialect(), recursiveQueriesAreSupported) | 		builder := db.NewSqlBuilder(cfg, features, store.GetDialect(), recursiveQueriesAreSupported) | ||||||
| 		builder.Write(selectLibraryElementDTOWithMeta) | 		builder.Write(selectLibraryElementDTOWithMeta) | ||||||
| 		builder.Write(", 'General' as folder_name ") | 		builder.Write(", ? as folder_name ", cmd.FolderName) | ||||||
| 		builder.Write(", '' as folder_uid ") | 		builder.Write(", '' as folder_uid ") | ||||||
| 		builder.Write(getFromLibraryElementDTOWithMeta(store.GetDialect())) | 		builder.Write(getFromLibraryElementDTOWithMeta(store.GetDialect())) | ||||||
| 		writeParamSelectorSQL(&builder, append(params, Pair{"folder_id", 0})...) | 		writeParamSelectorSQL(&builder, append(params, Pair{"folder_id", cmd.FolderID})...) | ||||||
| 		builder.Write(" UNION ") | 		builder.Write(" UNION ") | ||||||
| 		builder.Write(selectLibraryElementDTOWithMeta) | 		builder.Write(selectLibraryElementDTOWithMeta) | ||||||
| 		builder.Write(", dashboard.title as folder_name ") | 		builder.Write(", dashboard.title as folder_name ") | ||||||
|  | @ -303,8 +303,8 @@ func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signed | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // getLibraryElementByUid gets a Library Element by uid.
 | // getLibraryElementByUid gets a Library Element by uid.
 | ||||||
| func (l *LibraryElementService) getLibraryElementByUid(c context.Context, signedInUser *user.SignedInUser, UID string) (model.LibraryElementDTO, error) { | func (l *LibraryElementService) getLibraryElementByUid(c context.Context, signedInUser *user.SignedInUser, cmd model.GetLibraryElementCommand) (model.LibraryElementDTO, error) { | ||||||
| 	libraryElements, err := getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{key: "org_id", value: signedInUser.OrgID}, {key: "uid", value: UID}}, l.features) | 	libraryElements, err := getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{key: "org_id", value: signedInUser.OrgID}, {key: "uid", value: cmd.UID}}, l.features, cmd) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return model.LibraryElementDTO{}, err | 		return model.LibraryElementDTO{}, err | ||||||
| 	} | 	} | ||||||
|  | @ -317,7 +317,10 @@ func (l *LibraryElementService) getLibraryElementByUid(c context.Context, signed | ||||||
| 
 | 
 | ||||||
| // getLibraryElementByName gets a Library Element by name.
 | // getLibraryElementByName gets a Library Element by name.
 | ||||||
| func (l *LibraryElementService) getLibraryElementsByName(c context.Context, signedInUser *user.SignedInUser, name string) ([]model.LibraryElementDTO, error) { | func (l *LibraryElementService) getLibraryElementsByName(c context.Context, signedInUser *user.SignedInUser, name string) ([]model.LibraryElementDTO, error) { | ||||||
| 	return getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{"org_id", signedInUser.OrgID}, {"name", name}}, l.features) | 	return getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{"org_id", signedInUser.OrgID}, {"name", name}}, l.features, | ||||||
|  | 		model.GetLibraryElementCommand{ | ||||||
|  | 			FolderName: dashboards.RootFolderName, | ||||||
|  | 		}) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // getAllLibraryElements gets all Library Elements.
 | // getAllLibraryElements gets all Library Elements.
 | ||||||
|  |  | ||||||
|  | @ -29,7 +29,7 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.Rout | ||||||
| // Service is a service for operating on library elements.
 | // Service is a service for operating on library elements.
 | ||||||
| type Service interface { | type Service interface { | ||||||
| 	CreateElement(c context.Context, signedInUser *user.SignedInUser, cmd model.CreateLibraryElementCommand) (model.LibraryElementDTO, error) | 	CreateElement(c context.Context, signedInUser *user.SignedInUser, cmd model.CreateLibraryElementCommand) (model.LibraryElementDTO, error) | ||||||
| 	GetElement(c context.Context, signedInUser *user.SignedInUser, UID string) (model.LibraryElementDTO, error) | 	GetElement(c context.Context, signedInUser *user.SignedInUser, cmd model.GetLibraryElementCommand) (model.LibraryElementDTO, error) | ||||||
| 	GetElementsForDashboard(c context.Context, dashboardID int64) (map[string]model.LibraryElementDTO, error) | 	GetElementsForDashboard(c context.Context, dashboardID int64) (map[string]model.LibraryElementDTO, error) | ||||||
| 	ConnectElementsToDashboard(c context.Context, signedInUser *user.SignedInUser, elementUIDs []string, dashboardID int64) error | 	ConnectElementsToDashboard(c context.Context, signedInUser *user.SignedInUser, elementUIDs []string, dashboardID int64) error | ||||||
| 	DisconnectElementsFromDashboard(c context.Context, dashboardID int64) error | 	DisconnectElementsFromDashboard(c context.Context, dashboardID int64) error | ||||||
|  | @ -52,8 +52,8 @@ func (l *LibraryElementService) CreateElement(c context.Context, signedInUser *u | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // GetElement gets an element from a UID.
 | // GetElement gets an element from a UID.
 | ||||||
| func (l *LibraryElementService) GetElement(c context.Context, signedInUser *user.SignedInUser, UID string) (model.LibraryElementDTO, error) { | func (l *LibraryElementService) GetElement(c context.Context, signedInUser *user.SignedInUser, cmd model.GetLibraryElementCommand) (model.LibraryElementDTO, error) { | ||||||
| 	return l.getLibraryElementByUid(c, signedInUser, UID) | 	return l.getLibraryElementByUid(c, signedInUser, cmd) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // GetElementsForDashboard gets all connected elements for a specific dashboard.
 | // GetElementsForDashboard gets all connected elements for a specific dashboard.
 | ||||||
|  |  | ||||||
|  | @ -230,6 +230,13 @@ type PatchLibraryElementCommand struct { | ||||||
| 	UID string `json:"uid"` | 	UID string `json:"uid"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // GetLibraryElementCommand is the command for getting a library element.
 | ||||||
|  | type GetLibraryElementCommand struct { | ||||||
|  | 	FolderName string | ||||||
|  | 	FolderID   int64 | ||||||
|  | 	UID        string | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // SearchLibraryElementsQuery is the query used for searching for Elements
 | // SearchLibraryElementsQuery is the query used for searching for Elements
 | ||||||
| type SearchLibraryElementsQuery struct { | type SearchLibraryElementsQuery struct { | ||||||
| 	PerPage          int | 	PerPage          int | ||||||
|  |  | ||||||
|  | @ -10,21 +10,30 @@ import ( | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" | 	"github.com/grafana/grafana/pkg/infra/db" | ||||||
| 	"github.com/grafana/grafana/pkg/infra/log" | 	"github.com/grafana/grafana/pkg/infra/log" | ||||||
| 	"github.com/grafana/grafana/pkg/services/dashboards" | 	"github.com/grafana/grafana/pkg/services/dashboards" | ||||||
|  | 	"github.com/grafana/grafana/pkg/services/folder" | ||||||
| 	"github.com/grafana/grafana/pkg/services/libraryelements" | 	"github.com/grafana/grafana/pkg/services/libraryelements" | ||||||
| 	"github.com/grafana/grafana/pkg/services/libraryelements/model" | 	"github.com/grafana/grafana/pkg/services/libraryelements/model" | ||||||
|  | 	"github.com/grafana/grafana/pkg/services/store/entity" | ||||||
| 	"github.com/grafana/grafana/pkg/services/user" | 	"github.com/grafana/grafana/pkg/services/user" | ||||||
| 	"github.com/grafana/grafana/pkg/setting" | 	"github.com/grafana/grafana/pkg/setting" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.RouteRegister, | func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.RouteRegister, | ||||||
| 	libraryElementService libraryelements.Service) *LibraryPanelService { | 	libraryElementService libraryelements.Service, folderService folder.Service) (*LibraryPanelService, error) { | ||||||
| 	return &LibraryPanelService{ | 	lps := LibraryPanelService{ | ||||||
| 		Cfg:                   cfg, | 		Cfg:                   cfg, | ||||||
| 		SQLStore:              sqlStore, | 		SQLStore:              sqlStore, | ||||||
| 		RouteRegister:         routeRegister, | 		RouteRegister:         routeRegister, | ||||||
| 		LibraryElementService: libraryElementService, | 		LibraryElementService: libraryElementService, | ||||||
|  | 		FolderService:         folderService, | ||||||
| 		log:                   log.New("library-panels"), | 		log:                   log.New("library-panels"), | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	if err := folderService.RegisterService(lps); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return &lps, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Service is a service for operating on library panels.
 | // Service is a service for operating on library panels.
 | ||||||
|  | @ -44,6 +53,7 @@ type LibraryPanelService struct { | ||||||
| 	SQLStore              db.DB | 	SQLStore              db.DB | ||||||
| 	RouteRegister         routing.RouteRegister | 	RouteRegister         routing.RouteRegister | ||||||
| 	LibraryElementService libraryelements.Service | 	LibraryElementService libraryelements.Service | ||||||
|  | 	FolderService         folder.Service | ||||||
| 	log                   log.Logger | 	log                   log.Logger | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -130,7 +140,7 @@ func importLibraryPanelsRecursively(c context.Context, service libraryelements.S | ||||||
| 			return errLibraryPanelHeaderUIDMissing | 			return errLibraryPanelHeaderUIDMissing | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		_, err := service.GetElement(c, signedInUser, UID) | 		_, err := service.GetElement(c, signedInUser, model.GetLibraryElementCommand{UID: UID, FolderName: dashboards.RootFolderName}) | ||||||
| 		if err == nil { | 		if err == nil { | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
|  | @ -171,3 +181,28 @@ func importLibraryPanelsRecursively(c context.Context, service libraryelements.S | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // CountInFolder is a handler for retrieving the number of library panels contained
 | ||||||
|  | // within a given folder and for a specific organisation.
 | ||||||
|  | func (lps LibraryPanelService) CountInFolder(ctx context.Context, orgID int64, folderUID string, u *user.SignedInUser) (int64, error) { | ||||||
|  | 	var count int64 | ||||||
|  | 	return count, lps.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { | ||||||
|  | 		folder, err := lps.FolderService.Get(ctx, &folder.GetFolderQuery{UID: &folderUID, OrgID: orgID, SignedInUser: u}) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		q := sess.Table("library_element").Where("org_id = ?", u.OrgID). | ||||||
|  | 			Where("folder_id = ?", folder.ID).Where("kind = ?", int64(model.PanelElement)) | ||||||
|  | 		count, err = q.Count() | ||||||
|  | 		return err | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // DeleteInFolder deletes the library panels contained in a given folder.
 | ||||||
|  | func (lps LibraryPanelService) DeleteInFolder(ctx context.Context, orgID int64, folderUID string, user *user.SignedInUser) error { | ||||||
|  | 	return lps.LibraryElementService.DeleteLibraryElementsInFolder(ctx, user, folderUID) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Kind returns the name of the library panel type of entity.
 | ||||||
|  | func (lps LibraryPanelService) Kind() string { return entity.StandardKindLibraryPanel } | ||||||
|  |  | ||||||
|  | @ -319,6 +319,23 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { | ||||||
| 			require.Len(t, elements, 1) | 			require.Len(t, elements, 1) | ||||||
| 			require.Equal(t, sc.initialResult.Result.UID, elements[sc.initialResult.Result.UID].UID) | 			require.Equal(t, sc.initialResult.Result.UID, elements[sc.initialResult.Result.UID].UID) | ||||||
| 		}) | 		}) | ||||||
|  | 
 | ||||||
|  | 	scenarioWithLibraryPanel(t, "It should return the correct count of library panels in a folder", | ||||||
|  | 		func(t *testing.T, sc scenarioContext) { | ||||||
|  | 			count, err := sc.lps.CountInFolder(context.Background(), sc.user.OrgID, sc.folder.UID, sc.user) | ||||||
|  | 			require.NoError(t, err) | ||||||
|  | 			require.Equal(t, int64(1), count) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 	scenarioWithLibraryPanel(t, "It should delete library panels in a folder", | ||||||
|  | 		func(t *testing.T, sc scenarioContext) { | ||||||
|  | 			err := sc.lps.DeleteInFolder(context.Background(), sc.user.OrgID, sc.folder.UID, sc.user) | ||||||
|  | 			require.NoError(t, err) | ||||||
|  | 
 | ||||||
|  | 			_, err = sc.elementService.GetElement(sc.ctx, sc.user, | ||||||
|  | 				model.GetLibraryElementCommand{UID: sc.initialResult.Result.UID, FolderName: sc.folder.Title}) | ||||||
|  | 			require.EqualError(t, err, model.ErrLibraryElementNotFound.Error()) | ||||||
|  | 		}) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestImportLibraryPanelsForDashboard(t *testing.T) { | func TestImportLibraryPanelsForDashboard(t *testing.T) { | ||||||
|  | @ -367,14 +384,16 @@ func TestImportLibraryPanelsForDashboard(t *testing.T) { | ||||||
| 				}, | 				}, | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			_, err := sc.elementService.GetElement(sc.ctx, sc.user, missingUID) | 			_, err := sc.elementService.GetElement(sc.ctx, sc.user, | ||||||
|  | 				model.GetLibraryElementCommand{UID: missingUID, FolderName: dashboards.RootFolderName}) | ||||||
| 
 | 
 | ||||||
| 			require.EqualError(t, err, model.ErrLibraryElementNotFound.Error()) | 			require.EqualError(t, err, model.ErrLibraryElementNotFound.Error()) | ||||||
| 
 | 
 | ||||||
| 			err = sc.service.ImportLibraryPanelsForDashboard(sc.ctx, sc.user, simplejson.NewFromAny(libraryElements), panels, 0) | 			err = sc.service.ImportLibraryPanelsForDashboard(sc.ctx, sc.user, simplejson.NewFromAny(libraryElements), panels, 0) | ||||||
| 			require.NoError(t, err) | 			require.NoError(t, err) | ||||||
| 
 | 
 | ||||||
| 			element, err := sc.elementService.GetElement(sc.ctx, sc.user, missingUID) | 			element, err := sc.elementService.GetElement(sc.ctx, sc.user, | ||||||
|  | 				model.GetLibraryElementCommand{UID: missingUID, FolderName: dashboards.RootFolderName}) | ||||||
| 			require.NoError(t, err) | 			require.NoError(t, err) | ||||||
| 			var expected = getExpected(t, element, missingUID, missingName, missingModel) | 			var expected = getExpected(t, element, missingUID, missingName, missingModel) | ||||||
| 			var result = toLibraryElement(t, element) | 			var result = toLibraryElement(t, element) | ||||||
|  | @ -406,13 +425,15 @@ func TestImportLibraryPanelsForDashboard(t *testing.T) { | ||||||
| 				}, | 				}, | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			_, err := sc.elementService.GetElement(sc.ctx, sc.user, existingUID) | 			_, err := sc.elementService.GetElement(sc.ctx, sc.user, | ||||||
|  | 				model.GetLibraryElementCommand{UID: existingUID, FolderName: dashboards.RootFolderName}) | ||||||
| 			require.NoError(t, err) | 			require.NoError(t, err) | ||||||
| 
 | 
 | ||||||
| 			err = sc.service.ImportLibraryPanelsForDashboard(sc.ctx, sc.user, simplejson.New(), panels, sc.folder.ID) | 			err = sc.service.ImportLibraryPanelsForDashboard(sc.ctx, sc.user, simplejson.New(), panels, sc.folder.ID) | ||||||
| 			require.NoError(t, err) | 			require.NoError(t, err) | ||||||
| 
 | 
 | ||||||
| 			element, err := sc.elementService.GetElement(sc.ctx, sc.user, existingUID) | 			element, err := sc.elementService.GetElement(sc.ctx, sc.user, | ||||||
|  | 				model.GetLibraryElementCommand{UID: existingUID, FolderName: dashboards.RootFolderName}) | ||||||
| 			require.NoError(t, err) | 			require.NoError(t, err) | ||||||
| 			var expected = getExpected(t, element, existingUID, existingName, sc.initialResult.Result.Model) | 			var expected = getExpected(t, element, existingUID, existingName, sc.initialResult.Result.Model) | ||||||
| 			expected.FolderID = sc.initialResult.Result.FolderID | 			expected.FolderID = sc.initialResult.Result.FolderID | ||||||
|  | @ -519,16 +540,15 @@ func TestImportLibraryPanelsForDashboard(t *testing.T) { | ||||||
| 					}, | 					}, | ||||||
| 				}, | 				}, | ||||||
| 			} | 			} | ||||||
| 
 | 			_, err := sc.elementService.GetElement(sc.ctx, sc.user, model.GetLibraryElementCommand{UID: outsideUID, FolderName: dashboards.RootFolderName}) | ||||||
| 			_, err := sc.elementService.GetElement(sc.ctx, sc.user, outsideUID) |  | ||||||
| 			require.EqualError(t, err, model.ErrLibraryElementNotFound.Error()) | 			require.EqualError(t, err, model.ErrLibraryElementNotFound.Error()) | ||||||
| 			_, err = sc.elementService.GetElement(sc.ctx, sc.user, insideUID) | 			_, err = sc.elementService.GetElement(sc.ctx, sc.user, model.GetLibraryElementCommand{UID: insideUID, FolderName: dashboards.RootFolderName}) | ||||||
| 			require.EqualError(t, err, model.ErrLibraryElementNotFound.Error()) | 			require.EqualError(t, err, model.ErrLibraryElementNotFound.Error()) | ||||||
| 
 | 
 | ||||||
| 			err = sc.service.ImportLibraryPanelsForDashboard(sc.ctx, sc.user, simplejson.NewFromAny(libraryElements), panels, 0) | 			err = sc.service.ImportLibraryPanelsForDashboard(sc.ctx, sc.user, simplejson.NewFromAny(libraryElements), panels, 0) | ||||||
| 			require.NoError(t, err) | 			require.NoError(t, err) | ||||||
| 
 | 
 | ||||||
| 			element, err := sc.elementService.GetElement(sc.ctx, sc.user, outsideUID) | 			element, err := sc.elementService.GetElement(sc.ctx, sc.user, model.GetLibraryElementCommand{UID: outsideUID, FolderName: dashboards.RootFolderName}) | ||||||
| 			require.NoError(t, err) | 			require.NoError(t, err) | ||||||
| 			expected := getExpected(t, element, outsideUID, outsideName, outsideModel) | 			expected := getExpected(t, element, outsideUID, outsideName, outsideModel) | ||||||
| 			result := toLibraryElement(t, element) | 			result := toLibraryElement(t, element) | ||||||
|  | @ -536,7 +556,7 @@ func TestImportLibraryPanelsForDashboard(t *testing.T) { | ||||||
| 				t.Fatalf("Result mismatch (-want +got):\n%s", diff) | 				t.Fatalf("Result mismatch (-want +got):\n%s", diff) | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			element, err = sc.elementService.GetElement(sc.ctx, sc.user, insideUID) | 			element, err = sc.elementService.GetElement(sc.ctx, sc.user, model.GetLibraryElementCommand{UID: insideUID, FolderName: dashboards.RootFolderName}) | ||||||
| 			require.NoError(t, err) | 			require.NoError(t, err) | ||||||
| 			expected = getExpected(t, element, insideUID, insideName, insideModel) | 			expected = getExpected(t, element, insideUID, insideName, insideModel) | ||||||
| 			result = toLibraryElement(t, element) | 			result = toLibraryElement(t, element) | ||||||
|  | @ -607,6 +627,7 @@ type scenarioContext struct { | ||||||
| 	folder         *folder.Folder | 	folder         *folder.Folder | ||||||
| 	initialResult  libraryPanelResult | 	initialResult  libraryPanelResult | ||||||
| 	sqlStore       db.DB | 	sqlStore       db.DB | ||||||
|  | 	lps            LibraryPanelService | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func toLibraryElement(t *testing.T, res model.LibraryElementDTO) libraryElement { | func toLibraryElement(t *testing.T, res model.LibraryElementDTO) libraryElement { | ||||||
|  | @ -814,6 +835,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo | ||||||
| 			Cfg:                   cfg, | 			Cfg:                   cfg, | ||||||
| 			SQLStore:              sqlStore, | 			SQLStore:              sqlStore, | ||||||
| 			LibraryElementService: elementService, | 			LibraryElementService: elementService, | ||||||
|  | 			FolderService:         folderService, | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		usr := &user.SignedInUser{ | 		usr := &user.SignedInUser{ | ||||||
|  | @ -853,6 +875,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo | ||||||
| 			service:        &service, | 			service:        &service, | ||||||
| 			elementService: elementService, | 			elementService: elementService, | ||||||
| 			sqlStore:       sqlStore, | 			sqlStore:       sqlStore, | ||||||
|  | 			lps:            service, | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		foldr := createFolder(t, sc, "ScenarioFolder") | 		foldr := createFolder(t, sc, "ScenarioFolder") | ||||||
|  |  | ||||||
|  | @ -586,7 +586,7 @@ func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodel | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // DeleteInFolder deletes the rules contained in a given folder along with their associated data.
 | // DeleteInFolder deletes the rules contained in a given folder along with their associated data.
 | ||||||
| func (st DBstore) DeleteInFolder(ctx context.Context, orgID int64, folderUID string) error { | func (st DBstore) DeleteInFolder(ctx context.Context, orgID int64, folderUID string, user *user.SignedInUser) error { | ||||||
| 	rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{ | 	rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{ | ||||||
| 		OrgID:         orgID, | 		OrgID:         orgID, | ||||||
| 		NamespaceUIDs: []string{folderUID}, | 		NamespaceUIDs: []string{folderUID}, | ||||||
|  |  | ||||||
|  | @ -474,7 +474,7 @@ func TestIntegration_DeleteInFolder(t *testing.T) { | ||||||
| 	} | 	} | ||||||
| 	rule := createRule(t, store, nil) | 	rule := createRule(t, store, nil) | ||||||
| 
 | 
 | ||||||
| 	err := store.DeleteInFolder(context.Background(), rule.OrgID, rule.NamespaceUID) | 	err := store.DeleteInFolder(context.Background(), rule.OrgID, rule.NamespaceUID, nil) | ||||||
| 	require.NoError(t, err) | 	require.NoError(t, err) | ||||||
| 
 | 
 | ||||||
| 	c, err := store.CountInFolder(context.Background(), rule.OrgID, rule.NamespaceUID, nil) | 	c, err := store.CountInFolder(context.Background(), rule.OrgID, rule.NamespaceUID, nil) | ||||||
|  |  | ||||||
|  | @ -42,7 +42,7 @@ const ( | ||||||
| 	// the kind may need to change to better encapsulate { targets:[], transforms:[] }
 | 	// the kind may need to change to better encapsulate { targets:[], transforms:[] }
 | ||||||
| 	StandardKindQuery = "query" | 	StandardKindQuery = "query" | ||||||
| 
 | 
 | ||||||
| 	// KindAlertRule is not a real kind. It's used to refer to alert rules, for instance
 | 	// StandardKindAlertRule is not a real kind. It's used to refer to alert rules, for instance
 | ||||||
| 	// in the folder registry service.
 | 	// in the folder registry service.
 | ||||||
| 	StandardKindAlertRule = "alertrule" | 	StandardKindAlertRule = "alertrule" | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue