API: Support creating a nested folder (#58508)

* API: Support nested folder creation

* Update swagger

* fixup

* Update pkg/api/dtos/folder.go

Co-authored-by: Serge Zaitsev <serge.zaitsev@grafana.com>

* Fix some tests

* create legacy folder url from title and uid

Co-authored-by: idafurjes <36131195+idafurjes@users.noreply.github.com>
Co-authored-by: Serge Zaitsev <serge.zaitsev@grafana.com>
Co-authored-by: Ida Furjesova <ida.furjesova@grafana.com>
This commit is contained in:
Sofia Papagiannaki
2022-11-10 04:41:03 -05:00
committed by GitHub
co-authored by Serge Zaitsev idafurjes Ida Furjesova
parent b5388bb080
commit bf5a08e039
21 changed files with 452 additions and 137 deletions
+23 -25
View File
@@ -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
+69 -15
View File
@@ -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.
+10 -1
View File
@@ -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(?, ?, ?, ?, ?, ?, ?)"
Binary file not shown.
+9 -6
View File
@@ -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 {
+44 -2
View File
@@ -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,
}
}
+1 -1
View File
@@ -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