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 11:41:03 +02:00 committed by GitHub
parent b5388bb080
commit bf5a08e039
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 452 additions and 137 deletions

View File

@ -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 {

View File

@ -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

View File

@ -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()

View File

@ -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{

View File

@ -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{

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

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.

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.

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 {

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,
}
}

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

View File

@ -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())

View File

@ -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)

View File

@ -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

View File

@ -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)
})

View File

@ -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)
})

View File

@ -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)

View File

@ -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": {

View File

@ -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",

View File

@ -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": [
{