diff --git a/pkg/api/dtos/folder.go b/pkg/api/dtos/folder.go index 87605a01185..569255951cb 100644 --- a/pkg/api/dtos/folder.go +++ b/pkg/api/dtos/folder.go @@ -22,6 +22,8 @@ type Folder struct { Updated time.Time `json:"updated"` Version int `json:"version"` AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"` + // only used if nested folders are enabled + ParentUID string `json:"parentUid"` } type FolderSearchHit struct { diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 1676127712b..ed52fe3d0a6 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -105,6 +105,8 @@ func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response { // // Create folder. // +// If nested folders are enabled then it additionally expects the parent folder UID. +// // Responses: // 200: folderResponse // 400: badRequestError @@ -113,23 +115,29 @@ func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response { // 409: conflictError // 500: internalServerError func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response { - cmd := models.CreateFolderCommand{} + cmd := folder.CreateFolderCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } - folder, err := hs.folderService.CreateFolder(c.Req.Context(), c.SignedInUser, c.OrgID, cmd.Title, cmd.Uid) + cmd.OrgID = c.OrgID + + folder, err := hs.folderService.Create(c.Req.Context(), &cmd) if err != nil { return apierrors.ToFolderErrorResponse(err) } - g := guardian.New(c.Req.Context(), folder.Id, c.OrgID, c.SignedInUser) - return response.JSON(http.StatusOK, hs.toFolderDto(c, g, folder)) + g := guardian.New(c.Req.Context(), folder.ID, c.OrgID, c.SignedInUser) + // TODO set ParentUID if nested folders are enabled + return response.JSON(http.StatusOK, hs.newToFolderDto(c, g, folder)) } // swagger:route PUT /folders/{folder_uid} folders updateFolder // // Update folder. // +// If nested folders are enabled then it optionally expects a new parent folder UID that moves the folder and +// includes it into the response. +// // Responses: // 200: folderResponse // 400: badRequestError @@ -156,6 +164,7 @@ func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response { // Delete folder. // // Deletes an existing folder identified by UID along with all dashboards (and their alerts) stored in the folder. This operation cannot be reverted. +// If nested folders are enabled then it also deletes all the subfolders. // // Responses: // 200: deleteFolderResponse @@ -216,6 +225,42 @@ func (hs *HTTPServer) toFolderDto(c *models.ReqContext, g guardian.DashboardGuar } } +func (hs *HTTPServer) newToFolderDto(c *models.ReqContext, g guardian.DashboardGuardian, folder *folder.Folder) dtos.Folder { + canEdit, _ := g.CanEdit() + canSave, _ := g.CanSave() + canAdmin, _ := g.CanAdmin() + canDelete, _ := g.CanDelete() + + // Finding creator and last updater of the folder + updater, creator := anonString, anonString + /* + if folder.CreatedBy > 0 { + creator = hs.getUserLogin(c.Req.Context(), folder.CreatedBy) + } + if folder.UpdatedBy > 0 { + updater = hs.getUserLogin(c.Req.Context(), folder.UpdatedBy) + } + */ + + return dtos.Folder{ + Id: folder.ID, + Uid: folder.UID, + Title: folder.Title, + //Url: folder.Url, + //HasACL: folder.HasACL, + CanSave: canSave, + CanEdit: canEdit, + CanAdmin: canAdmin, + CanDelete: canDelete, + CreatedBy: creator, + Created: folder.Created, + UpdatedBy: updater, + Updated: folder.Updated, + //Version: folder.Version, + AccessControl: hs.getAccessControlMetadata(c, c.OrgID, dashboards.ScopeFoldersPrefix, folder.UID), + } +} + // swagger:parameters getFolders type GetFoldersParams struct { // Limit the maximum number of folders to return @@ -262,7 +307,7 @@ type GetFolderByIDParams struct { type CreateFolderParams struct { // in:body // required:true - Body models.CreateFolderCommand `json:"body"` + Body folder.CreateFolderCommand `json:"body"` } // swagger:parameters deleteFolder diff --git a/pkg/api/folder_permission_test.go b/pkg/api/folder_permission_test.go index c41b0ea3313..454d926ac1e 100644 --- a/pkg/api/folder_permission_test.go +++ b/pkg/api/folder_permission_test.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" service "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" @@ -130,7 +131,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { }, }) - folderService.ExpectedFolder = &models.Folder{Id: 1, Uid: "uid", Title: "Folder"} + folderService.ExpectedFolder = &folder.Folder{ID: 1, UID: "uid", Title: "Folder"} dashboardStore.On("UpdateDashboardACL", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() mockSQLStore := mockstore.NewSQLStoreMock() @@ -187,7 +188,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { CheckPermissionBeforeUpdateError: guardian.ErrGuardianPermissionExists, }) - folderService.ExpectedFolder = &models.Folder{Id: 1, Uid: "uid", Title: "Folder"} + folderService.ExpectedFolder = &folder.Folder{ID: 1, UID: "uid", Title: "Folder"} cmd := dtos.UpdateDashboardACLCommand{ Items: []dtos.DashboardACLUpdateItem{ @@ -251,7 +252,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { CheckPermissionBeforeUpdateError: guardian.ErrGuardianOverride}, ) - folderService.ExpectedFolder = &models.Folder{Id: 1, Uid: "uid", Title: "Folder"} + folderService.ExpectedFolder = &folder.Folder{ID: 1, UID: "uid", Title: "Folder"} cmd := dtos.UpdateDashboardACLCommand{ Items: []dtos.DashboardACLUpdateItem{ @@ -297,7 +298,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { var gotItems []*models.DashboardACL - folderService.ExpectedFolder = &models.Folder{Id: 1, Uid: "uid", Title: "Folder"} + folderService.ExpectedFolder = &folder.Folder{ID: 1, UID: "uid", Title: "Folder"} dashboardStore.On("UpdateDashboardACL", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { gotItems = args.Get(2).([]*models.DashboardACL) }).Return(nil).Once() diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 2daafa0b274..d2f15ba5bb9 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -39,7 +39,7 @@ func TestFoldersAPIEndpoint(t *testing.T) { Title: "Folder", } - folderService.ExpectedFolder = &models.Folder{Id: 1, Uid: "uid", Title: "Folder"} + folderService.ExpectedFolder = &folder.Folder{ID: 1, UID: "uid", Title: "Folder"} createFolderScenario(t, "When calling POST on", "/api/folders", "/api/folders", folderService, cmd, func(sc *scenarioContext) { @@ -94,7 +94,7 @@ func TestFoldersAPIEndpoint(t *testing.T) { Title: "Folder upd", } - folderService.ExpectedFolder = &models.Folder{Id: 1, Uid: "uid", Title: "Folder upd"} + folderService.ExpectedFolder = &folder.Folder{ID: 1, UID: "uid", Title: "Folder upd"} updateFolderScenario(t, "When calling PUT on", "/api/folders/uid", "/api/folders/:uid", folderService, cmd, func(sc *scenarioContext) { @@ -179,7 +179,7 @@ func TestHTTPServer_FolderMetadata(t *testing.T) { }) t.Run("Should attach access control metadata to folder response", func(t *testing.T) { - folderService.ExpectedFolder = &models.Folder{Uid: "folderUid"} + folderService.ExpectedFolder = &folder.Folder{UID: "folderUid"} req := server.NewGetRequest("/api/folders/folderUid?accesscontrol=true") webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{ @@ -202,7 +202,7 @@ func TestHTTPServer_FolderMetadata(t *testing.T) { }) t.Run("Should attach access control metadata to folder response", func(t *testing.T) { - folderService.ExpectedFolder = &models.Folder{Uid: "folderUid"} + folderService.ExpectedFolder = &folder.Folder{UID: "folderUid"} req := server.NewGetRequest("/api/folders/folderUid") webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{ diff --git a/pkg/services/dashboardimport/service/service_test.go b/pkg/services/dashboardimport/service/service_test.go index c73f8605a30..1222ccada0e 100644 --- a/pkg/services/dashboardimport/service/service_test.go +++ b/pkg/services/dashboardimport/service/service_test.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/org" @@ -55,9 +56,9 @@ func TestImportDashboardService(t *testing.T) { }, } folderService := &foldertest.FakeService{ - ExpectedFolder: &models.Folder{ - Id: 5, - Uid: "123", + ExpectedFolder: &folder.Folder{ + ID: 5, + UID: "123", }, } @@ -115,9 +116,9 @@ func TestImportDashboardService(t *testing.T) { } libraryPanelService := &libraryPanelServiceMock{} folderService := &foldertest.FakeService{ - ExpectedFolder: &models.Folder{ - Id: 5, - Uid: "123", + ExpectedFolder: &folder.Folder{ + ID: 5, + UID: "123", }, } s := &ImportDashboardService{ diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index d0d19537dc9..bf8e664a39b 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -21,7 +21,6 @@ import ( "github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" ) type Service struct { @@ -150,16 +149,21 @@ func (s *Service) GetFolderByTitle(ctx context.Context, user *user.SignedInUser, return dashFolder, nil } -func (s *Service) CreateFolder(ctx context.Context, user *user.SignedInUser, orgID int64, title, uid string) (*models.Folder, error) { - dashFolder := models.NewDashboardFolder(title) - dashFolder.OrgId = orgID +func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { + dashFolder := models.NewDashboardFolder(cmd.Title) + dashFolder.OrgId = cmd.OrgID - trimmedUID := strings.TrimSpace(uid) + trimmedUID := strings.TrimSpace(cmd.UID) if trimmedUID == accesscontrol.GeneralFolderUID { return nil, dashboards.ErrFolderInvalidUID } dashFolder.SetUid(trimmedUID) + + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } userID := user.UserID if userID == 0 { userID = -1 @@ -170,7 +174,7 @@ func (s *Service) CreateFolder(ctx context.Context, user *user.SignedInUser, org dto := &dashboards.SaveDashboardDTO{ Dashboard: dashFolder, - OrgId: orgID, + OrgId: cmd.OrgID, User: user, } @@ -185,7 +189,7 @@ func (s *Service) CreateFolder(ctx context.Context, user *user.SignedInUser, org } var createdFolder *models.Folder - createdFolder, err = s.dashboardStore.GetFolderByID(ctx, orgID, dash.Id) + createdFolder, err = s.dashboardStore.GetFolderByID(ctx, cmd.OrgID, dash.Id) if err != nil { return nil, err } @@ -204,9 +208,9 @@ func (s *Service) CreateFolder(ctx context.Context, user *user.SignedInUser, org {BuiltinRole: string(org.RoleViewer), Permission: models.PERMISSION_VIEW.String()}, }...) - _, permissionErr = s.permissions.SetPermissions(ctx, orgID, createdFolder.Uid, permissions...) + _, permissionErr = s.permissions.SetPermissions(ctx, cmd.OrgID, createdFolder.Uid, permissions...) } else if s.cfg.EditorsCanAdmin && user.IsRealUser() && !user.IsAnonymous { - permissionErr = s.MakeUserAdmin(ctx, orgID, userID, createdFolder.Id, true) + permissionErr = s.MakeUserAdmin(ctx, cmd.OrgID, userID, createdFolder.Id, true) } if permissionErr != nil { @@ -219,31 +223,34 @@ func (s *Service) CreateFolder(ctx context.Context, user *user.SignedInUser, org description = dash.Data.Get("description").MustString() } + parentUID := folder.RootFolderUID + if cmd.ParentUID != "" { + parentUID = cmd.ParentUID + } _, err := s.store.Create(ctx, folder.CreateFolderCommand{ // TODO: Today, if a UID isn't specified, the dashboard store // generates a new UID. The new folder store will need to do this as // well, but for now we take the UID from the newly created folder. UID: dash.Uid, - OrgID: orgID, - Title: title, + OrgID: cmd.OrgID, + Title: cmd.Title, Description: description, - ParentUID: folder.RootFolderUID, + ParentUID: parentUID, }) if err != nil { // We'll log the error and also roll back the previously-created // (legacy) folder. s.log.Error("error saving folder to nested folder store", err) - - err = s.DeleteFolder(ctx, &folder.DeleteFolderCommand{UID: createdFolder.Uid, OrgID: orgID, ForceDeleteRules: true}) + err = s.DeleteFolder(ctx, &folder.DeleteFolderCommand{UID: createdFolder.Uid, OrgID: cmd.OrgID, ForceDeleteRules: true}) if err != nil { s.log.Error("error deleting folder after failed save to nested folder store", err) } - return createdFolder, err + return folder.FromDashboard(dash), err } // The folder UID is specified (or generated) during creation, so we'll // stop here and return the created model.Folder. } - return createdFolder, nil + return folder.FromDashboard(dash), nil } func (s *Service) UpdateFolder(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) error { @@ -333,15 +340,6 @@ func (s *Service) DeleteFolder(ctx context.Context, cmd *folder.DeleteFolderComm return nil } -func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { - // check the flag, if old - do whatever did before - // for new only the store - if cmd.UID == "" { - cmd.UID = util.GenerateShortUID() - } - return s.store.Create(ctx, *cmd) -} - func (s *Service) Update(ctx context.Context, cmd *folder.UpdateFolderCommand) (*folder.Folder, error) { // check the flag, if old - do whatever did before // for new only the store diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 73e9256e784..dee401cca09 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -79,12 +79,12 @@ func TestIntegrationFolderService(t *testing.T) { folderId := rand.Int63() folderUID := util.GenerateShortUID() - newFolder := models.NewFolder("Folder") - newFolder.Id = folderId - newFolder.Uid = folderUID + f := models.NewFolder("Folder") + f.Id = folderId + f.Uid = folderUID - dashStore.On("GetFolderByID", mock.Anything, orgID, folderId).Return(newFolder, nil) - dashStore.On("GetFolderByUID", mock.Anything, orgID, folderUID).Return(newFolder, nil) + dashStore.On("GetFolderByID", mock.Anything, orgID, folderId).Return(f, nil) + dashStore.On("GetFolderByUID", mock.Anything, orgID, folderUID).Return(f, nil) t.Run("When get folder by id should return access denied error", func(t *testing.T) { _, err := service.GetFolderByID(context.Background(), usr, folderId, orgID) @@ -104,7 +104,12 @@ func TestIntegrationFolderService(t *testing.T) { t.Run("When creating folder should return access denied error", func(t *testing.T) { dashStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.AnythingOfType("*models.Dashboard"), mock.AnythingOfType("bool")).Return(true, nil).Times(2) - _, err := service.CreateFolder(context.Background(), usr, orgID, newFolder.Title, folderUID) + ctx := appcontext.WithUser(context.Background(), usr) + _, err := service.Create(ctx, &folder.CreateFolderCommand{ + OrgID: orgID, + Title: f.Title, + UID: folderUID, + }) require.Equal(t, err, dashboards.ErrFolderAccessDenied) }) @@ -158,16 +163,26 @@ func TestIntegrationFolderService(t *testing.T) { dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(dash, nil).Once() dashStore.On("GetFolderByID", mock.Anything, orgID, dash.Id).Return(f, nil) - actualFolder, err := service.CreateFolder(context.Background(), usr, orgID, dash.Title, "someuid") + ctx := appcontext.WithUser(context.Background(), usr) + actualFolder, err := service.Create(ctx, &folder.CreateFolderCommand{ + OrgID: orgID, + Title: dash.Title, + UID: "someuid", + }) require.NoError(t, err) - require.Equal(t, f, actualFolder) + require.Equal(t, f, actualFolder.ToLegacyModel()) }) t.Run("When creating folder should return error if uid is general", func(t *testing.T) { dash := models.NewDashboardFolder("Test-Folder") dash.Id = rand.Int63() - _, err := service.CreateFolder(context.Background(), usr, orgID, dash.Title, "general") + ctx := appcontext.WithUser(context.Background(), usr) + _, err := service.Create(ctx, &folder.CreateFolderCommand{ + OrgID: orgID, + Title: dash.Title, + UID: "general", + }) require.ErrorIs(t, err, dashboards.ErrFolderInvalidUID) }) @@ -287,14 +302,38 @@ func TestIntegrationFolderService(t *testing.T) { }) } -func TestFolderService(t *testing.T) { +func TestNestedFolderServiceFeatureToggle(t *testing.T) { folderStore := NewFakeStore() + + dashboardsvc := dashboards.FakeDashboardService{} + dashboardsvc.On("BuildSaveDashboardCommand", + mock.Anything, mock.AnythingOfType("*dashboards.SaveDashboardDTO"), + mock.AnythingOfType("bool"), mock.AnythingOfType("bool")).Return(&models.SaveDashboardCommand{}, nil) + dashStore := dashboards.FakeDashboardStore{} + dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(&models.Dashboard{}, nil) + dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&models.Folder{}, nil) + cfg := setting.NewCfg() + cfg.RBACEnabled = false + nestedFoldersEnabled := true + features := featuremgmt.WithFeatures() + cfg.IsFeatureToggleEnabled = func(key string) bool { + if key == featuremgmt.FlagNestedFolders { + return nestedFoldersEnabled + } + return false + } + cfg.IsFeatureToggleEnabled = features.IsEnabled folderService := &Service{ - store: folderStore, + cfg: cfg, + store: folderStore, + dashboardStore: &dashStore, + dashboardService: &dashboardsvc, + features: features, } t.Run("create folder", func(t *testing.T) { folderStore.ExpectedFolder = &folder.Folder{} - res, err := folderService.Create(context.Background(), &folder.CreateFolderCommand{}) + ctx := appcontext.WithUser(context.Background(), usr) + res, err := folderService.Create(ctx, &folder.CreateFolderCommand{}) require.NoError(t, err) require.NotNil(t, res.UID) }) @@ -381,7 +420,12 @@ func TestNestedFolderService(t *testing.T) { dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(&models.Dashboard{}, nil) dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&models.Folder{}, nil) - _, err := foldersvc.CreateFolder(ctx, usr, orgID, "myFolder", "myFolder") + ctx = appcontext.WithUser(ctx, usr) + _, err := foldersvc.Create(ctx, &folder.CreateFolderCommand{ + OrgID: orgID, + Title: "myFolder", + UID: "myFolder", + }) require.NoError(t, err) // CreateFolder should not call the folder store create if the feature toggle is not enabled. require.False(t, store.CreateCalled) @@ -434,7 +478,12 @@ func TestNestedFolderService(t *testing.T) { mock.AnythingOfType("bool"), mock.AnythingOfType("bool")).Return(&models.SaveDashboardCommand{}, nil) dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(&models.Dashboard{}, nil) dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&models.Folder{}, nil) - _, err := foldersvc.CreateFolder(ctx, usr, orgID, "myFolder", "myFolder") + ctx = appcontext.WithUser(ctx, usr) + _, err := foldersvc.Create(ctx, &folder.CreateFolderCommand{ + OrgID: orgID, + Title: "myFolder", + UID: "myFolder", + }) require.NoError(t, err) // CreateFolder should also call the folder store's create method. require.True(t, store.CreateCalled) @@ -457,7 +506,12 @@ func TestNestedFolderService(t *testing.T) { store.ExpectedError = errors.New("FAILED") // the service return success as long as the legacy create succeeds - _, err := foldersvc.CreateFolder(ctx, usr, orgID, "myFolder", "myFolder") + ctx = appcontext.WithUser(ctx, usr) + _, err := foldersvc.Create(ctx, &folder.CreateFolderCommand{ + OrgID: orgID, + Title: "myFolder", + UID: "myFolder", + }) require.Error(t, err, "FAILED") // CreateFolder should also call the folder store's create method. diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go index 4dbbafb0497..fd67f2c2d8e 100644 --- a/pkg/services/folder/folderimpl/sqlstore.go +++ b/pkg/services/folder/folderimpl/sqlstore.go @@ -36,6 +36,15 @@ func (ss *sqlStore) Create(ctx context.Context, cmd folder.CreateFolderCommand) } var foldr *folder.Folder + /* + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + version := 1 + updatedBy := user.UserID + createdBy := user.UserID + */ err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { var sqlOrArgs []interface{} if cmd.ParentUID == "" { @@ -47,7 +56,7 @@ func (ss *sqlStore) Create(ctx context.Context, cmd folder.CreateFolderCommand) UID: &cmd.ParentUID, OrgID: cmd.OrgID, }); err != nil { - return err + return folder.ErrFolderNotFound.Errorf("parent folder does not exist") } } sql := "INSERT INTO folder(org_id, uid, parent_uid, title, description, created, updated) VALUES(?, ?, ?, ?, ?, ?, ?)" diff --git a/pkg/services/folder/folderimpl/test.db b/pkg/services/folder/folderimpl/test.db new file mode 100644 index 00000000000..e00490e1512 Binary files /dev/null and b/pkg/services/folder/folderimpl/test.db differ diff --git a/pkg/services/folder/foldertest/foldertest.go b/pkg/services/folder/foldertest/foldertest.go index 5783963c4f8..3c0a26f853c 100644 --- a/pkg/services/folder/foldertest/foldertest.go +++ b/pkg/services/folder/foldertest/foldertest.go @@ -10,7 +10,7 @@ import ( type FakeService struct { ExpectedFolders []*models.Folder - ExpectedFolder *models.Folder + ExpectedFolder *folder.Folder ExpectedError error } @@ -20,19 +20,22 @@ func (s *FakeService) GetFolders(ctx context.Context, user *user.SignedInUser, o return s.ExpectedFolders, s.ExpectedError } func (s *FakeService) GetFolderByID(ctx context.Context, user *user.SignedInUser, id int64, orgID int64) (*models.Folder, error) { - return s.ExpectedFolder, s.ExpectedError + return s.ExpectedFolder.ToLegacyModel(), s.ExpectedError } func (s *FakeService) GetFolderByUID(ctx context.Context, user *user.SignedInUser, orgID int64, uid string) (*models.Folder, error) { - return s.ExpectedFolder, s.ExpectedError + if s.ExpectedFolder == nil { + return nil, s.ExpectedError + } + return s.ExpectedFolder.ToLegacyModel(), s.ExpectedError } func (s *FakeService) GetFolderByTitle(ctx context.Context, user *user.SignedInUser, orgID int64, title string) (*models.Folder, error) { - return s.ExpectedFolder, s.ExpectedError + return s.ExpectedFolder.ToLegacyModel(), s.ExpectedError } -func (s *FakeService) CreateFolder(ctx context.Context, user *user.SignedInUser, orgID int64, title, uid string) (*models.Folder, error) { +func (s *FakeService) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { return s.ExpectedFolder, s.ExpectedError } func (s *FakeService) UpdateFolder(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) error { - cmd.Result = s.ExpectedFolder + cmd.Result = s.ExpectedFolder.ToLegacyModel() return s.ExpectedError } func (s *FakeService) DeleteFolder(ctx context.Context, cmd *folder.DeleteFolderCommand) error { diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index eb1d68e3a11..55f79526854 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -3,6 +3,7 @@ package folder import ( "time" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -33,7 +34,17 @@ type Folder struct { // TODO: validate if this field is required/relevant to folders. // currently there is no such column + // Version int + // Url string // UpdatedBy int64 + // CreatedBy int64 + // HasACL bool +} + +type FolderDTO struct { + Folder + + Children []FolderDTO } // NewFolder tales a title and returns a Folder with the Created and Updated @@ -51,7 +62,7 @@ func NewFolder(title string, description string) *Folder { // to create a folder. type CreateFolderCommand struct { UID string `json:"uid"` - OrgID int64 `json:"orgId"` + OrgID int64 `json:"-"` Title string `json:"title"` Description string `json:"description"` ParentUID string `json:"parent_uid"` @@ -72,7 +83,7 @@ type UpdateFolderCommand struct { type MoveFolderCommand struct { UID string `json:"uid"` NewParentUID string `json:"new_parent_uid"` - OrgID int64 `json:"orgId"` + OrgID int64 `json:"-"` } // DeleteFolderCommand captures the information required by the folder service @@ -113,3 +124,34 @@ type GetTreeQuery struct { Limit int64 Page int64 } + +// ToLegacyModel is temporary until the two folder services are merged +func (f *Folder) ToLegacyModel() *models.Folder { + return &models.Folder{ + Id: f.ID, + Uid: f.UID, + Title: f.Title, + Url: models.GetFolderUrl(f.UID, models.SlugifyTitle(f.Title)), + Version: 0, + Created: f.Created, + Updated: f.Updated, + UpdatedBy: 0, + CreatedBy: 0, + HasACL: false, + } +} + +func FromDashboard(dash *models.Dashboard) *Folder { + return &Folder{ + ID: dash.Id, + UID: dash.Uid, + Title: dash.Title, + //HasACL: dash.HasACL, + //Url: dash.GetUrl(), + //Version: dash.Version, + Created: dash.Created, + //CreatedBy: dash.CreatedBy, + Updated: dash.Updated, + //UpdatedBy: dash.UpdatedBy, + } +} diff --git a/pkg/services/folder/service.go b/pkg/services/folder/service.go index 9675cc51542..9a287102ca4 100644 --- a/pkg/services/folder/service.go +++ b/pkg/services/folder/service.go @@ -12,7 +12,7 @@ type Service interface { GetFolderByID(ctx context.Context, user *user.SignedInUser, id int64, orgID int64) (*models.Folder, error) GetFolderByUID(ctx context.Context, user *user.SignedInUser, orgID int64, uid string) (*models.Folder, error) GetFolderByTitle(ctx context.Context, user *user.SignedInUser, orgID int64, title string) (*models.Folder, error) - CreateFolder(ctx context.Context, user *user.SignedInUser, orgID int64, title, uid string) (*models.Folder, error) + Create(ctx context.Context, cmd *CreateFolderCommand) (*Folder, error) UpdateFolder(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) error DeleteFolder(ctx context.Context, cmd *DeleteFolderCommand) error MakeUserAdmin(ctx context.Context, orgID int64, userID, folderID int64, setViewAndEditPermissions bool) error diff --git a/pkg/services/libraryelements/libraryelements_get_all_test.go b/pkg/services/libraryelements/libraryelements_get_all_test.go index cad56c2ec5a..9d0970a8d3a 100644 --- a/pkg/services/libraryelements/libraryelements_get_all_test.go +++ b/pkg/services/libraryelements/libraryelements_get_all_test.go @@ -524,11 +524,11 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and folderFilter is set to existing folders, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { newFolder := createFolderWithACL(t, sc.sqlStore, "NewFolder", sc.user, []folderACLItem{}) - command := getCreatePanelCommand(newFolder.Id, "Text - Library Panel2") + command := getCreatePanelCommand(newFolder.ID, "Text - Library Panel2") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) - folderFilter := strconv.FormatInt(newFolder.Id, 10) + folderFilter := strconv.FormatInt(newFolder.ID, 10) err := sc.reqContext.Req.ParseForm() require.NoError(t, err) @@ -548,7 +548,7 @@ func TestGetAllLibraryElements(t *testing.T) { { ID: 2, OrgID: 1, - FolderID: newFolder.Id, + FolderID: newFolder.ID, UID: result.Result.Elements[0].UID, Name: "Text - Library Panel2", Kind: int64(models.PanelElement), @@ -564,7 +564,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "NewFolder", - FolderUID: newFolder.Uid, + FolderUID: newFolder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[0].Meta.Created, Updated: result.Result.Elements[0].Meta.Updated, @@ -591,7 +591,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and folderFilter is set to a nonexistent folders, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { newFolder := createFolderWithACL(t, sc.sqlStore, "NewFolder", sc.user, []folderACLItem{}) - command := getCreatePanelCommand(newFolder.Id, "Text - Library Panel2") + command := getCreatePanelCommand(newFolder.ID, "Text - Library Panel2") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) diff --git a/pkg/services/libraryelements/libraryelements_patch_test.go b/pkg/services/libraryelements/libraryelements_patch_test.go index 77f80e215fb..998a5c59ab8 100644 --- a/pkg/services/libraryelements/libraryelements_patch_test.go +++ b/pkg/services/libraryelements/libraryelements_patch_test.go @@ -25,7 +25,7 @@ func TestPatchLibraryElement(t *testing.T) { func(t *testing.T, sc scenarioContext) { newFolder := createFolderWithACL(t, sc.sqlStore, "NewFolder", sc.user, []folderACLItem{}) cmd := PatchLibraryElementCommand{ - FolderID: newFolder.Id, + FolderID: newFolder.ID, Name: "Panel - New name", Model: []byte(` { @@ -48,7 +48,7 @@ func TestPatchLibraryElement(t *testing.T) { Result: libraryElement{ ID: 1, OrgID: 1, - FolderID: newFolder.Id, + FolderID: newFolder.ID, UID: sc.initialResult.Result.UID, Name: "Panel - New name", Kind: int64(models.PanelElement), @@ -90,7 +90,7 @@ func TestPatchLibraryElement(t *testing.T) { func(t *testing.T, sc scenarioContext) { newFolder := createFolderWithACL(t, sc.sqlStore, "NewFolder", sc.user, []folderACLItem{}) cmd := PatchLibraryElementCommand{ - FolderID: newFolder.Id, + FolderID: newFolder.ID, Kind: int64(models.PanelElement), Version: 1, } @@ -99,7 +99,7 @@ func TestPatchLibraryElement(t *testing.T) { resp := sc.service.patchHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) var result = validateAndUnMarshalResponse(t, resp) - sc.initialResult.Result.FolderID = newFolder.Id + sc.initialResult.Result.FolderID = newFolder.ID sc.initialResult.Result.Meta.CreatedBy.Name = userInDbName sc.initialResult.Result.Meta.CreatedBy.AvatarURL = userInDbAvatar sc.initialResult.Result.Meta.Updated = result.Result.Meta.Updated @@ -325,7 +325,7 @@ func TestPatchLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to patch a library panel with a folder where a library panel with the same name already exists, it should fail", func(t *testing.T, sc scenarioContext) { newFolder := createFolderWithACL(t, sc.sqlStore, "NewFolder", sc.user, []folderACLItem{}) - command := getCreatePanelCommand(newFolder.Id, "Text - Library Panel") + command := getCreatePanelCommand(newFolder.ID, "Text - Library Panel") sc.ctx.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) var result = validateAndUnMarshalResponse(t, resp) diff --git a/pkg/services/libraryelements/libraryelements_permissions_test.go b/pkg/services/libraryelements/libraryelements_permissions_test.go index 22b3eb9ab74..cca13cb678c 100644 --- a/pkg/services/libraryelements/libraryelements_permissions_test.go +++ b/pkg/services/libraryelements/libraryelements_permissions_test.go @@ -73,7 +73,7 @@ func TestLibraryElementPermissions(t *testing.T) { folder := createFolderWithACL(t, sc.sqlStore, "Folder", sc.user, testCase.items) sc.reqContext.SignedInUser.OrgRole = testCase.role - command := getCreatePanelCommand(folder.Id, "Library Panel Name") + command := getCreatePanelCommand(folder.ID, "Library Panel Name") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, testCase.status, resp.Status()) @@ -82,14 +82,14 @@ func TestLibraryElementPermissions(t *testing.T) { testScenario(t, fmt.Sprintf("When %s tries to patch a library panel by moving it to a folder with %s, it should return correct status", testCase.role, testCase.desc), func(t *testing.T, sc scenarioContext) { fromFolder := createFolderWithACL(t, sc.sqlStore, "Everyone", sc.user, everyonePermissions) - command := getCreatePanelCommand(fromFolder.Id, "Library Panel Name") + command := getCreatePanelCommand(fromFolder.ID, "Library Panel Name") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) result := validateAndUnMarshalResponse(t, resp) toFolder := createFolderWithACL(t, sc.sqlStore, "Folder", sc.user, testCase.items) sc.reqContext.SignedInUser.OrgRole = testCase.role - cmd := PatchLibraryElementCommand{FolderID: toFolder.Id, Version: 1, Kind: int64(models.PanelElement)} + cmd := PatchLibraryElementCommand{FolderID: toFolder.ID, Version: 1, Kind: int64(models.PanelElement)} sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": result.Result.UID}) sc.reqContext.Req.Body = mockRequestBody(cmd) resp = sc.service.patchHandler(sc.reqContext) @@ -99,14 +99,14 @@ func TestLibraryElementPermissions(t *testing.T) { testScenario(t, fmt.Sprintf("When %s tries to patch a library panel by moving it from a folder with %s, it should return correct status", testCase.role, testCase.desc), func(t *testing.T, sc scenarioContext) { fromFolder := createFolderWithACL(t, sc.sqlStore, "Everyone", sc.user, testCase.items) - command := getCreatePanelCommand(fromFolder.Id, "Library Panel Name") + command := getCreatePanelCommand(fromFolder.ID, "Library Panel Name") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) result := validateAndUnMarshalResponse(t, resp) toFolder := createFolderWithACL(t, sc.sqlStore, "Folder", sc.user, everyonePermissions) sc.reqContext.SignedInUser.OrgRole = testCase.role - cmd := PatchLibraryElementCommand{FolderID: toFolder.Id, Version: 1, Kind: int64(models.PanelElement)} + cmd := PatchLibraryElementCommand{FolderID: toFolder.ID, Version: 1, Kind: int64(models.PanelElement)} sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": result.Result.UID}) sc.reqContext.Req.Body = mockRequestBody(cmd) resp = sc.service.patchHandler(sc.reqContext) @@ -116,7 +116,7 @@ func TestLibraryElementPermissions(t *testing.T) { testScenario(t, fmt.Sprintf("When %s tries to delete a library panel in a folder with %s, it should return correct status", testCase.role, testCase.desc), func(t *testing.T, sc scenarioContext) { folder := createFolderWithACL(t, sc.sqlStore, "Folder", sc.user, testCase.items) - cmd := getCreatePanelCommand(folder.Id, "Library Panel Name") + cmd := getCreatePanelCommand(folder.ID, "Library Panel Name") sc.reqContext.Req.Body = mockRequestBody(cmd) resp := sc.service.createHandler(sc.reqContext) result := validateAndUnMarshalResponse(t, resp) @@ -151,7 +151,7 @@ func TestLibraryElementPermissions(t *testing.T) { testScenario(t, fmt.Sprintf("When %s tries to patch a library panel by moving it to the General folder, it should return correct status", testCase.role), func(t *testing.T, sc scenarioContext) { folder := createFolderWithACL(t, sc.sqlStore, "Folder", sc.user, everyonePermissions) - command := getCreatePanelCommand(folder.Id, "Library Panel Name") + command := getCreatePanelCommand(folder.ID, "Library Panel Name") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) result := validateAndUnMarshalResponse(t, resp) @@ -173,7 +173,7 @@ func TestLibraryElementPermissions(t *testing.T) { result := validateAndUnMarshalResponse(t, resp) sc.reqContext.SignedInUser.OrgRole = testCase.role - cmd := PatchLibraryElementCommand{FolderID: folder.Id, Version: 1, Kind: int64(models.PanelElement)} + cmd := PatchLibraryElementCommand{FolderID: folder.ID, Version: 1, Kind: int64(models.PanelElement)} sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": result.Result.UID}) sc.ctx.Req.Body = mockRequestBody(cmd) resp = sc.service.patchHandler(sc.reqContext) @@ -216,7 +216,7 @@ func TestLibraryElementPermissions(t *testing.T) { testScenario(t, fmt.Sprintf("When %s tries to patch a library panel by moving it to a folder that doesn't exist, it should fail", testCase.role), func(t *testing.T, sc scenarioContext) { folder := createFolderWithACL(t, sc.sqlStore, "Folder", sc.user, everyonePermissions) - command := getCreatePanelCommand(folder.Id, "Library Panel Name") + command := getCreatePanelCommand(folder.ID, "Library Panel Name") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) result := validateAndUnMarshalResponse(t, resp) @@ -245,7 +245,7 @@ func TestLibraryElementPermissions(t *testing.T) { var results []libraryElement for i, folderCase := range folderCases { folder := createFolderWithACL(t, sc.sqlStore, fmt.Sprintf("Folder%v", i), sc.user, folderCase) - cmd := getCreatePanelCommand(folder.Id, fmt.Sprintf("Library Panel in Folder%v", i)) + cmd := getCreatePanelCommand(folder.ID, fmt.Sprintf("Library Panel in Folder%v", i)) sc.reqContext.Req.Body = mockRequestBody(cmd) resp := sc.service.createHandler(sc.reqContext) result := validateAndUnMarshalResponse(t, resp) @@ -254,7 +254,7 @@ func TestLibraryElementPermissions(t *testing.T) { result.Result.Meta.UpdatedBy.Name = userInDbName result.Result.Meta.UpdatedBy.AvatarURL = userInDbAvatar result.Result.Meta.FolderName = folder.Title - result.Result.Meta.FolderUID = folder.Uid + result.Result.Meta.FolderUID = folder.UID results = append(results, result.Result) } sc.reqContext.SignedInUser.OrgRole = testCase.role @@ -308,7 +308,7 @@ func TestLibraryElementPermissions(t *testing.T) { var results []libraryElement for i, folderCase := range folderCases { folder := createFolderWithACL(t, sc.sqlStore, fmt.Sprintf("Folder%v", i), sc.user, folderCase) - cmd := getCreatePanelCommand(folder.Id, fmt.Sprintf("Library Panel in Folder%v", i)) + cmd := getCreatePanelCommand(folder.ID, fmt.Sprintf("Library Panel in Folder%v", i)) sc.reqContext.Req.Body = mockRequestBody(cmd) resp := sc.service.createHandler(sc.reqContext) result := validateAndUnMarshalResponse(t, resp) @@ -317,7 +317,7 @@ func TestLibraryElementPermissions(t *testing.T) { result.Result.Meta.UpdatedBy.Name = userInDbName result.Result.Meta.UpdatedBy.AvatarURL = userInDbAvatar result.Result.Meta.FolderName = folder.Title - result.Result.Meta.FolderUID = folder.Uid + result.Result.Meta.FolderUID = folder.UID results = append(results, result.Result) } sc.reqContext.SignedInUser.OrgRole = testCase.role diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index d1f7f307b24..a497c0dfbfe 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" "github.com/grafana/grafana/pkg/infra/tracing" @@ -25,6 +26,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards/database" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" @@ -294,7 +296,7 @@ func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash } func createFolderWithACL(t *testing.T, sqlStore db.DB, title string, user user.SignedInUser, - items []folderACLItem) *models.Folder { + items []folderACLItem) *folder.Folder { t.Helper() cfg := setting.NewCfg() @@ -312,10 +314,13 @@ func createFolderWithACL(t *testing.T, sqlStore db.DB, title string, user user.S ) s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, d, dashboardStore, nil, features, folderPermissions, nil) t.Logf("Creating folder with title and UID %q", title) - folder, err := s.CreateFolder(context.Background(), &user, user.OrgID, title, title) + ctx := appcontext.WithUser(context.Background(), &user) + folder, err := s.Create(ctx, &folder.CreateFolderCommand{ + OrgID: user.OrgID, Title: title, UID: title, + }) require.NoError(t, err) - updateFolderACL(t, dashboardStore, folder.Id, items) + updateFolderACL(t, dashboardStore, folder.ID, items) return folder } @@ -456,7 +461,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo }, } - sc.folder = createFolderWithACL(t, sc.sqlStore, "ScenarioFolder", sc.user, []folderACLItem{}) + sc.folder = createFolderWithACL(t, sc.sqlStore, "ScenarioFolder", sc.user, []folderACLItem{}).ToLegacyModel() fn(t, sc) }) diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 02b18692e85..007a7ed33e5 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" "github.com/grafana/grafana/pkg/infra/tracing" @@ -22,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards/database" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/libraryelements" @@ -705,7 +707,7 @@ func createDashboard(t *testing.T, sqlStore db.DB, user *user.SignedInUser, dash } func createFolderWithACL(t *testing.T, sqlStore db.DB, title string, user *user.SignedInUser, - items []folderACLItem) *models.Folder { + items []folderACLItem) *folder.Folder { t.Helper() ac := acmock.New() @@ -720,10 +722,11 @@ func createFolderWithACL(t *testing.T, sqlStore db.DB, title string, user *user. s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, d, dashboardStore, nil, features, folderPermissions, nil) t.Logf("Creating folder with title and UID %q", title) - folder, err := s.CreateFolder(context.Background(), user, user.OrgID, title, title) + ctx := appcontext.WithUser(context.Background(), user) + folder, err := s.Create(ctx, &folder.CreateFolderCommand{OrgID: user.OrgID, Title: title, UID: title}) require.NoError(t, err) - updateFolderACL(t, dashboardStore, folder.Id, items) + updateFolderACL(t, dashboardStore, folder.ID, items) return folder } @@ -858,7 +861,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo sqlStore: sqlStore, } - sc.folder = createFolderWithACL(t, sc.sqlStore, "ScenarioFolder", sc.user, []folderACLItem{}) + sc.folder = createFolderWithACL(t, sc.sqlStore, "ScenarioFolder", sc.user, []folderACLItem{}).ToLegacyModel() fn(t, sc) }) diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index 3daf391381b..921cd630703 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -14,9 +14,11 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + gfmodels "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" @@ -24,6 +26,7 @@ import ( databasestore "github.com/grafana/grafana/pkg/services/dashboards/database" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/ngalert" @@ -122,8 +125,13 @@ func CreateTestAlertRuleWithLabels(t testing.TB, ctx context.Context, dbstore *s OrgRole: org.RoleAdmin, IsGrafanaAdmin: true, } - folder, err := dbstore.FolderService.CreateFolder(ctx, user, orgID, "FOLDER-"+util.GenerateShortUID(), folderUID) - if errors.Is(err, dashboards.ErrFolderWithSameUIDExists) || errors.Is(err, dashboards.ErrFolderVersionMismatch) { + + ctx = appcontext.WithUser(ctx, user) + f, err := dbstore.FolderService.Create(ctx, &folder.CreateFolderCommand{OrgID: orgID, Title: "FOLDER-" + util.GenerateShortUID(), UID: folderUID}) + var folder *gfmodels.Folder + if err == nil { + folder = f.ToLegacyModel() + } else if errors.Is(err, dashboards.ErrFolderWithSameUIDExists) || errors.Is(err, dashboards.ErrFolderVersionMismatch) { folder, err = dbstore.FolderService.GetFolderByUID(ctx, user, orgID, folderUID) } require.NoError(t, err) diff --git a/public/api-merged.json b/public/api-merged.json index eff155dbab4..f0aebc474db 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -5255,6 +5255,7 @@ } }, "post": { + "description": "If nested folders are enabled then it additionally expects the parent folder UID.", "tags": [ "folders" ], @@ -5362,6 +5363,7 @@ } }, "put": { + "description": "If nested folders are enabled then it optionally expects a new parent folder UID that moves the folder and\nincludes it into the response.", "tags": [ "folders" ], @@ -5409,7 +5411,7 @@ } }, "delete": { - "description": "Deletes an existing folder identified by UID along with all dashboards (and their alerts) stored in the folder. This operation cannot be reverted.", + "description": "Deletes an existing folder identified by UID along with all dashboards (and their alerts) stored in the folder. This operation cannot be reverted.\nIf nested folders are enabled then it also deletes all the subfolders.", "tags": [ "folders" ], @@ -11399,6 +11401,10 @@ "replyTo": { "type": "string" }, + "scaleFactor": { + "type": "integer", + "format": "int64" + }, "schedule": { "$ref": "#/definitions/ScheduleDTO" }, @@ -11618,8 +11624,15 @@ } }, "CreateFolderCommand": { + "description": "CreateFolderCommand captures the information required by the folder service\nto create a folder.", "type": "object", "properties": { + "description": { + "type": "string" + }, + "parent_uid": { + "type": "string" + }, "title": { "type": "string" }, @@ -11706,6 +11719,10 @@ "replyTo": { "type": "string" }, + "scaleFactor": { + "type": "integer", + "format": "int64" + }, "schedule": { "$ref": "#/definitions/ScheduleDTO" }, @@ -12051,6 +12068,9 @@ "hasAcl": { "type": "boolean" }, + "hasPublicDashboard": { + "type": "boolean" + }, "isFolder": { "type": "boolean" }, @@ -12334,11 +12354,14 @@ "title": "DataResponse contains the results from a DataQuery.", "properties": { "Error": { - "description": "Error is a property to be set if the the corresponding DataQuery has an error.", + "description": "Error is a property to be set if the corresponding DataQuery has an error.", "type": "string" }, "Frames": { "$ref": "#/definitions/Frames" + }, + "Status": { + "$ref": "#/definitions/Status" } } }, @@ -12587,7 +12610,7 @@ "type": "string" }, "DsPermissionType": { - "description": "Datasource permission\nDescription:\n`0` - No Access\n`1` - Query\nEnum: 0,1", + "description": "Datasource permission\nDescription:\n`0` - No Access\n`1` - Query\n`2` - Edit\nEnum: 0,1,2", "type": "integer", "format": "int64" }, @@ -12606,6 +12629,9 @@ "auth_password": { "$ref": "#/definitions/Secret" }, + "auth_password_file": { + "type": "string" + }, "auth_secret": { "$ref": "#/definitions/Secret" }, @@ -12963,6 +12989,10 @@ "type": "integer", "format": "int64" }, + "parent_uid": { + "description": "only used if nested folders are enabled", + "type": "string" + }, "title": { "type": "string" }, @@ -13519,6 +13549,9 @@ "smtp_auth_password": { "$ref": "#/definitions/Secret" }, + "smtp_auth_password_file": { + "type": "string" + }, "smtp_auth_secret": { "$ref": "#/definitions/Secret" }, @@ -13734,6 +13767,9 @@ "type": "integer", "format": "int64" }, + "folderUid": { + "type": "string" + }, "imported": { "type": "boolean" }, @@ -14081,6 +14117,10 @@ "type": "integer", "format": "int64" }, + "schemaVersion": { + "type": "integer", + "format": "int64" + }, "type": { "type": "string" }, @@ -16844,12 +16884,8 @@ "type": "string" }, "Status": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - } - } + "type": "integer", + "format": "int64" }, "Success": { "$ref": "#/definitions/ResponseDetails" @@ -17273,6 +17309,9 @@ "type": "string" } }, + "location": { + "type": "string" + }, "months": { "type": "array", "items": { @@ -18323,7 +18362,6 @@ } }, "alertGroup": { - "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -18515,7 +18553,6 @@ } }, "gettableSilence": { - "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -18564,13 +18601,13 @@ } }, "gettableSilences": { - "description": "GettableSilences gettable silences", "type": "array", "items": { "$ref": "#/definitions/gettableSilence" } }, "integration": { + "description": "Integration integration", "type": "object", "required": [ "name", @@ -18667,6 +18704,15 @@ } } }, + "postSilencesOKBody": { + "type": "object", + "properties": { + "silenceID": { + "description": "silence ID", + "type": "string" + } + } + }, "postableAlert": { "description": "PostableAlert postable alert", "type": "object", @@ -18705,7 +18751,6 @@ } }, "postableSilence": { - "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", @@ -18743,6 +18788,7 @@ } }, "receiver": { + "description": "Receiver receiver", "type": "object", "required": [ "active", @@ -19935,6 +19981,15 @@ "$ref": "#/definitions/QueryDataResponse" } }, + "receiversResponse": { + "description": "(empty)", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/receiver" + } + } + }, "recordingRuleResponse": { "description": "(empty)", "schema": { diff --git a/public/api-spec.json b/public/api-spec.json index dc1d00da78f..6d479bd6e5b 100644 --- a/public/api-spec.json +++ b/public/api-spec.json @@ -4608,6 +4608,7 @@ } }, "post": { + "description": "If nested folders are enabled then it additionally expects the parent folder UID.", "tags": [ "folders" ], @@ -4715,6 +4716,7 @@ } }, "put": { + "description": "If nested folders are enabled then it optionally expects a new parent folder UID that moves the folder and\nincludes it into the response.", "tags": [ "folders" ], @@ -4762,7 +4764,7 @@ } }, "delete": { - "description": "Deletes an existing folder identified by UID along with all dashboards (and their alerts) stored in the folder. This operation cannot be reverted.", + "description": "Deletes an existing folder identified by UID along with all dashboards (and their alerts) stored in the folder. This operation cannot be reverted.\nIf nested folders are enabled then it also deletes all the subfolders.", "tags": [ "folders" ], @@ -10425,6 +10427,10 @@ "replyTo": { "type": "string" }, + "scaleFactor": { + "type": "integer", + "format": "int64" + }, "schedule": { "$ref": "#/definitions/ScheduleDTO" }, @@ -10638,8 +10644,15 @@ } }, "CreateFolderCommand": { + "description": "CreateFolderCommand captures the information required by the folder service\nto create a folder.", "type": "object", "properties": { + "description": { + "type": "string" + }, + "parent_uid": { + "type": "string" + }, "title": { "type": "string" }, @@ -10726,6 +10739,10 @@ "replyTo": { "type": "string" }, + "scaleFactor": { + "type": "integer", + "format": "int64" + }, "schedule": { "$ref": "#/definitions/ScheduleDTO" }, @@ -11071,6 +11088,9 @@ "hasAcl": { "type": "boolean" }, + "hasPublicDashboard": { + "type": "boolean" + }, "isFolder": { "type": "boolean" }, @@ -11354,11 +11374,14 @@ "title": "DataResponse contains the results from a DataQuery.", "properties": { "Error": { - "description": "Error is a property to be set if the the corresponding DataQuery has an error.", + "description": "Error is a property to be set if the corresponding DataQuery has an error.", "type": "string" }, "Frames": { "$ref": "#/definitions/Frames" + }, + "Status": { + "$ref": "#/definitions/Status" } } }, @@ -11590,7 +11613,7 @@ "type": "string" }, "DsPermissionType": { - "description": "Datasource permission\nDescription:\n`0` - No Access\n`1` - Query\nEnum: 0,1", + "description": "Datasource permission\nDescription:\n`0` - No Access\n`1` - Query\n`2` - Edit\nEnum: 0,1,2", "type": "integer", "format": "int64" }, @@ -11785,6 +11808,10 @@ "type": "integer", "format": "int64" }, + "parent_uid": { + "description": "only used if nested folders are enabled", + "type": "string" + }, "title": { "type": "string" }, @@ -12080,6 +12107,9 @@ "type": "integer", "format": "int64" }, + "folderUid": { + "type": "string" + }, "imported": { "type": "boolean" }, @@ -12354,6 +12384,10 @@ "type": "integer", "format": "int64" }, + "schemaVersion": { + "type": "integer", + "format": "int64" + }, "type": { "type": "string" }, @@ -13809,12 +13843,8 @@ "type": "string" }, "Status": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - } - } + "type": "integer", + "format": "int64" }, "SuccessResponseBody": { "type": "object", diff --git a/public/openapi3.json b/public/openapi3.json index e4bbb88506d..ce3ef488c15 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -1511,6 +1511,19 @@ }, "description": "(empty)" }, + "receiversResponse": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/receiver" + }, + "type": "array" + } + } + }, + "description": "(empty)" + }, "recordingRuleResponse": { "content": { "application/json": { @@ -2790,6 +2803,10 @@ "replyTo": { "type": "string" }, + "scaleFactor": { + "format": "int64", + "type": "integer" + }, "schedule": { "$ref": "#/components/schemas/ScheduleDTO" }, @@ -3010,7 +3027,14 @@ "type": "object" }, "CreateFolderCommand": { + "description": "CreateFolderCommand captures the information required by the folder service\nto create a folder.", "properties": { + "description": { + "type": "string" + }, + "parent_uid": { + "type": "string" + }, "title": { "type": "string" }, @@ -3097,6 +3121,10 @@ "replyTo": { "type": "string" }, + "scaleFactor": { + "format": "int64", + "type": "integer" + }, "schedule": { "$ref": "#/components/schemas/ScheduleDTO" }, @@ -3442,6 +3470,9 @@ "hasAcl": { "type": "boolean" }, + "hasPublicDashboard": { + "type": "boolean" + }, "isFolder": { "type": "boolean" }, @@ -3724,11 +3755,14 @@ "description": "A map of RefIDs (unique query identifiers) to this type makes up the Responses property of a QueryDataResponse.\nThe Error property is used to allow for partial success responses from the containing QueryDataResponse.", "properties": { "Error": { - "description": "Error is a property to be set if the the corresponding DataQuery has an error.", + "description": "Error is a property to be set if the corresponding DataQuery has an error.", "type": "string" }, "Frames": { "$ref": "#/components/schemas/Frames" + }, + "Status": { + "$ref": "#/components/schemas/Status" } }, "title": "DataResponse contains the results from a DataQuery.", @@ -3979,7 +4013,7 @@ "type": "string" }, "DsPermissionType": { - "description": "Datasource permission\nDescription:\n`0` - No Access\n`1` - Query\nEnum: 0,1", + "description": "Datasource permission\nDescription:\n`0` - No Access\n`1` - Query\n`2` - Edit\nEnum: 0,1,2", "format": "int64", "type": "integer" }, @@ -3996,6 +4030,9 @@ "auth_password": { "$ref": "#/components/schemas/Secret" }, + "auth_password_file": { + "type": "string" + }, "auth_secret": { "$ref": "#/components/schemas/Secret" }, @@ -4354,6 +4391,10 @@ "format": "int64", "type": "integer" }, + "parent_uid": { + "description": "only used if nested folders are enabled", + "type": "string" + }, "title": { "type": "string" }, @@ -4910,6 +4951,9 @@ "smtp_auth_password": { "$ref": "#/components/schemas/Secret" }, + "smtp_auth_password_file": { + "type": "string" + }, "smtp_auth_secret": { "$ref": "#/components/schemas/Secret" }, @@ -5124,6 +5168,9 @@ "format": "int64", "type": "integer" }, + "folderUid": { + "type": "string" + }, "imported": { "type": "boolean" }, @@ -5471,6 +5518,10 @@ "format": "int64", "type": "integer" }, + "schemaVersion": { + "format": "int64", + "type": "integer" + }, "type": { "type": "string" }, @@ -8234,12 +8285,8 @@ "type": "string" }, "Status": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "type": "object" + "format": "int64", + "type": "integer" }, "Success": { "$ref": "#/components/schemas/ResponseDetails" @@ -8662,6 +8709,9 @@ }, "type": "array" }, + "location": { + "type": "string" + }, "months": { "items": { "type": "string" @@ -9713,7 +9763,6 @@ "type": "object" }, "alertGroup": { - "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -9905,7 +9954,6 @@ "type": "array" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -9954,13 +10002,13 @@ "type": "object" }, "gettableSilences": { - "description": "GettableSilences gettable silences", "items": { "$ref": "#/components/schemas/gettableSilence" }, "type": "array" }, "integration": { + "description": "Integration integration", "properties": { "lastNotifyAttempt": { "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", @@ -10057,6 +10105,15 @@ ], "type": "object" }, + "postSilencesOKBody": { + "properties": { + "silenceID": { + "description": "silence ID", + "type": "string" + } + }, + "type": "object" + }, "postableAlert": { "description": "PostableAlert postable alert", "properties": { @@ -10095,7 +10152,6 @@ "type": "array" }, "postableSilence": { - "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -10133,6 +10189,7 @@ "type": "object" }, "receiver": { + "description": "Receiver receiver", "properties": { "active": { "description": "active", @@ -15963,6 +16020,7 @@ ] }, "post": { + "description": "If nested folders are enabled then it additionally expects the parent folder UID.", "operationId": "createFolder", "requestBody": { "content": { @@ -16041,7 +16099,7 @@ }, "/folders/{folder_uid}": { "delete": { - "description": "Deletes an existing folder identified by UID along with all dashboards (and their alerts) stored in the folder. This operation cannot be reverted.", + "description": "Deletes an existing folder identified by UID along with all dashboards (and their alerts) stored in the folder. This operation cannot be reverted.\nIf nested folders are enabled then it also deletes all the subfolders.", "operationId": "deleteFolder", "parameters": [ { @@ -16122,6 +16180,7 @@ ] }, "put": { + "description": "If nested folders are enabled then it optionally expects a new parent folder UID that moves the folder and\nincludes it into the response.", "operationId": "updateFolder", "parameters": [ {